scryer 0.1.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 +7 -0
- data/README.md +367 -0
- data/exe/scryer +7 -0
- data/lib/generators/scryer/USAGE +66 -0
- data/lib/generators/scryer/install_generator.rb +29 -0
- data/lib/generators/scryer/templates/scryer_initializer.rb +28 -0
- data/lib/scryer/ai_client.rb +53 -0
- data/lib/scryer/ai_fix_suggester.rb +138 -0
- data/lib/scryer/ast.rb +277 -0
- data/lib/scryer/cache_extractor.rb +124 -0
- data/lib/scryer/cli.rb +193 -0
- data/lib/scryer/dependency_audit.rb +225 -0
- data/lib/scryer/duplicate_detector.rb +103 -0
- data/lib/scryer/finding.rb +21 -0
- data/lib/scryer/method_extractor.rb +55 -0
- data/lib/scryer/performance_rules/inefficient_save_loop_rule.rb +108 -0
- data/lib/scryer/performance_rules/missing_pagination_rule.rb +132 -0
- data/lib/scryer/performance_rules/n_plus_one_query_rule.rb +221 -0
- data/lib/scryer/performance_rules/unbounded_table_scan_rule.rb +78 -0
- data/lib/scryer/query_extractor.rb +123 -0
- data/lib/scryer/query_watcher.rb +250 -0
- data/lib/scryer/railtie.rb +12 -0
- data/lib/scryer/report_renderer.rb +546 -0
- data/lib/scryer/rule.rb +43 -0
- data/lib/scryer/rule_set.rb +19 -0
- data/lib/scryer/rules/command_injection_rule.rb +61 -0
- data/lib/scryer/rules/csrf_protection_rule.rb +89 -0
- data/lib/scryer/rules/hardcoded_secret_rule.rb +96 -0
- data/lib/scryer/rules/mass_assignment_rule.rb +103 -0
- data/lib/scryer/rules/open_redirect_rule.rb +57 -0
- data/lib/scryer/rules/sql_injection_rule.rb +63 -0
- data/lib/scryer/rules/unsafe_deserialization_rule.rb +71 -0
- data/lib/scryer/rules/weak_crypto_rule.rb +66 -0
- data/lib/scryer/rules/xss_unsafe_html_rule.rb +70 -0
- data/lib/scryer/scanner.rb +129 -0
- data/lib/scryer/version.rb +3 -0
- data/lib/scryer.rb +65 -0
- data/lib/tasks/scryer.rake +172 -0
- metadata +106 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: e14bfb1c3912e5ce4773838e90a19b9f5acd3814cf8f216052ca91784473863b
|
|
4
|
+
data.tar.gz: 9f70388a0d3537da003c1c621117df0c3bf953e0a3aaea63e8b56ea4dc3c8d1f
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 387810bd90bd1031be8c6b0b8debf3ee0ef91dfa982dfdf03afb331be07d7d41379b6e4e33fc340465c7da1e7f9d28a5eb186b2ec630826d54fb9522953085ec
|
|
7
|
+
data.tar.gz: d2adf3bb70c181e2c874354c963828fb06e6dcd69dec7417b69fe064653da1df1b9a7cc82a14e4b7dd5fe4623876978dfb4a28469ae63d87df6c54d544334455
|
data/README.md
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
# Scryer
|
|
2
|
+
|
|
3
|
+
A static code analyzer for Rails apps, built on Ruby's own `Ripper` (stdlib — no Rails or
|
|
4
|
+
`bundle install` needed to run the scan itself). Reports three things:
|
|
5
|
+
|
|
6
|
+
- **Security findings**: SQL injection via string interpolation, unpermitted mass assignment,
|
|
7
|
+
command injection, hardcoded API keys/secrets, unsafe deserialization (`Marshal.load`/
|
|
8
|
+
`YAML.load`/`JSON.load`), unescaped-HTML XSS risk (`.html_safe`/`raw`), CSRF protection gaps,
|
|
9
|
+
weak password hashing, and open redirects.
|
|
10
|
+
- **Duplicate code**: near-duplicate methods across the codebase, via token-normalized similarity
|
|
11
|
+
(renamed variables/changed literals still count as "the same shape").
|
|
12
|
+
- **Performance heuristics**: likely N+1 queries, missing pagination on index actions,
|
|
13
|
+
inefficient per-record save loops, and unbounded full-table iteration.
|
|
14
|
+
|
|
15
|
+
Several more things live alongside the static scan, each documented in its own section below:
|
|
16
|
+
|
|
17
|
+
- **Skipping rules** ([Skipping rules](#skipping-rules)): silence a specific rule by `rule_id`
|
|
18
|
+
(config-wide via `c.skip_rules`, or one-off via `--skip`) without editing or deleting it.
|
|
19
|
+
- A **runtime query watcher** ([Runtime query watcher](#runtime-query-watcher)) that instruments a
|
|
20
|
+
*running* app to catch N+1 queries and unused eager loading as they actually happen — the same
|
|
21
|
+
broad goal as [Bullet](https://github.com/flyerhzm/bullet), built independently on a different
|
|
22
|
+
mechanism.
|
|
23
|
+
- A **dependency audit** ([Dependency audit](#dependency-audit)) that checks `Gemfile.lock` against
|
|
24
|
+
[OSV.dev](https://osv.dev) for known-vulnerable gem versions and insecure sources — the same
|
|
25
|
+
broad goal as [bundler-audit](https://github.com/rubysec/bundler-audit), built independently on a
|
|
26
|
+
different data source.
|
|
27
|
+
- **AI-assisted fix suggestions** ([AI-assisted fix suggestions](#ai-assisted-fix-suggestions)),
|
|
28
|
+
optional, provider-agnostic: rewrites each finding's `suggested_fix` against its actual code
|
|
29
|
+
using an LLM you configure — any LLM, not a specific vendor.
|
|
30
|
+
|
|
31
|
+
Every finding includes a `suggested_fix` — human-reviewable text explaining the issue with an
|
|
32
|
+
example. **Nothing is auto-applied.** A security or performance fix needs a human's judgment
|
|
33
|
+
about the surrounding code; this gem's job is to point at the issue and explain it clearly, not
|
|
34
|
+
to rewrite your files. This holds whether `suggested_fix` came from a rule's static template or
|
|
35
|
+
from the optional AI enrichment below — either way, it's text for a human to read and act on.
|
|
36
|
+
|
|
37
|
+
## A note on how this gem was actually verified
|
|
38
|
+
|
|
39
|
+
Unlike most hand-written Rails work, this gem's core scanning engine was genuinely executed
|
|
40
|
+
during development — `Ripper` needs no Rails or bundler to run, so every rule here was tested
|
|
41
|
+
against real vulnerable-code and clean/fixed-code fixtures, not just written and hoped for. Where
|
|
42
|
+
a rule produced a false positive during that testing (an actual one did — `BCrypt::Password.create`
|
|
43
|
+
initially tripped the mass-assignment rule meant for `Model.create(params)`), it was caught and
|
|
44
|
+
fixed before being considered done. That's a meaningfully higher bar of confidence than most of
|
|
45
|
+
this account's Rails-app work, which can't be executed in the sandbox it was built in.
|
|
46
|
+
|
|
47
|
+
That said: this is a heuristic tool, not full data-flow/taint analysis (that's what a mature tool
|
|
48
|
+
like Brakeman spends years building). Expect some false positives and false negatives — that's
|
|
49
|
+
normal for this class of tool, not a bug. An already-guarded call can still get flagged since
|
|
50
|
+
rules don't trace surrounding conditionals — always review a finding in its surrounding context
|
|
51
|
+
before acting on it.
|
|
52
|
+
|
|
53
|
+
## Install
|
|
54
|
+
|
|
55
|
+
Scryer is a development-time analysis tool, not something a running production process needs —
|
|
56
|
+
scope it to the `:development` group so a production deploy (typically `bundle install --without
|
|
57
|
+
development`, or `BUNDLE_WITHOUT=development` in CI) never installs it at all:
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
# Gemfile
|
|
61
|
+
group :development do
|
|
62
|
+
gem "scryer"
|
|
63
|
+
end
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
bundle install
|
|
68
|
+
bin/rails generate scryer:install
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
This drops a commented `config/initializers/scryer.rb` into your app — see
|
|
72
|
+
[Generators](#generators) below for exactly what it does and what each setting controls. Scryer
|
|
73
|
+
is entirely local: there is nothing to point at a server and nothing to authenticate, so the
|
|
74
|
+
generator's only job is letting you override the defaults (project name, which directories get
|
|
75
|
+
scanned, the branch label).
|
|
76
|
+
|
|
77
|
+
## Running a scan
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
bin/rails scryer:report # writes tmp/scryer_report.{json,html}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
By default this writes both `tmp/scryer_report.json` and `tmp/scryer_report.html`. Both format
|
|
84
|
+
and output path are configurable via task args — any mix of `json`, `html`, `csv` plus at most one
|
|
85
|
+
path (quote the whole thing so your shell doesn't eat the brackets/commas, e.g.
|
|
86
|
+
`bin/rails 'scryer:report[json,doc/security_report.json]'`). Rake splits bracket args on every
|
|
87
|
+
comma, so each format is its own item in the list rather than one comma-joined string
|
|
88
|
+
(`scryer:report[json,html]` is two args, not `"json,html"` as one). At most one non-format token
|
|
89
|
+
is accepted per call; giving two raises an error rather than guessing which one you meant.
|
|
90
|
+
|
|
91
|
+
Outside a Rails app (or in CI, or anywhere you don't want a rake task), the gem also ships a
|
|
92
|
+
`scryer` executable — same idea as `brakeman -o report.json`, with `-o` repeatable and format
|
|
93
|
+
inferred per-file from its extension:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
scryer # scans ., writes tmp/scryer_report.{json,html}
|
|
97
|
+
scryer -o report.json # just one file, exact path, format from extension
|
|
98
|
+
scryer -o report.json -o report.html # as many outputs as you like, one -o each
|
|
99
|
+
scryer -p /path/to/app -o /tmp/out.html # -p sets the root to scan (default: cwd)
|
|
100
|
+
scryer --help # full option list (--project-name, --branch, --version, ...)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
It exits `0` when the scan is clean and `1` when there's at least one security finding, so
|
|
104
|
+
`scryer -o report.json` can gate a CI job the same way `brakeman -o report.json` does. A `2` exit
|
|
105
|
+
means a usage error (bad flag, unrecognized output extension) rather than anything about the scan
|
|
106
|
+
itself.
|
|
107
|
+
|
|
108
|
+
Or use the scanning engine directly as a library:
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
require "scryer"
|
|
112
|
+
result = Scryer::Scanner.new(root: "/path/to/app").call
|
|
113
|
+
puts result.security_findings.size
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Open `tmp/scryer_report.html` in a browser — it's a single self-contained file (inline CSS, no
|
|
117
|
+
external assets, works offline) with an overview, a summary of counts by severity, the full list
|
|
118
|
+
of checks that ran, a breakdown of warnings by type, every finding in detail, and duplicate-code
|
|
119
|
+
groups — laid out similarly to a Brakeman report. `tmp/scryer_report.json` has the same data in
|
|
120
|
+
machine-readable form, for feeding into your own dashboard or CI gate. A third format, `.csv`
|
|
121
|
+
(`scryer -o report.csv` / `rails 'scryer:report[csv]'`), is a flat one-row-per-finding table
|
|
122
|
+
(security + performance findings, plus dependency findings if `--include-deps`/`deps` was used) —
|
|
123
|
+
handy for dropping into a spreadsheet or importing into a ticketing tool. It skips duplicate-code
|
|
124
|
+
groups, which don't reduce to a single actionable row.
|
|
125
|
+
|
|
126
|
+
## What gets scanned
|
|
127
|
+
|
|
128
|
+
By default: `app/`, `lib/`, `config/`, `db/` (relative to the app root) — configurable via
|
|
129
|
+
`c.dirs` in the initializer. `vendor/`, `node_modules/`, `tmp/`, `log/`, `.git/`, `spec/`, `test/`
|
|
130
|
+
are always skipped.
|
|
131
|
+
|
|
132
|
+
## Branch reporting
|
|
133
|
+
|
|
134
|
+
By default the `git_branch` recorded in the report comes from `git rev-parse --abbrev-ref HEAD` in
|
|
135
|
+
the app root. Set `c.branch` in the initializer to override this — useful in CI, where checkouts
|
|
136
|
+
often land in a detached-HEAD state and there's no real branch name to detect:
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
Scryer.configure do |c|
|
|
140
|
+
c.branch = ENV["CI_COMMIT_BRANCH"]
|
|
141
|
+
end
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
This only changes the label shown in the report; it does not check out a different branch or
|
|
145
|
+
restrict which branch can be scanned.
|
|
146
|
+
|
|
147
|
+
## Skipping rules
|
|
148
|
+
|
|
149
|
+
Every rule is heuristic, so an occasional false positive on your specific codebase is expected
|
|
150
|
+
(see "A note on how this gem was actually verified" above) — `skip_rules` silences one by
|
|
151
|
+
`rule_id` without editing or deleting the rule itself, so it still runs (and could still catch a
|
|
152
|
+
real issue) on every other codebase that uses this gem:
|
|
153
|
+
|
|
154
|
+
```ruby
|
|
155
|
+
# config/initializers/scryer.rb
|
|
156
|
+
Scryer.configure do |c|
|
|
157
|
+
c.skip_rules = ["mass_assignment"] # rule_id, from the finding's `rule_id` field
|
|
158
|
+
end
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
The `scryer` executable's `--skip RULE_ID` flag (repeatable) adds to this list for a single run
|
|
162
|
+
without touching the initializer — useful for a one-off "does this go away without rule X" check:
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
scryer --skip mass_assignment --skip weak_crypto
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Runtime query watcher
|
|
169
|
+
|
|
170
|
+
Everything above is a one-shot static scan — it reads source, never boots Rails, and can't see
|
|
171
|
+
what actually happens at request time. `Scryer::QueryWatcher` is different: it's an opt-in
|
|
172
|
+
runtime instrumentation that watches a *running* app for two of the problems
|
|
173
|
+
[Bullet](https://github.com/flyerhzm/bullet) is best known for catching — a collection query
|
|
174
|
+
followed by one repeat query per row ("N+1"), and an `.includes`/`.preload`/`.eager_load`
|
|
175
|
+
association that gets fetched but never actually read ("unused eager loading"). It was built
|
|
176
|
+
independently of Bullet, on a different mechanism (SQL-shape correlation via
|
|
177
|
+
`ActiveSupport::Notifications`, plus a `Module#prepend` on `ActiveRecord::QueryMethods`'s eager-load
|
|
178
|
+
methods and `Association#reader` — not Bullet's own per-request association bookkeeping); no
|
|
179
|
+
Bullet source was read or copied to build it.
|
|
180
|
+
|
|
181
|
+
Enable it from an initializer (typically gated to development/test, though nothing stops you from
|
|
182
|
+
running it in production if you want the noise):
|
|
183
|
+
|
|
184
|
+
```ruby
|
|
185
|
+
# config/initializers/scryer.rb
|
|
186
|
+
if Rails.env.development? || Rails.env.test?
|
|
187
|
+
require "scryer/query_watcher"
|
|
188
|
+
Scryer::QueryWatcher.enable!
|
|
189
|
+
Rails.application.config.middleware.use Scryer::QueryWatcher::Middleware
|
|
190
|
+
end
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
The middleware opens one tracking scope per request and logs (via `Rails.logger` by default —
|
|
194
|
+
override with `Scryer::QueryWatcher.enable!(logger: ...)`) anything it finds when the request
|
|
195
|
+
ends. Outside a request — a Sidekiq job, a rake task, a console session — wrap the code yourself:
|
|
196
|
+
|
|
197
|
+
```ruby
|
|
198
|
+
findings = Scryer::QueryWatcher.watch { SomeJob.new.perform }
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
`enable!(n_plus_one_threshold: 2)` controls how many repeats of the same query shape from the same
|
|
202
|
+
call site count as N+1 (default: the second occurrence already means one collection load produced
|
|
203
|
+
more than one query). Each finding is a `Scryer::QueryWatcher::Finding` with `kind`
|
|
204
|
+
(`n_plus_one_query_runtime` or `unused_eager_load`), `message`, `call_site`, `count`, and
|
|
205
|
+
`suggested_fix` — the same shape as everything else in this gem, so it's straightforward to feed
|
|
206
|
+
into your own logging/alerting instead of (or alongside) the built-in logger call.
|
|
207
|
+
|
|
208
|
+
This is genuinely runtime-only: with no queries running, it finds nothing, and it never appears in
|
|
209
|
+
the static `tmp/scryer_report.html` output. Think of it as this gem's answer to "what actually
|
|
210
|
+
happened during this request", where the rest of Scryer answers "what does this code look like".
|
|
211
|
+
|
|
212
|
+
## Dependency audit
|
|
213
|
+
|
|
214
|
+
`Scryer::DependencyAudit` checks `Gemfile.lock` for the same broad concerns
|
|
215
|
+
[bundler-audit](https://github.com/rubysec/bundler-audit) targets — known-vulnerable gem versions
|
|
216
|
+
and insecure dependency sources — built independently on a different data source and a different
|
|
217
|
+
lockfile parser: rather than bundler-audit's local clone of the `ruby-advisory-db` git repo, this
|
|
218
|
+
queries [OSV.dev](https://osv.dev) (Google's Open Source Vulnerabilities database) live, one
|
|
219
|
+
lookup per gem+version, over a small stdlib-only thread pool; `Gemfile.lock` is read with a small
|
|
220
|
+
hand-rolled parser (see `DependencyAudit.parse_lockfile`) instead of depending on the `bundler`
|
|
221
|
+
library, so it works the same whether or not this happens to run under Bundler. No bundler-audit
|
|
222
|
+
source was read or copied to build this.
|
|
223
|
+
|
|
224
|
+
```bash
|
|
225
|
+
bin/rails scryer:audit_dependencies # inside a Rails app
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
This is the one part of Scryer that needs a live network connection (to reach OSV.dev) — so
|
|
229
|
+
unlike the static scan, it's not part of the default `scryer`/`scryer:report` run; call it
|
|
230
|
+
explicitly, the same way `bundle-audit check` is a separate command from your test suite. It exits
|
|
231
|
+
non-zero if anything is found, so it can gate CI.
|
|
232
|
+
|
|
233
|
+
Two checks run, and either can be called on its own as a library:
|
|
234
|
+
|
|
235
|
+
```ruby
|
|
236
|
+
Scryer::DependencyAudit.insecure_sources(Rails.root.to_s) # offline — no network needed
|
|
237
|
+
Scryer::DependencyAudit.vulnerable_gems(Rails.root.to_s) # needs network (OSV.dev)
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
- **Insecure sources**: flags any `GIT`/`PATH` block in `Gemfile.lock` whose `remote:` is
|
|
241
|
+
unencrypted (`git://` or plain `http://`) — the same supply-chain concern bundler-audit's
|
|
242
|
+
insecure-source check targets.
|
|
243
|
+
- **Vulnerable gems**: for every RubyGems-sourced gem (git/path-sourced gems are skipped — their
|
|
244
|
+
version string doesn't necessarily correspond to the same code as the published gem of that
|
|
245
|
+
name, so checking them against RubyGems advisories could misattribute or miss vulnerabilities),
|
|
246
|
+
queries OSV.dev for known vulnerabilities affecting that exact version. Each finding carries the
|
|
247
|
+
advisory id, title, a severity bucketed from OSV's own severity level, a link to the advisory,
|
|
248
|
+
and the fixed version(s) to upgrade to.
|
|
249
|
+
|
|
250
|
+
**From the `scryer` executable** (outside a Rails app, or in CI): `scryer --audit-deps` runs the
|
|
251
|
+
same two checks standalone, same output/exit-code behavior as the rake task. `scryer --include-deps`
|
|
252
|
+
folds them into the normal `-o` report instead — one HTML/JSON/CSV file covering static findings
|
|
253
|
+
*and* dependency findings together. And for a single gem, without touching `Gemfile.lock` at all:
|
|
254
|
+
|
|
255
|
+
```bash
|
|
256
|
+
scryer --check-gem rack # every advisory ever filed against rack, any version
|
|
257
|
+
scryer --check-gem rack:2.0.8 # only advisories affecting exactly this version
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
This is the same OSV.dev client `vulnerable_gems` uses, exposed as a one-off lookup — useful for
|
|
261
|
+
"is this gem I'm about to add actually fine" before it's even in your `Gemfile.lock`.
|
|
262
|
+
|
|
263
|
+
## AI-assisted fix suggestions
|
|
264
|
+
|
|
265
|
+
Every rule already ships a generic, human-reviewable `suggested_fix` — that's always there and
|
|
266
|
+
needs nothing configured. `Scryer::AiFixSuggester` optionally rewrites that text per finding using
|
|
267
|
+
an LLM, so the suggestion is written against the finding's actual offending line instead of a
|
|
268
|
+
generic template.
|
|
269
|
+
|
|
270
|
+
**Provider-agnostic by design — bring any LLM.** Scryer doesn't depend on or assume any specific
|
|
271
|
+
vendor's API or SDK (consistent with the zero-runtime-dependency design described in the gemspec).
|
|
272
|
+
Configure `c.ai_client` to any object, or even a bare `Proc`/lambda, that responds to `#call(prompt)`
|
|
273
|
+
(or `#complete(prompt)`) and returns the model's reply as a `String`:
|
|
274
|
+
|
|
275
|
+
```ruby
|
|
276
|
+
# config/initializers/scryer.rb
|
|
277
|
+
Scryer.configure do |c|
|
|
278
|
+
# Simplest form: any callable. Wire up whatever SDK/gem you already use —
|
|
279
|
+
# Scryer never requires one itself.
|
|
280
|
+
c.ai_client = ->(prompt) { MyLlmClient.chat(prompt) }
|
|
281
|
+
end
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
This is entirely opt-in: `c.ai_client` is `nil` by default, and with no client configured
|
|
285
|
+
`AiFixSuggester` makes zero network calls and every finding keeps its original `suggested_fix`.
|
|
286
|
+
|
|
287
|
+
### `Scryer::AiClient` — a ready-made HTTP adapter
|
|
288
|
+
|
|
289
|
+
For the common case of a JSON/HTTP chat endpoint, `Scryer::AiClient` saves writing the request
|
|
290
|
+
plumbing by hand. It takes the two pieces of vendor-specific shape as plain `Proc`s and handles the
|
|
291
|
+
HTTP call itself (stdlib `Net::HTTP`, no gem):
|
|
292
|
+
|
|
293
|
+
```ruby
|
|
294
|
+
# Claude (Messages API)
|
|
295
|
+
Scryer.configure do |c|
|
|
296
|
+
c.ai_client = Scryer::AiClient.new(
|
|
297
|
+
url: "https://api.anthropic.com/v1/messages",
|
|
298
|
+
headers: { "x-api-key" => ENV.fetch("ANTHROPIC_API_KEY"), "anthropic-version" => "2023-06-01" },
|
|
299
|
+
build_request: ->(prompt) { { model: "claude-opus-5", max_tokens: 1024, messages: [{ role: "user", content: prompt }] } },
|
|
300
|
+
parse_response: ->(json) { json.dig("content", 0, "text") }
|
|
301
|
+
)
|
|
302
|
+
end
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
```ruby
|
|
306
|
+
# Any OpenAI-compatible chat completions endpoint (OpenAI itself, a local
|
|
307
|
+
# Ollama/vLLM server, Azure OpenAI, ...) — same adapter, different shape.
|
|
308
|
+
Scryer.configure do |c|
|
|
309
|
+
c.ai_client = Scryer::AiClient.new(
|
|
310
|
+
url: "https://api.openai.com/v1/chat/completions",
|
|
311
|
+
headers: { "Authorization" => "Bearer #{ENV.fetch('OPENAI_API_KEY')}" },
|
|
312
|
+
build_request: ->(prompt) { { model: "gpt-4o-mini", messages: [{ role: "user", content: prompt }] } },
|
|
313
|
+
parse_response: ->(json) { json.dig("choices", 0, "message", "content") }
|
|
314
|
+
)
|
|
315
|
+
end
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
Neither example pins Scryer to that vendor — `build_request`/`parse_response` are just data telling
|
|
319
|
+
`AiClient` how to shape one HTTP call; point it at any endpoint that takes a JSON body and returns
|
|
320
|
+
a JSON body.
|
|
321
|
+
|
|
322
|
+
### What happens with it configured
|
|
323
|
+
|
|
324
|
+
`bin/rails scryer:report` and the `scryer` executable both check `Scryer.configuration.ai_client`
|
|
325
|
+
after scanning and, if set, call `Scryer::AiFixSuggester.enhance_result!(result)` before rendering
|
|
326
|
+
— one LLM call per security/performance finding, run across a small thread pool (same pattern as
|
|
327
|
+
the dependency audit's OSV.dev lookups) rather than one at a time. A client that raises, times out,
|
|
328
|
+
or returns nothing usable just leaves that finding's original `suggested_fix` in place — a failed
|
|
329
|
+
enrichment never fails the scan.
|
|
330
|
+
|
|
331
|
+
```ruby
|
|
332
|
+
Scryer::AiFixSuggester.enhance!(finding) # one Finding, in place
|
|
333
|
+
Scryer::AiFixSuggester.enhance_result!(result) # every finding on a Scanner::Result, in place
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
**This sends code snippets to whatever endpoint you configure.** `code_snippet`, `message`, and the
|
|
337
|
+
file path are included in the prompt — the same privacy consideration as any third-party service:
|
|
338
|
+
don't point this at an endpoint you don't trust with your source, and be mindful this is a second
|
|
339
|
+
place (besides the HTML report itself) where finding detail leaves your machine.
|
|
340
|
+
|
|
341
|
+
## Generators
|
|
342
|
+
|
|
343
|
+
Scryer ships one generator: `scryer:install`. Run `bin/rails generate scryer:install --help`
|
|
344
|
+
in a host app for the full description (also in
|
|
345
|
+
[lib/generators/scryer/USAGE](lib/generators/scryer/USAGE)); short version:
|
|
346
|
+
|
|
347
|
+
- **What it creates**: a single file, `config/initializers/scryer.rb`, templated from
|
|
348
|
+
[lib/generators/scryer/templates/scryer_initializer.rb](lib/generators/scryer/templates/scryer_initializer.rb).
|
|
349
|
+
Nothing else — no routes, no migrations, no controllers/views.
|
|
350
|
+
- **What it doesn't do**: the rake tasks (`scryer:report`) are registered by
|
|
351
|
+
[Scryer::Railtie](lib/scryer/railtie.rb) as soon as the gem is in your `Gemfile`, whether or
|
|
352
|
+
not you ever run this generator. The generator exists purely to give you an editable config file.
|
|
353
|
+
- **Settings it exposes**: `c.project_name` (report header label), `c.dirs` (which top-level
|
|
354
|
+
directories get scanned), `c.branch` (override the git branch label — see
|
|
355
|
+
[Branch reporting](#branch-reporting)). All are optional; the commented-out initializer works
|
|
356
|
+
as-is with just `bundle install` + the generator.
|
|
357
|
+
- **Re-running it**: standard Thor/Rails::Generators behavior — if
|
|
358
|
+
`config/initializers/scryer.rb` already exists, you'll be prompted to overwrite, skip, or diff
|
|
359
|
+
rather than have it silently clobbered.
|
|
360
|
+
|
|
361
|
+
## Extending it
|
|
362
|
+
|
|
363
|
+
Every rule is a small class extending `Scryer::Rule` — see `lib/scryer/rules/*.rb`
|
|
364
|
+
(security) and `lib/scryer/performance_rules/*.rb` (performance) for the pattern. A new
|
|
365
|
+
rule file dropped into either directory is picked up automatically (rules self-register via
|
|
366
|
+
`Rule.inherited` — no manual wiring needed). `Scryer::Ast` has the tree-walking
|
|
367
|
+
helpers used throughout.
|
data/exe/scryer
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
Description:
|
|
2
|
+
Installs Scryer into a Rails application by creating a commented
|
|
3
|
+
config/initializers/scryer.rb initializer.
|
|
4
|
+
|
|
5
|
+
Scryer itself needs no configuration to run — the initializer only
|
|
6
|
+
exists so you can override defaults. It does not create migrations,
|
|
7
|
+
routes, controllers, or views: this generator's only side effect is
|
|
8
|
+
the one file it creates. The rake tasks (`scryer:report`) are made
|
|
9
|
+
available automatically by Scryer's Railtie the moment the gem is
|
|
10
|
+
in your Gemfile, with or without running this generator.
|
|
11
|
+
|
|
12
|
+
What you get in config/initializers/scryer.rb, and what each option
|
|
13
|
+
controls:
|
|
14
|
+
|
|
15
|
+
c.project_name - Label shown in the report header. Defaults to the
|
|
16
|
+
app's directory name if left unset.
|
|
17
|
+
|
|
18
|
+
c.dirs - Which top-level directories (relative to Rails.root)
|
|
19
|
+
get scanned. Defaults to %w[app lib config db].
|
|
20
|
+
vendor/, node_modules/, tmp/, log/, .git/, spec/,
|
|
21
|
+
and test/ are always skipped regardless of this
|
|
22
|
+
setting.
|
|
23
|
+
|
|
24
|
+
c.branch - Overrides the git branch name recorded in the
|
|
25
|
+
report. Only useful in CI, where checkouts often
|
|
26
|
+
land in a detached-HEAD state and
|
|
27
|
+
`git rev-parse --abbrev-ref HEAD` returns nothing
|
|
28
|
+
useful. Leave unset to use the actual checked-out
|
|
29
|
+
branch.
|
|
30
|
+
|
|
31
|
+
Two more things ship in this gem but aren't controlled by the
|
|
32
|
+
initializer — see the README for both:
|
|
33
|
+
|
|
34
|
+
- Scryer::QueryWatcher: an opt-in runtime instrumentation for N+1
|
|
35
|
+
queries and unused eager loading (README: "Runtime query watcher").
|
|
36
|
+
- `bin/rails scryer:audit_dependencies`: a Gemfile.lock vulnerability
|
|
37
|
+
+ insecure-source audit against OSV.dev (README: "Dependency audit").
|
|
38
|
+
|
|
39
|
+
Example:
|
|
40
|
+
bin/rails generate scryer:install
|
|
41
|
+
|
|
42
|
+
This creates:
|
|
43
|
+
config/initializers/scryer.rb
|
|
44
|
+
|
|
45
|
+
After running it:
|
|
46
|
+
bin/rails scryer:report
|
|
47
|
+
|
|
48
|
+
writes tmp/scryer_report.json and tmp/scryer_report.html — open the
|
|
49
|
+
.html file in a browser to review findings (security, performance,
|
|
50
|
+
duplicate code), grouped and summarized similarly to a Brakeman report.
|
|
51
|
+
|
|
52
|
+
Format and output path are both configurable via task args — any mix of
|
|
53
|
+
json/html, plus the 'deps' token to fold a dependency audit in, plus at
|
|
54
|
+
most one path (quote the whole thing so your shell doesn't eat the
|
|
55
|
+
brackets/commas):
|
|
56
|
+
|
|
57
|
+
bin/rails 'scryer:report[json]' # just JSON, under tmp/
|
|
58
|
+
bin/rails 'scryer:report[json,doc/security.json]' # JSON to an exact file
|
|
59
|
+
bin/rails 'scryer:report[json,html]' # both, under tmp/
|
|
60
|
+
bin/rails 'scryer:report[json,html,reports]' # both, under reports/
|
|
61
|
+
bin/rails 'scryer:report[html,deps]' # HTML + a dependency audit section
|
|
62
|
+
|
|
63
|
+
Re-running `bin/rails generate scryer:install` after the initializer
|
|
64
|
+
already exists follows normal Thor/Rails::Generators behavior: it will
|
|
65
|
+
prompt to overwrite, skip, or diff the existing file rather than
|
|
66
|
+
silently clobbering your edits.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
|
|
3
|
+
module Scryer
|
|
4
|
+
module Generators
|
|
5
|
+
# `rails generate scryer:install` — drops a commented initializer
|
|
6
|
+
# into the host app. Doesn't touch anything else; the rake tasks are
|
|
7
|
+
# already loaded automatically by the Railtie. See
|
|
8
|
+
# lib/generators/scryer/USAGE for the detailed description shown by
|
|
9
|
+
# `bin/rails generate scryer:install --help`.
|
|
10
|
+
class InstallGenerator < Rails::Generators::Base
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
desc "Creates a config/initializers/scryer.rb initializer for static code analysis (security, duplicate code, performance heuristics)."
|
|
14
|
+
|
|
15
|
+
def create_initializer
|
|
16
|
+
template "scryer_initializer.rb", "config/initializers/scryer.rb"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def show_readme
|
|
20
|
+
say ""
|
|
21
|
+
say "Scryer installed. Next steps:", :green
|
|
22
|
+
say " 1. (Optional) Edit config/initializers/scryer.rb to set project_name, dirs, or branch."
|
|
23
|
+
say " 2. Run `bin/rails scryer:report` to scan this app and write tmp/scryer_report.{json,html}."
|
|
24
|
+
say " Add a dependency audit or a custom path with task args, e.g. `bin/rails 'scryer:report[html,deps]'`."
|
|
25
|
+
say " 3. Open tmp/scryer_report.html in a browser to review findings — see README.md for what each section means."
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Configuration for the scryer gem (static code analysis: security,
|
|
2
|
+
# duplicate code, performance heuristics). Run `bin/rails scryer:report`
|
|
3
|
+
# to scan this app and generate tmp/scryer_report.{json,html} locally.
|
|
4
|
+
|
|
5
|
+
Scryer.configure do |c|
|
|
6
|
+
c.project_name = "my-rails-app"
|
|
7
|
+
|
|
8
|
+
# Which top-level directories to scan (relative to Rails.root). Defaults
|
|
9
|
+
# to app/, lib/, config/, db/ — uncomment to customize:
|
|
10
|
+
# c.dirs = %w[app lib config db]
|
|
11
|
+
|
|
12
|
+
# The branch recorded in the report as this scan's git_branch, overriding
|
|
13
|
+
# whatever `git rev-parse --abbrev-ref HEAD` finds locally (useful in CI
|
|
14
|
+
# where checkouts often land in detached-HEAD state). Leave blank to use
|
|
15
|
+
# the actual checked-out branch:
|
|
16
|
+
# c.branch = ENV["CI_COMMIT_BRANCH"]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Runtime query watcher (N+1 / unused eager loading — see README's "Runtime
|
|
20
|
+
# query watcher"). Off by default; uncomment to enable in this environment.
|
|
21
|
+
# Unlike the static scan above, this instruments a *running* app, so it's
|
|
22
|
+
# typically only turned on for development/test:
|
|
23
|
+
#
|
|
24
|
+
# if Rails.env.development? || Rails.env.test?
|
|
25
|
+
# require "scryer/query_watcher"
|
|
26
|
+
# Scryer::QueryWatcher.enable!
|
|
27
|
+
# Rails.application.config.middleware.use Scryer::QueryWatcher::Middleware
|
|
28
|
+
# end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "net/http"
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Scryer
|
|
6
|
+
# A small, provider-agnostic HTTP adapter for wiring *any* LLM into
|
|
7
|
+
# AiFixSuggester — Claude, OpenAI, a self-hosted Ollama/vLLM server, or
|
|
8
|
+
# anything else that takes a JSON request and returns a JSON response over
|
|
9
|
+
# HTTP. Scryer ships no vendor SDKs (see the gemspec's zero-runtime-
|
|
10
|
+
# dependency design); this adapter is configured with just the two pieces
|
|
11
|
+
# of vendor-specific shape it needs, both plain Procs:
|
|
12
|
+
#
|
|
13
|
+
# - build_request: prompt (String) -> request body (Hash)
|
|
14
|
+
# - parse_response: parsed JSON response (Hash) -> the model's reply (String)
|
|
15
|
+
#
|
|
16
|
+
# Everything else (the HTTP call, headers, timeouts) is generic. See the
|
|
17
|
+
# README's "AI-assisted fix suggestions" section for ready-made
|
|
18
|
+
# build_request/parse_response pairs for a couple of common APIs.
|
|
19
|
+
#
|
|
20
|
+
# AiFixSuggester itself doesn't require this class at all — any object
|
|
21
|
+
# (or bare Proc/lambda) responding to #call(prompt) and returning a String
|
|
22
|
+
# works as Scryer.configuration.ai_client. This class just saves writing
|
|
23
|
+
# the HTTP plumbing by hand for the common case of a JSON chat endpoint.
|
|
24
|
+
class AiClient
|
|
25
|
+
def initialize(url:, build_request:, parse_response:, headers: {}, open_timeout: 5, read_timeout: 30)
|
|
26
|
+
@uri = URI(url)
|
|
27
|
+
@headers = { "Content-Type" => "application/json" }.merge(headers)
|
|
28
|
+
@build_request = build_request
|
|
29
|
+
@parse_response = parse_response
|
|
30
|
+
@open_timeout = open_timeout
|
|
31
|
+
@read_timeout = read_timeout
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Returns the model's reply as a String, or nil if the request failed or
|
|
35
|
+
# the response couldn't be parsed. Never raises — AiFixSuggester treats
|
|
36
|
+
# a nil/blank reply the same as "no client configured" and leaves the
|
|
37
|
+
# finding's original suggested_fix untouched.
|
|
38
|
+
def call(prompt)
|
|
39
|
+
body = JSON.generate(@build_request.call(prompt))
|
|
40
|
+
|
|
41
|
+
response = Net::HTTP.start(@uri.host, @uri.port, use_ssl: @uri.scheme == "https",
|
|
42
|
+
open_timeout: @open_timeout, read_timeout: @read_timeout) do |http|
|
|
43
|
+
http.post(@uri.request_uri, body, @headers)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
return nil unless response.is_a?(Net::HTTPSuccess)
|
|
47
|
+
|
|
48
|
+
@parse_response.call(JSON.parse(response.body))
|
|
49
|
+
rescue StandardError
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|