scryer 0.1.0 → 0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e14bfb1c3912e5ce4773838e90a19b9f5acd3814cf8f216052ca91784473863b
4
- data.tar.gz: 9f70388a0d3537da003c1c621117df0c3bf953e0a3aaea63e8b56ea4dc3c8d1f
3
+ metadata.gz: e1c8d4523447f9bdd1ea796fd27177efa18d6f726be6764e08aac1b18e05482b
4
+ data.tar.gz: de971058c3bfd42ad8ed9eabb6b633b305d1d10dc4971ea50e606fdefd573067
5
5
  SHA512:
6
- metadata.gz: 387810bd90bd1031be8c6b0b8debf3ee0ef91dfa982dfdf03afb331be07d7d41379b6e4e33fc340465c7da1e7f9d28a5eb186b2ec630826d54fb9522953085ec
7
- data.tar.gz: d2adf3bb70c181e2c874354c963828fb06e6dcd69dec7417b69fe064653da1df1b9a7cc82a14e4b7dd5fe4623876978dfb4a28469ae63d87df6c54d544334455
6
+ metadata.gz: 516425ec399996c7d20d20d6e8e222f188bf227cf911c401cdf514c084de22adcb9d7d15d2629cbb4cf79d0f3ef1f939058e64d24d14f0b3a3239d3922289270
7
+ data.tar.gz: 8c5efc1769526f6d8ed9b335a53129ac8f461156719e846d00f30e1ceae340f9b774bd83c89814689351b87c1200fb28fb5c76f4420bb4d86a296042717254d8
data/CHANGELOG.md ADDED
@@ -0,0 +1,47 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. Format loosely follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
+
6
+ ## [0.2.0] - 2026-08-11
7
+
8
+ - New `style` rule category (`Scryer::RuleSet` category `"style"`, alongside `security` and
9
+ `performance`): a deliberately narrow first check, `frozen_string_literal`, flags Ruby files
10
+ missing the `# frozen_string_literal: true` magic comment (info severity). Findings appear
11
+ everywhere other findings do (JSON/HTML/CSV reports, AI-assisted fix suggestions, `--skip`), and
12
+ are counted under "Code Quality" in the summary box alongside duplicate-code groups. This
13
+ remains the *only* style/lint check Scryer performs — see the README's Scryer-vs-RuboCop
14
+ comparison for why the rest of that territory is intentionally left to RuboCop.
15
+ - Fixed: `bin/rails scryer:report` now fails (non-zero exit) when security or dependency findings
16
+ are present, matching `scryer` (the standalone executable) and `scryer:audit_dependencies`. Previously
17
+ it always exited 0 regardless of findings, so it couldn't actually gate a CI job the way the
18
+ README described.
19
+ - Added `LICENSE.txt` (MIT) — declared via `spec.license` since 0.1.0 but not previously shipped
20
+ as an actual file.
21
+ - `spec.homepage` now points to the live docs site (https://ramlaxmanyadav.github.io/scryer/)
22
+ instead of the GitHub repo — the repo is still one click away via `source_code_uri` in
23
+ `spec.metadata`.
24
+
25
+ ## [0.1.0] - 2026-08-10
26
+
27
+ Initial release.
28
+
29
+ - Static security scan (`Ripper`-based, no Rails/Bundler needed to run it): SQL injection, mass
30
+ assignment, command injection, hardcoded secrets, unsafe deserialization, XSS-prone unescaped
31
+ HTML, CSRF gaps, weak cryptography, open redirects.
32
+ - Performance heuristics: N+1 queries, missing pagination, inefficient per-record saves, unbounded
33
+ full-table iteration.
34
+ - Duplicate/near-duplicate code detection via token-normalized similarity.
35
+ - Dependency audit against `Gemfile.lock` — known-vulnerable gem versions (live OSV.dev query) and
36
+ insecure git/http sources — runs by default on every scan (`--no-deps`/`nodeps` to opt out).
37
+ - Rule skipping: `c.skip_rules` / `--skip RULE_ID` to silence a specific check without editing it.
38
+ - One-off single-gem vulnerability lookup: `scryer --check-gem NAME[:VERSION]`.
39
+ - JSON, self-contained HTML, and CSV report formats.
40
+ - Optional, provider-agnostic AI-assisted fix suggestions (`Scryer::AiFixSuggester` +
41
+ `Scryer::AiClient`) — bring any LLM via a `#call(prompt)`/`#complete(prompt)` interface.
42
+ - Opt-in runtime query watcher (`Scryer::QueryWatcher`) for N+1s and unused eager loading during
43
+ actual request handling.
44
+ - `scryer` standalone executable and `scryer:report`/`scryer:audit_dependencies` Rails rake tasks.
45
+
46
+ [0.2.0]: https://github.com/ramlaxmanyadav/scryer/releases/tag/v0.2.0
47
+ [0.1.0]: https://github.com/ramlaxmanyadav/scryer/releases/tag/v0.1.0
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ram Laxman Yadav
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md CHANGED
@@ -1,38 +1,168 @@
1
- # Scryer
1
+ # Scryer — Ruby & Rails Code Security Auditor
2
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:
3
+ Scryer is a Ruby static code analysis and security auditing tool. It analyzes Ruby and Rails
4
+ projects, audits `Gemfile.lock` dependencies for known vulnerabilities, identifies potential
5
+ security, performance, and code-quality issues, and gives you an actionable, human-reviewable
6
+ suggestion for fixing each one.
5
7
 
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.
8
+ Most Rails teams already run several separate tools to cover code quality and security: RuboCop
9
+ for style, Brakeman for security, bundler-audit for dependency CVEs, Reek for code smells, plus
10
+ whatever custom scripts glue their outputs together in CI — different gems, different config
11
+ files, different report formats, different CI steps to maintain.
12
+
13
+ **Scryer's job is to be the one audit command that covers all of it** — security vulnerabilities,
14
+ performance problems, duplicate/smelly code, and dependency vulnerabilities in a single scan
15
+ with a single report:
16
+
17
+ ```bash
18
+ gem install scryer
19
+ scryer
20
+ ```
21
+
22
+ ```
23
+ Scryer Audit — 236 files scanned
24
+ ────────────────────────────────
25
+
26
+ Security 8 findings
27
+ Performance 10 findings
28
+ Code Quality 248 findings
29
+ Dependencies 24 findings
30
+ ────────────────────────────────
31
+ Total 290 findings
32
+
33
+ JSON report: tmp/scryer_report.json
34
+ HTML report: tmp/scryer_report.html
35
+ ```
36
+
37
+ That's real output from a scan of a live 236-file Rails app — not a mockup. See
38
+ [Scryer vs RuboCop vs Brakeman vs bundler-audit](#scryer-vs-rubocop-vs-brakeman-vs-bundler-audit)
39
+ below for exactly how it stacks up against the tools it's meant to consolidate.
40
+
41
+ ### What Scryer detects
42
+
43
+ **Security**
44
+
45
+ * SQL injection
46
+ * Mass assignment
47
+ * Command injection
48
+ * Hardcoded secrets
49
+ * Unsafe deserialization
50
+ * XSS-prone HTML
51
+ * CSRF gaps
52
+ * Weak cryptography
53
+ * Open redirects
54
+
55
+ **Code quality**
56
+
57
+ * Near-duplicate code
58
+ * Repeated logic
59
+ * Potentially problematic code patterns
60
+ * Missing `frozen_string_literal` magic comment (the one deliberate, narrow style check — see
61
+ [comparison table](#scryer-vs-rubocop-vs-brakeman-vs-bundler-audit) for why not more)
62
+
63
+ **Performance**
64
+
65
+ * N+1 queries
66
+ * Missing pagination
67
+ * Inefficient per-record saves
68
+ * Unbounded full-table iteration
69
+
70
+ **Dependencies**
71
+
72
+ * Known dependency vulnerabilities via OSV.dev
73
+ * Insecure gem sources
74
+
75
+ ### Example findings
76
+
77
+ Real output — three lines of deliberately flawed Rails code, scanned with plain `scryer`:
78
+
79
+ ```ruby
80
+ def create
81
+ @invoice = Invoice.create(params[:invoice])
82
+ end
83
+ ```
84
+
85
+ ```
86
+ [CRITICAL] mass_assignment — app/controllers/invoices_controller.rb:3
87
+ `create` receives `params` (or a subscript of it) directly, with no `.permit(...)` call — every
88
+ attribute in the request can be set, including ones the form/API was never meant to expose (e.g.
89
+ `admin`, `role_id`).
90
+
91
+ fix: Wrap the params in a strong-parameters method, e.g. `create(order_params)` with
92
+ `def order_params; params.require(:order).permit(:status, :total); end` — only the explicitly
93
+ permitted keys get through.
94
+ ```
95
+
96
+ ```ruby
97
+ Invoice.where("customer_name = '#{name}'")
98
+ ```
99
+
100
+ ```
101
+ [CRITICAL] sql_injection — app/controllers/invoices_controller.rb:9
102
+ `where` is called with a string built via interpolation, which lets user-controlled input change
103
+ the SQL executed.
104
+
105
+ fix: Use a parameterized form instead, e.g. `where("column = ?", value)` or the hash form
106
+ `where(column: value)` — both let Active Record escape the value safely instead of interpolating
107
+ it directly into SQL.
108
+ ```
109
+
110
+ ```ruby
111
+ @invoices = Invoice.where(status: "open")
112
+ @invoices.each { |invoice| invoice.account.name }
113
+ ```
114
+
115
+ ```
116
+ [WARNING] n_plus_one_query — app/controllers/invoices_controller.rb:14
117
+ `invoice.account` is called inside a loop — if `account` is an association, this issues a separate
118
+ query per iteration instead of one batched query (a classic N+1).
119
+
120
+ fix: Eager-load the association on the base query before the loop, e.g.
121
+ `invoices = Model.includes(:account).where(...)` (or add `:account` to an existing `.includes(...)`
122
+ call), so Rails fetches it in one extra query instead of one per record.
123
+ ```
124
+
125
+ Every finding follows this shape, whichever category it's in: exactly where the issue is, why it
126
+ matters, and a fix written against your actual code — not a generic paragraph you have to
127
+ translate into your own file.
128
+
129
+ ### Reports
130
+
131
+ Generate detailed JSON or self-contained HTML reports:
132
+
133
+ ```bash
134
+ scryer -o report.json -o report.html
135
+ ```
136
+
137
+ ### Developer-friendly suggestions
138
+
139
+ Every finding includes a human-reviewable suggested fix. Scryer never automatically modifies your source code.
140
+
141
+ ### Runtime analysis
142
+
143
+ Scryer can optionally monitor ActiveRecord queries at runtime to detect N+1 queries and unused eager loading.
144
+
145
+ ### Designed for Ruby
146
+
147
+ Scryer uses Ruby's standard-library `Ripper` parser, allowing source analysis without requiring Rails or Bundler to run the static scan itself.
14
148
 
15
149
  Several more things live alongside the static scan, each documented in its own section below:
16
150
 
17
151
  - **Skipping rules** ([Skipping rules](#skipping-rules)): silence a specific rule by `rule_id`
18
152
  (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.
153
+ - A **dependency audit** ([Dependency audit](#dependency-audit)), on by default, that checks
154
+ `Gemfile.lock` against [OSV.dev](https://osv.dev) for known-vulnerable gem versions and insecure
155
+ sources — the same broad goal as [bundler-audit](https://github.com/rubysec/bundler-audit), built
156
+ independently on a different data source. Pass `--no-deps` (or the `nodeps` rake arg) for a fast,
157
+ fully offline run instead.
27
158
  - **AI-assisted fix suggestions** ([AI-assisted fix suggestions](#ai-assisted-fix-suggestions)),
28
159
  optional, provider-agnostic: rewrites each finding's `suggested_fix` against its actual code
29
160
  using an LLM you configure — any LLM, not a specific vendor.
30
161
 
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.
162
+ **Nothing is auto-applied.** A security or performance fix needs a human's judgment about the
163
+ surrounding code; this gem's job is to point at the issue and explain it clearly, not to rewrite
164
+ your files. This holds whether `suggested_fix` came from a rule's static template or from the
165
+ optional AI enrichment below either way, it's text for a human to read and act on.
36
166
 
37
167
  ## A note on how this gem was actually verified
38
168
 
@@ -50,6 +180,50 @@ normal for this class of tool, not a bug. An already-guarded call can still get
50
180
  rules don't trace surrounding conditionals — always review a finding in its surrounding context
51
181
  before acting on it.
52
182
 
183
+ ## Scryer vs RuboCop vs Brakeman vs bundler-audit
184
+
185
+ Scryer isn't trying to out-lint RuboCop or out-analyze Brakeman — where an existing tool
186
+ specializes, it's still worth running on its own; style/lint conventions are almost entirely
187
+ RuboCop's job, and Scryer stays out of that territory except for one narrow, deliberately-scoped
188
+ check (see footnote below). What Scryer actually replaces is *stitching several of these together
189
+ yourself*: it covers categories none of the others do alone (performance heuristics,
190
+ duplicate-code detection, runtime query analysis), and folds the categories they *do* cover into
191
+ one command and one report instead of several separate tools, configs, and CI steps.
192
+
193
+ | Capability | Scryer | RuboCop | Brakeman | bundler-audit |
194
+ |-------------------------------------------|:------:|:--------:|:--------:|:-------------:|
195
+ | Style/lint conventions | Partial [h] | ✅ | ❌ | ❌ |
196
+ | Rails security scanning | ✅ | ❌ | ✅ | ❌ |
197
+ | Performance heuristics | ✅ | Partial [a] | ❌ | ❌ |
198
+ | Duplicate/similar code detection | ✅ | Partial [b] | ❌ | ❌ |
199
+ | Dependency vulnerability scanning | ✅ | ❌ | ❌ | ✅ |
200
+ | Runtime query analysis (N+1 in production) | ✅ | ❌ | ❌ | ❌ |
201
+ | HTML report | ✅ | Partial [c] | ✅ | ❌ |
202
+ | JSON report | ✅ | ✅ | ✅ | Limited [d] |
203
+ | Human-reviewable fix suggestions | ✅ | Partial [e] | Partial [f] | Limited [g] |
204
+ | Single command covering all of the above | ✅ | ❌ | ❌ | ❌ |
205
+
206
+ - **[a]** `rubocop-performance` adds some Ruby/Rails performance cops, but nothing like N+1-query
207
+ or missing-pagination detection.
208
+ - **[b]** A handful of cops catch exact-duplicate patterns (e.g. `Lint/DuplicateMethods`) — no
209
+ near-duplicate/similarity detection across methods.
210
+ - **[c]** RuboCop's built-in HTML formatter is a plain findings list, not an interactive report.
211
+ - **[d]** bundler-audit's output is primarily console text; no first-class JSON formatter.
212
+ - **[e]** RuboCop's `-A` auto-corrects many style violations directly — an automatic rewrite, not
213
+ a human-reviewable explanation, and only for auto-correctable cops.
214
+ - **[f]** Brakeman's warnings describe the issue and a confidence level, not a concrete
215
+ before/after code fix.
216
+ - **[g]** bundler-audit names the patched version to upgrade to; no code-level remediation (it
217
+ doesn't operate on your code at all, only `Gemfile.lock`).
218
+ - **[h]** One check only: a missing `# frozen_string_literal: true` magic comment
219
+ (`frozen_string_literal`, info severity). Everything else in RuboCop's style/lint domain —
220
+ naming, layout, quote style, line length, and hundreds more — is intentionally out of scope; see
221
+ [Skipping rules](#skipping-rules) if you don't want even this one.
222
+
223
+ If you already run RuboCop for style, keep it — Scryer isn't a replacement for it. If you're
224
+ currently running Brakeman + bundler-audit + a duplicate-code linter as three separate steps,
225
+ Scryer is the "run one thing instead" option.
226
+
53
227
  ## Install
54
228
 
55
229
  Scryer is a development-time analysis tool, not something a running production process needs —
@@ -80,13 +254,16 @@ scanned, the branch label).
80
254
  bin/rails scryer:report # writes tmp/scryer_report.{json,html}
81
255
  ```
82
256
 
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.
257
+ By default this writes both `tmp/scryer_report.json` and `tmp/scryer_report.html`, and includes a
258
+ dependency audit against `Gemfile.lock` (needs networksee [Dependency audit](#dependency-audit);
259
+ pass the `nodeps` arg for a fast, fully offline run instead). Format and output path are
260
+ configurable via task args — any mix of `json`, `html`, `csv` plus at most one path (quote the
261
+ whole thing so your shell doesn't eat the brackets/commas, e.g.
86
262
  `bin/rails 'scryer:report[json,doc/security_report.json]'`). Rake splits bracket args on every
87
263
  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.
264
+ (`scryer:report[json,html]` is two args, not `"json,html"` as one). At most one non-format,
265
+ non-`nodeps` token is accepted per call; giving two raises an error rather than guessing which one
266
+ you meant.
90
267
 
91
268
  Outside a Rails app (or in CI, or anywhere you don't want a rake task), the gem also ships a
92
269
  `scryer` executable — same idea as `brakeman -o report.json`, with `-o` repeatable and format
@@ -97,13 +274,17 @@ scryer # scans ., writes tmp/scryer_report.{j
97
274
  scryer -o report.json # just one file, exact path, format from extension
98
275
  scryer -o report.json -o report.html # as many outputs as you like, one -o each
99
276
  scryer -p /path/to/app -o /tmp/out.html # -p sets the root to scan (default: cwd)
277
+ scryer --no-deps # skip the dependency audit for a fast offline run
100
278
  scryer --help # full option list (--project-name, --branch, --version, ...)
101
279
  ```
102
280
 
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.
281
+ Console output is a summary box across every category see the box in the intro above for a real
282
+ example followed by where each report was written.
283
+
284
+ It exits `0` when the scan is clean and `1` when there's at least one security or dependency
285
+ finding, so `scryer -o report.json` can gate a CI job the same way `brakeman -o report.json` does.
286
+ A `2` exit means a usage error (bad flag, unrecognized output extension) rather than anything
287
+ about the scan itself.
107
288
 
108
289
  Or use the scanning engine directly as a library:
109
290
 
@@ -119,7 +300,7 @@ of checks that ran, a breakdown of warnings by type, every finding in detail, an
119
300
  groups — laid out similarly to a Brakeman report. `tmp/scryer_report.json` has the same data in
120
301
  machine-readable form, for feeding into your own dashboard or CI gate. A third format, `.csv`
121
302
  (`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) —
303
+ (security + performance findings, plus dependency findings unless `--no-deps`/`nodeps` was used) —
123
304
  handy for dropping into a spreadsheet or importing into a ticketing tool. It skips duplicate-code
124
305
  groups, which don't reduce to a single actionable row.
125
306
 
@@ -222,12 +403,14 @@ library, so it works the same whether or not this happens to run under Bundler.
222
403
  source was read or copied to build this.
223
404
 
224
405
  ```bash
225
- bin/rails scryer:audit_dependencies # inside a Rails app
406
+ bin/rails scryer:audit_dependencies # inside a Rails app — dependency audit only, no static scan
226
407
  ```
227
408
 
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
409
+ This is the one part of Scryer that needs a live network connection (to reach OSV.dev), and it
410
+ runs automatically as part of every `scryer`/`scryer:report` scan pass `--no-deps` (CLI) or the
411
+ `nodeps` arg (rake) for a fast, fully offline run instead. `scryer:audit_dependencies` above (and
412
+ `scryer --audit-deps` below) are for when you want *only* the dependency audit, the same way
413
+ `bundle-audit check` is a separate, standalone command from your test suite. Either way it exits
231
414
  non-zero if anything is found, so it can gate CI.
232
415
 
233
416
  Two checks run, and either can be called on its own as a library:
@@ -248,9 +431,11 @@ Scryer::DependencyAudit.vulnerable_gems(Rails.root.to_s) # needs network (OSV.
248
431
  and the fixed version(s) to upgrade to.
249
432
 
250
433
  **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:
434
+ same two checks standalone (no static scan), same output/exit-code behavior as the rake task above.
435
+ Plain `scryer` already folds them into the normal `-o` report — one HTML/JSON/CSV file covering
436
+ static findings *and* dependency findings together so `--audit-deps` is only for when you want
437
+ dependency findings *without* the static scan. And for a single gem, without touching
438
+ `Gemfile.lock` at all:
254
439
 
255
440
  ```bash
256
441
  scryer --check-gem rack # every advisory ever filed against rack, any version
@@ -360,8 +545,12 @@ in a host app for the full description (also in
360
545
 
361
546
  ## Extending it
362
547
 
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.
548
+ Every rule is a small class extending `Scryer::Rule` — see `lib/scryer/rules/*.rb` (security),
549
+ `lib/scryer/performance_rules/*.rb` (performance), and `lib/scryer/style_rules/*.rb` (style) for
550
+ the pattern. A new rule file dropped into any of the three directories is picked up automatically
551
+ (rules self-register via `Rule.inherited` — no manual wiring needed, just `self.category =
552
+ "security"|"performance"|"style"`). `Scryer::Ast` has the tree-walking helpers used throughout.
553
+
554
+ ## License
555
+
556
+ MIT licensed.
@@ -35,13 +35,15 @@ module Scryer
35
35
  finding
36
36
  end
37
37
 
38
- # Enhances every security/performance finding on a Scanner::Result in
39
- # place. Runs across a small thread pool (network-bound work, same
40
- # pattern as DependencyAudit.vulnerable_gems) so a large finding count
41
- # doesn't mean one-request-at-a-time. No-op if no client is
42
- # configured callers don't need to check first.
38
+ # Enhances every security/performance/style finding on a
39
+ # Scanner::Result in place. Runs across a small thread pool
40
+ # (network-bound work, same pattern as
41
+ # DependencyAudit.vulnerable_gems) so a large finding count doesn't
42
+ # mean one-request-at-a-time. No-op if no client is configured —
43
+ # callers don't need to check first.
43
44
  def enhance_result!(result, client: Scryer.configuration.ai_client, concurrency: 4)
44
- enhance_many!(result.security_findings + result.performance_findings, client: client, concurrency: concurrency)
45
+ enhance_many!(result.security_findings + result.performance_findings + result.style_findings,
46
+ client: client, concurrency: concurrency)
45
47
  result
46
48
  end
47
49
 
data/lib/scryer/cli.rb CHANGED
@@ -33,10 +33,16 @@ module Scryer
33
33
 
34
34
  result = Scanner.new(root: root, dirs: Scryer.configuration.dirs, skip_rules: skip_rules).call
35
35
 
36
+ # Dependency auditing (OSV.dev) runs by default — a single `scryer`
37
+ # invocation is meant to cover the same ground as RuboCop + Brakeman +
38
+ # bundler-audit + Reek run separately, and that story isn't true if
39
+ # the dependency half is silently skipped unless you remember a flag.
40
+ # `--no-deps` opts back out for a fast, fully offline run (e.g. no
41
+ # network in this environment, or you only want the static scan).
42
+ ran_deps = !options[:no_deps]
36
43
  dependency_findings = []
37
- if options[:include_deps]
38
- @stdout.puts "Scryer: checking Gemfile.lock sources (offline)..."
39
- @stdout.puts "Scryer: querying OSV.dev for known vulnerabilities (needs network)..."
44
+ if ran_deps
45
+ @stdout.puts "Scryer: querying OSV.dev for known-vulnerable gems (needs network)..."
40
46
  dependency_findings = DependencyAudit.insecure_sources(root) + DependencyAudit.vulnerable_gems(root)
41
47
  end
42
48
 
@@ -58,12 +64,7 @@ module Scryer
58
64
  outputs = options[:outputs].empty? ? default_outputs(root) : options[:outputs]
59
65
  outputs.each { |path| write_report(renderer, path) }
60
66
 
61
- @stdout.puts "Scryer: #{result.files_scanned} files scanned, " \
62
- "#{result.security_findings.size} security findings, " \
63
- "#{result.performance_findings.size} performance findings, " \
64
- "#{result.duplicate_groups.size} duplicate groups" \
65
- "#{options[:include_deps] ? ", #{dependency_findings.size} dependency findings" : ""}."
66
- @stdout.puts "Report written to #{outputs.join(', ')}"
67
+ print_summary(result: result, dependency_findings: dependency_findings, ran_deps: ran_deps, outputs: outputs)
67
68
 
68
69
  result.security_findings.empty? && dependency_findings.empty? ? 0 : 1
69
70
  rescue UsageError => e
@@ -95,6 +96,42 @@ module Scryer
95
96
  total.positive? ? 1 : 0
96
97
  end
97
98
 
99
+ # The "one audit command" summary — a single scan's worth of every
100
+ # category Scryer covers (security, performance, duplicate/smelly code,
101
+ # dependencies), the same categories usually split across RuboCop +
102
+ # Brakeman + bundler-audit + Reek, side by side in one box.
103
+ def print_summary(result:, dependency_findings:, ran_deps:, outputs:)
104
+ # "Code Quality" is the umbrella label for both duplicate-code groups
105
+ # and rule-based style findings (e.g. frozen_string_literal) — two
106
+ # different detectors, same broad concern, one row in the box.
107
+ code_quality = result.duplicate_groups.size + result.style_findings.size
108
+ deps_count = ran_deps ? dependency_findings.size : nil
109
+ total = result.security_findings.size + result.performance_findings.size + code_quality + (deps_count || 0)
110
+
111
+ rows = [
112
+ ["Security", result.security_findings.size],
113
+ ["Performance", result.performance_findings.size],
114
+ ["Code Quality", code_quality],
115
+ ["Dependencies", deps_count]
116
+ ]
117
+
118
+ divider = "─" * 32
119
+ @stdout.puts ""
120
+ @stdout.puts "Scryer Audit — #{result.files_scanned} files scanned"
121
+ @stdout.puts divider
122
+ @stdout.puts ""
123
+ rows.each { |label, count| @stdout.puts summary_row(label, count) }
124
+ @stdout.puts divider
125
+ @stdout.puts summary_row("Total", total)
126
+ @stdout.puts ""
127
+ outputs.each { |path| @stdout.puts "#{format_for(path).upcase} report: #{path}" }
128
+ end
129
+
130
+ def summary_row(label, count)
131
+ value = count.nil? ? "skipped (--no-deps)" : "#{count} finding#{"s" unless count == 1}"
132
+ "#{label.ljust(14)}#{value.rjust(20)}"
133
+ end
134
+
98
135
  # One-off OSV.dev lookup for a single gem — no Gemfile.lock, no scan,
99
136
  # no other network calls. `spec` is "name" or "name:version" (colon
100
137
  # rather than a second CLI arg, so this stays a single -o-style flag).
@@ -128,13 +165,11 @@ module Scryer
128
165
  "insecure git/http sources (offline), instead of running the normal static scan. " \
129
166
  "Exits non-zero if anything is found, so this can gate CI the same way " \
130
167
  "`bundle-audit check` does.") { options[:audit_deps] = true }
131
- opts.on("--include-deps",
132
- "Fold a dependency audit (same checks as --audit-deps: OSV.dev vulnerable gems + " \
133
- "insecure git/http sources) into the normal report as one more section, instead " \
134
- "of running it as a separate command. Combine with -o to get one HTML/JSON " \
135
- "report covering static findings, duplicate code, and dependency findings " \
136
- "together — and, with an AI client configured, AI-rewritten suggested fixes for " \
137
- "all of them.") { options[:include_deps] = true }
168
+ opts.on("--no-deps",
169
+ "Skip the dependency audit (OSV.dev vulnerable gems + insecure git/http sources) " \
170
+ "that otherwise runs as part of every normal scan. Use this for a fast, fully " \
171
+ "offline run e.g. no network available, or you only want the static " \
172
+ "findings.") { options[:no_deps] = true }
138
173
  opts.on("--skip RULE_ID",
139
174
  "Skip a rule by rule_id (repeatable) — e.g. a known false positive on this " \
140
175
  "codebase. Adds to c.skip_rules for this run only; doesn't affect other " \
@@ -44,6 +44,7 @@ module Scryer
44
44
  "parse_errors" => @result.parse_errors.map { |pe| { "file" => pe[:file], "error" => pe[:error] } },
45
45
  "security_findings" => @result.security_findings.map(&:to_h),
46
46
  "performance_findings" => @result.performance_findings.map(&:to_h),
47
+ "style_findings" => @result.style_findings.map(&:to_h),
47
48
  "duplicate_groups" => @result.duplicate_groups.map { |g| duplicate_group_hash(g) },
48
49
  "dependency_findings" => @dependency_findings.map(&:to_h)
49
50
  }
@@ -66,6 +67,7 @@ module Scryer
66
67
  rows = [CSV_HEADERS]
67
68
  h["security_findings"].each { |f| rows << static_csv_row(f) }
68
69
  h["performance_findings"].each { |f| rows << static_csv_row(f) }
70
+ h["style_findings"].each { |f| rows << static_csv_row(f) }
69
71
  h["dependency_findings"].each { |f| rows << dependency_csv_row(f) }
70
72
 
71
73
  rows.map { |row| row.map { |field| csv_field(field) }.join(",") }.join("\n")
@@ -75,7 +77,8 @@ module Scryer
75
77
  h = as_hash
76
78
  security = h["security_findings"]
77
79
  performance = h["performance_findings"]
78
- all_findings = security + performance
80
+ style = h["style_findings"]
81
+ all_findings = security + performance + style
79
82
  by_severity = all_findings.group_by { |f| f["severity"] }
80
83
  duplicate_groups = h["duplicate_groups"]
81
84
  dependency_findings = h["dependency_findings"]
@@ -105,7 +108,7 @@ module Scryer
105
108
 
106
109
  <section id="summary">
107
110
  <h2>Summary</h2>
108
- #{render_summary_table(security, performance, duplicate_groups, dependency_findings)}
111
+ #{render_summary_table(security, performance, style, duplicate_groups, dependency_findings)}
109
112
  </section>
110
113
 
111
114
  <section id="checks-performed">
@@ -185,19 +188,21 @@ module Scryer
185
188
  "<table class=\"kv\">#{body}</table>"
186
189
  end
187
190
 
188
- def render_summary_table(security, performance, duplicate_groups, dependency_findings)
191
+ def render_summary_table(security, performance, style, duplicate_groups, dependency_findings)
189
192
  sec_counts = count_by_severity(security)
190
193
  perf_counts = count_by_severity(performance)
191
- total_counts = SEVERITY_ORDER.each_with_object({}) { |s, acc| acc[s] = sec_counts[s] + perf_counts[s] }
194
+ style_counts = count_by_severity(style)
195
+ total_counts = SEVERITY_ORDER.each_with_object({}) { |s, acc| acc[s] = sec_counts[s] + perf_counts[s] + style_counts[s] }
192
196
 
193
197
  header = "<tr><th>Category</th>" + SEVERITY_ORDER.map { |s| "<th>#{SEVERITY_LABELS[s]}</th>" }.join + "<th>Total</th></tr>"
194
198
  sec_row = summary_row("Security", sec_counts)
195
199
  perf_row = summary_row("Performance", perf_counts)
200
+ style_row = summary_row("Style", style_counts)
196
201
  total_row = summary_row("Total", total_counts, css_class: "total")
197
202
  dup_row = "<tr><th>Duplicate code</th><td colspan=\"#{SEVERITY_ORDER.size}\">—</td><td>#{duplicate_groups.size} group(s)</td></tr>"
198
203
  deps_row = "<tr><th>Dependency audit</th><td colspan=\"#{SEVERITY_ORDER.size}\">—</td><td>#{dependency_findings.size} finding(s)</td></tr>"
199
204
 
200
- "<table class=\"summary\">#{header}#{sec_row}#{perf_row}#{dup_row}#{deps_row}#{total_row}</table>"
205
+ "<table class=\"summary\">#{header}#{sec_row}#{perf_row}#{style_row}#{dup_row}#{deps_row}#{total_row}</table>"
201
206
  end
202
207
 
203
208
  def summary_row(label, counts, css_class: nil)
@@ -221,9 +226,11 @@ module Scryer
221
226
  def render_checks_performed
222
227
  security_rules = rules_by_category["security"] || []
223
228
  performance_rules = rules_by_category["performance"] || []
229
+ style_rules = rules_by_category["style"] || []
224
230
 
225
231
  "<h3>Security (#{security_rules.size})</h3>#{checks_table(security_rules)}" \
226
- "<h3>Performance (#{performance_rules.size})</h3>#{checks_table(performance_rules)}"
232
+ "<h3>Performance (#{performance_rules.size})</h3>#{checks_table(performance_rules)}" \
233
+ "<h3>Style (#{style_rules.size})</h3>#{checks_table(style_rules)}"
227
234
  end
228
235
 
229
236
  def checks_table(rules)
@@ -26,7 +26,7 @@ module Scryer
26
26
  QUERY_SIMILARITY_THRESHOLD = 0.7
27
27
  CACHE_SIMILARITY_THRESHOLD = 0.7
28
28
 
29
- Result = Struct.new(:security_findings, :performance_findings, :duplicate_groups, :files_scanned, :parse_errors, keyword_init: true)
29
+ Result = Struct.new(:security_findings, :performance_findings, :style_findings, :duplicate_groups, :files_scanned, :parse_errors, keyword_init: true)
30
30
 
31
31
  # `skip_rules` silences specific checks by rule_id (e.g. a known false
32
32
  # positive on this codebase) without editing/removing the rule itself —
@@ -44,6 +44,7 @@ module Scryer
44
44
  all_cache_calls = []
45
45
  security_findings = []
46
46
  performance_findings = []
47
+ style_findings = []
47
48
  parse_errors = []
48
49
 
49
50
  files.each do |abs_path|
@@ -69,6 +70,7 @@ module Scryer
69
70
  case rule_class.category
70
71
  when "security" then security_findings
71
72
  when "performance" then performance_findings
73
+ when "style" then style_findings
72
74
  end
73
75
  next unless bucket
74
76
 
@@ -97,6 +99,7 @@ module Scryer
97
99
  Result.new(
98
100
  security_findings: security_findings,
99
101
  performance_findings: performance_findings,
102
+ style_findings: style_findings,
100
103
  duplicate_groups: duplicate_groups,
101
104
  files_scanned: files.size,
102
105
  parse_errors: parse_errors
@@ -0,0 +1,50 @@
1
+ module Scryer
2
+ module Rules
3
+ # Flags Ruby files with no `# frozen_string_literal: true` magic comment
4
+ # — the one deliberate, narrow style check Scryer makes (see the
5
+ # README's Scryer-vs-RuboCop comparison: everything else in
6
+ # style/lint conventions is intentionally left to RuboCop). Checked on
7
+ # raw source rather than the parsed sexp — a magic comment is lexical,
8
+ # not part of the AST — so this only needs the file's leading lines,
9
+ # not Ripper.sexp.
10
+ class FrozenStringLiteralRule < Rule
11
+ self.rule_id = "frozen_string_literal"
12
+ self.category = "style"
13
+ self.default_severity = "info"
14
+ self.title = "Missing `frozen_string_literal` magic comment"
15
+
16
+ MAGIC_COMMENT = /\A#\s*frozen_string_literal:\s*(true|false)\s*\z/i.freeze
17
+
18
+ def scan
19
+ return [] if source.strip.empty?
20
+ return [] if leading_comment_lines.any? { |line| MAGIC_COMMENT.match?(line.strip) }
21
+
22
+ [
23
+ finding(
24
+ line: 1,
25
+ message: "This file has no `# frozen_string_literal: true` magic comment — every " \
26
+ "string literal allocates a new String object at runtime instead of reusing " \
27
+ "a single frozen one.",
28
+ suggested_fix: "Add `# frozen_string_literal: true` as the first line of the file " \
29
+ "(after a shebang line, if any) — a cheap, safe default in modern " \
30
+ "Ruby. If a specific literal needs to stay mutable, call `.dup` on it " \
31
+ "explicitly at that call site."
32
+ )
33
+ ]
34
+ end
35
+
36
+ private
37
+
38
+ # The lines Ruby itself considers when looking for a magic comment:
39
+ # starting from the top of the file, skip a shebang line, then take
40
+ # every line up to (not including) the first line that isn't blank or
41
+ # a comment.
42
+ def leading_comment_lines
43
+ lines = source.each_line.to_a
44
+ lines.shift if lines.first&.start_with?("#!")
45
+
46
+ lines.take_while { |line| line.strip.empty? || line.lstrip.start_with?("#") }
47
+ end
48
+ end
49
+ end
50
+ end
@@ -1,3 +1,3 @@
1
1
  module Scryer
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  end
data/lib/scryer.rb CHANGED
@@ -15,6 +15,7 @@ require "scryer/ai_fix_suggester"
15
15
 
16
16
  Dir[File.join(__dir__, "scryer", "rules", "*.rb")].sort.each { |f| require f }
17
17
  Dir[File.join(__dir__, "scryer", "performance_rules", "*.rb")].sort.each { |f| require f }
18
+ Dir[File.join(__dir__, "scryer", "style_rules", "*.rb")].sort.each { |f| require f }
18
19
 
19
20
  module Scryer
20
21
  class Configuration
@@ -9,32 +9,35 @@ require "fileutils"
9
9
  # Ruby nil-or-empty checks only.
10
10
  namespace :scryer do
11
11
  desc "Run Scryer and write a report. Args are any mix of json/html/csv (which formats " \
12
- "to write — default json,html), the token 'deps' (fold a dependency audit — OSV.dev " \
13
- "vulnerable gems + insecure git/http sources, same checks as scryer:audit_dependencies " \
14
- " into this report instead of running it separately), plus at most one path (a " \
15
- "directory when writing more than one format, or an exact file for a single format). " \
16
- "Rake splits bracket args on every comma, so pass each token as its own item rather " \
17
- "than one comma-joined string. e.g. rails scryer:report, rails 'scryer:report[html]', " \
12
+ "to write — default json,html), the token 'nodeps' (skip the dependency audit — OSV.dev " \
13
+ "vulnerable gems + insecure git/http sources that otherwise runs by default; use for a " \
14
+ "fast, fully offline run), plus at most one path (a directory when writing more than one " \
15
+ "format, or an exact file for a single format). Rake splits bracket args on every comma, " \
16
+ "so pass each token as its own item rather than one comma-joined string. " \
17
+ "e.g. rails scryer:report, rails 'scryer:report[html]', " \
18
18
  "rails 'scryer:report[json,doc/security.json]', rails 'scryer:report[json,html]', " \
19
- "rails 'scryer:report[html,deps]', rails 'scryer:report[csv]'"
19
+ "rails 'scryer:report[html,nodeps]', rails 'scryer:report[csv]'"
20
20
  task :report, [:format] do |_, args|
21
21
  root = defined?(Rails) ? Rails.root.to_s : Dir.pwd
22
22
  dirs = Scryer.configuration.dirs
23
23
 
24
24
  # Ignore the declared :format name and read every positional value Rake
25
25
  # was given (args.to_a) — the whole point is accepting a variable number
26
- # of format/deps tokens plus one path, which a single named param can't do.
27
- formats, path_arg, include_deps = parse_report_args(args.to_a)
26
+ # of format/nodeps tokens plus one path, which a single named param can't do.
27
+ formats, path_arg, run_deps = parse_report_args(args.to_a)
28
28
 
29
29
  skip_rules = Scryer.configuration.skip_rules
30
30
  puts "Scryer: skipping #{skip_rules.join(', ')}." if skip_rules.any?
31
31
 
32
32
  result = Scryer::Scanner.new(root: root, dirs: dirs, skip_rules: skip_rules).call
33
33
 
34
+ # Dependency auditing (OSV.dev) runs by default — a single scryer:report
35
+ # run is meant to cover the same ground as RuboCop + Brakeman +
36
+ # bundler-audit + Reek run separately. Pass the 'nodeps' token for a
37
+ # fast, fully offline run instead.
34
38
  dependency_findings = []
35
- if include_deps
36
- puts "Scryer: checking Gemfile.lock sources (offline)..."
37
- puts "Scryer: querying OSV.dev for known vulnerabilities (needs network)..."
39
+ if run_deps
40
+ puts "Scryer: querying OSV.dev for known-vulnerable gems (needs network)..."
38
41
  dependency_findings = Scryer::DependencyAudit.insecure_sources(root) + Scryer::DependencyAudit.vulnerable_gems(root)
39
42
  end
40
43
 
@@ -65,12 +68,17 @@ namespace :scryer do
65
68
  File.write(path, content)
66
69
  end
67
70
 
68
- puts "Scryer: #{result.files_scanned} files scanned, " \
69
- "#{result.security_findings.size} security findings, " \
70
- "#{result.performance_findings.size} performance findings, " \
71
- "#{result.duplicate_groups.size} duplicate groups" \
72
- "#{include_deps ? ", #{dependency_findings.size} dependency findings" : ""}."
73
- puts "Report written to #{paths.values.join(', ')}"
71
+ ScryerTasks.print_summary(result: result, dependency_findings: dependency_findings, ran_deps: run_deps, paths: paths)
72
+
73
+ # Same gate as the `scryer` executable and scryer:audit_dependencies
74
+ # below: fail the task (and so the CI job running it) on any security or
75
+ # dependency finding, so `bin/rails scryer:report` gates a build the
76
+ # same way `brakeman` or `bundle-audit check` would. Performance/style
77
+ # findings are advisory only and never fail the task.
78
+ if result.security_findings.any? || dependency_findings.any?
79
+ abort("Scryer: found #{result.security_findings.size} security finding(s) and " \
80
+ "#{dependency_findings.size} dependency finding(s).")
81
+ end
74
82
  end
75
83
 
76
84
  desc "Check Gemfile.lock for known-vulnerable gem versions (via OSV.dev — needs network) " \
@@ -98,17 +106,17 @@ namespace :scryer do
98
106
 
99
107
  VALID_FORMATS = %w[json html csv].freeze
100
108
  EXTENSION_FOR_FORMAT = { "json" => "json", "html" => "html", "csv" => "csv" }.freeze
101
- DEPS_TOKEN = "deps".freeze
109
+ NO_DEPS_TOKEN = "nodeps".freeze
102
110
 
103
111
  # tokens is every bracket arg Rake was given, e.g. %w[json doc/security.json] or
104
- # %w[json html] or %w[html deps] or []. Returns [formats, path_arg,
105
- # include_deps] — any token matching a known format is a format, the
106
- # literal "deps" token opts into folding a dependency audit into the
107
- # report, and at most one other token is allowed, which is the path.
112
+ # %w[json html] or %w[html nodeps] or []. Returns [formats, path_arg,
113
+ # run_deps] — any token matching a known format is a format, the literal
114
+ # "nodeps" token opts *out* of the dependency audit that otherwise runs by
115
+ # default, and at most one other token is allowed, which is the path.
108
116
  def parse_report_args(tokens)
109
117
  tokens = tokens.map { |t| blank_to_nil(t) }.compact
110
- include_deps = tokens.any? { |t| t.downcase == DEPS_TOKEN }
111
- tokens = tokens.reject { |t| t.downcase == DEPS_TOKEN }
118
+ run_deps = tokens.none? { |t| t.downcase == NO_DEPS_TOKEN }
119
+ tokens = tokens.reject { |t| t.downcase == NO_DEPS_TOKEN }
112
120
 
113
121
  format_tokens, other_tokens = tokens.partition { |t| VALID_FORMATS.include?(t.downcase) }
114
122
 
@@ -119,7 +127,7 @@ namespace :scryer do
119
127
  formats = format_tokens.map(&:downcase).uniq
120
128
  formats = %w[json html] if formats.empty?
121
129
 
122
- [formats, other_tokens.first, include_deps]
130
+ [formats, other_tokens.first, run_deps]
123
131
  end
124
132
 
125
133
  # No path given: default filenames under tmp/. A path given with a single
@@ -169,4 +177,40 @@ module ScryerTasks
169
177
  rescue StandardError
170
178
  nil
171
179
  end
180
+
181
+ # The "one audit command" summary — a single scan's worth of every
182
+ # category Scryer covers (security, performance, duplicate/smelly code,
183
+ # dependencies), the same categories usually split across RuboCop +
184
+ # Brakeman + bundler-audit + Reek, side by side in one box.
185
+ def print_summary(result:, dependency_findings:, ran_deps:, paths:)
186
+ # "Code Quality" is the umbrella label for both duplicate-code groups
187
+ # and rule-based style findings (e.g. frozen_string_literal) — two
188
+ # different detectors, same broad concern, one row in the box.
189
+ code_quality = result.duplicate_groups.size + result.style_findings.size
190
+ deps_count = ran_deps ? dependency_findings.size : nil
191
+ total = result.security_findings.size + result.performance_findings.size + code_quality + (deps_count || 0)
192
+
193
+ rows = [
194
+ ["Security", result.security_findings.size],
195
+ ["Performance", result.performance_findings.size],
196
+ ["Code Quality", code_quality],
197
+ ["Dependencies", deps_count]
198
+ ]
199
+
200
+ divider = "─" * 32
201
+ puts ""
202
+ puts "Scryer Audit — #{result.files_scanned} files scanned"
203
+ puts divider
204
+ puts ""
205
+ rows.each { |label, count| puts summary_row(label, count) }
206
+ puts divider
207
+ puts summary_row("Total", total)
208
+ puts ""
209
+ paths.each { |format, path| puts "#{format.upcase} report: #{path}" }
210
+ end
211
+
212
+ def summary_row(label, count)
213
+ value = count.nil? ? "skipped (nodeps)" : "#{count} finding#{"s" unless count == 1}"
214
+ "#{label.ljust(14)}#{value.rjust(20)}"
215
+ end
172
216
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: scryer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ram Laxman Yadav
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-08-10 00:00:00.000000000 Z
11
+ date: 2026-08-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rake
@@ -25,23 +25,26 @@ dependencies:
25
25
  - !ruby/object:Gem::Version
26
26
  version: '13.0'
27
27
  description: |
28
- Scans a Rails app's own source with Ruby's stdlib Ripper parser (no Rails/bundler needed to
29
- run the scan itself) and reports: security findings (SQL injection, mass assignment, command
30
- injection, hardcoded secrets, unsafe deserialization, XSS-prone unescaped HTML, CSRF gaps,
31
- weak crypto, open redirects), near-duplicate code (token-normalized similarity across
32
- methods), and performance heuristics (N+1 queries, missing pagination, inefficient per-record
33
- save loops, unbounded full-table iteration). Every finding includes a human-reviewable
34
- suggested fix nothing is auto-applied. Writes a detailed report as JSON and/or
35
- self-contained HTML (tmp/scryer_report.{json,html}). Ships a `scryer` executable for
36
- running outside a Rails app too, e.g. `scryer -o report.json -o report.html`. Also includes a
37
- runtime query watcher (N+1 / unused-eager-load detection via ActiveRecord instrumentation,
38
- opt-in) and a dependency vulnerability + insecure-source audit against OSV.dev.
28
+ One scan, one report: security, performance, duplicate-code, and dependency audits for Ruby
29
+ and Rails — the ground Brakeman, bundler-audit, Reek, and custom glue scripts usually split
30
+ between them, covered by a single `scryer` command. (Style/lint is RuboCop's job — Scryer
31
+ doesn't touch that.)
32
+
33
+ Detects SQL injection, mass assignment, hardcoded secrets, XSS, weak crypto, and more; N+1
34
+ queries, missing pagination, and other performance heuristics; near-duplicate code; and
35
+ known-vulnerable gems via a live OSV.dev dependency audit on by default, every run.
36
+
37
+ Every finding includes a human-reviewable suggested fix never auto-applied, optionally
38
+ rewritten against your actual code by any LLM you configure. Reports in JSON, self-contained
39
+ HTML, or CSV. Zero runtime dependencies beyond Ruby's own stdlib.
39
40
  email:
40
41
  executables:
41
42
  - scryer
42
43
  extensions: []
43
44
  extra_rdoc_files: []
44
45
  files:
46
+ - CHANGELOG.md
47
+ - LICENSE.txt
45
48
  - README.md
46
49
  - exe/scryer
47
50
  - lib/generators/scryer/USAGE
@@ -77,12 +80,18 @@ files:
77
80
  - lib/scryer/rules/weak_crypto_rule.rb
78
81
  - lib/scryer/rules/xss_unsafe_html_rule.rb
79
82
  - lib/scryer/scanner.rb
83
+ - lib/scryer/style_rules/frozen_string_literal_rule.rb
80
84
  - lib/scryer/version.rb
81
85
  - lib/tasks/scryer.rake
82
- homepage: https://github.com/ramlaxmanyadav/scryer
86
+ homepage: https://ramlaxmanyadav.github.io/scryer/
83
87
  licenses:
84
88
  - MIT
85
- metadata: {}
89
+ metadata:
90
+ source_code_uri: https://github.com/ramlaxmanyadav/scryer
91
+ documentation_uri: https://ramlaxmanyadav.github.io/scryer/
92
+ changelog_uri: https://github.com/ramlaxmanyadav/scryer/blob/main/CHANGELOG.md
93
+ bug_tracker_uri: https://github.com/ramlaxmanyadav/scryer/issues
94
+ rubygems_mfa_required: 'true'
86
95
  post_install_message:
87
96
  rdoc_options: []
88
97
  require_paths:
@@ -101,6 +110,6 @@ requirements: []
101
110
  rubygems_version: 3.5.11
102
111
  signing_key:
103
112
  specification_version: 4
104
- summary: 'Static code analysis for Rails apps: security vulnerabilities, duplicate
105
- code, and performance heuristics.'
113
+ summary: Ruby/Rails security & quality auditor one command instead of Brakeman +
114
+ bundler-audit + Reek.
106
115
  test_files: []