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.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +367 -0
  3. data/exe/scryer +7 -0
  4. data/lib/generators/scryer/USAGE +66 -0
  5. data/lib/generators/scryer/install_generator.rb +29 -0
  6. data/lib/generators/scryer/templates/scryer_initializer.rb +28 -0
  7. data/lib/scryer/ai_client.rb +53 -0
  8. data/lib/scryer/ai_fix_suggester.rb +138 -0
  9. data/lib/scryer/ast.rb +277 -0
  10. data/lib/scryer/cache_extractor.rb +124 -0
  11. data/lib/scryer/cli.rb +193 -0
  12. data/lib/scryer/dependency_audit.rb +225 -0
  13. data/lib/scryer/duplicate_detector.rb +103 -0
  14. data/lib/scryer/finding.rb +21 -0
  15. data/lib/scryer/method_extractor.rb +55 -0
  16. data/lib/scryer/performance_rules/inefficient_save_loop_rule.rb +108 -0
  17. data/lib/scryer/performance_rules/missing_pagination_rule.rb +132 -0
  18. data/lib/scryer/performance_rules/n_plus_one_query_rule.rb +221 -0
  19. data/lib/scryer/performance_rules/unbounded_table_scan_rule.rb +78 -0
  20. data/lib/scryer/query_extractor.rb +123 -0
  21. data/lib/scryer/query_watcher.rb +250 -0
  22. data/lib/scryer/railtie.rb +12 -0
  23. data/lib/scryer/report_renderer.rb +546 -0
  24. data/lib/scryer/rule.rb +43 -0
  25. data/lib/scryer/rule_set.rb +19 -0
  26. data/lib/scryer/rules/command_injection_rule.rb +61 -0
  27. data/lib/scryer/rules/csrf_protection_rule.rb +89 -0
  28. data/lib/scryer/rules/hardcoded_secret_rule.rb +96 -0
  29. data/lib/scryer/rules/mass_assignment_rule.rb +103 -0
  30. data/lib/scryer/rules/open_redirect_rule.rb +57 -0
  31. data/lib/scryer/rules/sql_injection_rule.rb +63 -0
  32. data/lib/scryer/rules/unsafe_deserialization_rule.rb +71 -0
  33. data/lib/scryer/rules/weak_crypto_rule.rb +66 -0
  34. data/lib/scryer/rules/xss_unsafe_html_rule.rb +70 -0
  35. data/lib/scryer/scanner.rb +129 -0
  36. data/lib/scryer/version.rb +3 -0
  37. data/lib/scryer.rb +65 -0
  38. data/lib/tasks/scryer.rake +172 -0
  39. 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,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "scryer"
5
+ require "scryer/cli"
6
+
7
+ exit Scryer::CLI.new(ARGV).run
@@ -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