scryer 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +414 -0
  3. data/README.md +114 -649
  4. data/docs/architecture.md +268 -0
  5. data/docs/contributing.md +42 -0
  6. data/docs/fix-mode.md +364 -0
  7. data/docs/rails-integration.md +162 -0
  8. data/docs/rules.md +310 -0
  9. data/docs/usage.md +301 -0
  10. data/lib/generators/scryer/USAGE +10 -2
  11. data/lib/generators/scryer/templates/scryer_initializer.rb +15 -0
  12. data/lib/scryer/ai_fix_suggester.rb +37 -11
  13. data/lib/scryer/ast.rb +26 -0
  14. data/lib/scryer/authorization_watcher.rb +156 -0
  15. data/lib/scryer/baseline.rb +75 -0
  16. data/lib/scryer/cli.rb +688 -14
  17. data/lib/scryer/colorizer.rb +56 -0
  18. data/lib/scryer/dependency_fixer.rb +96 -0
  19. data/lib/scryer/finding.rb +6 -0
  20. data/lib/scryer/fix_runner.rb +161 -0
  21. data/lib/scryer/fix_verifier.rb +169 -0
  22. data/lib/scryer/mechanical_fixer.rb +288 -0
  23. data/lib/scryer/minitest.rb +48 -0
  24. data/lib/scryer/performance_rules/inefficient_save_loop_rule.rb +32 -0
  25. data/lib/scryer/performance_rules/missing_pagination_rule.rb +1 -0
  26. data/lib/scryer/performance_rules/n_plus_one_query_rule.rb +1 -0
  27. data/lib/scryer/performance_rules/unbounded_table_scan_rule.rb +1 -0
  28. data/lib/scryer/report_renderer.rb +539 -46
  29. data/lib/scryer/rspec.rb +55 -0
  30. data/lib/scryer/rule.rb +22 -2
  31. data/lib/scryer/rules/action_cable_forgery_protection_rule.rb +3 -0
  32. data/lib/scryer/rules/active_storage_inline_disposition_rule.rb +3 -0
  33. data/lib/scryer/rules/active_storage_missing_content_type_validation_rule.rb +3 -0
  34. data/lib/scryer/rules/authentication_bypass_rule.rb +30 -7
  35. data/lib/scryer/rules/command_injection_rule.rb +3 -0
  36. data/lib/scryer/rules/consider_all_requests_local_rule.rb +51 -0
  37. data/lib/scryer/rules/cors_misconfiguration_rule.rb +51 -20
  38. data/lib/scryer/rules/csrf_protection_rule.rb +60 -11
  39. data/lib/scryer/rules/force_ssl_rule.rb +3 -0
  40. data/lib/scryer/rules/graphql_missing_query_limits_rule.rb +31 -0
  41. data/lib/scryer/rules/hardcoded_basic_auth_rule.rb +3 -0
  42. data/lib/scryer/rules/hardcoded_secret_key_base_rule.rb +3 -0
  43. data/lib/scryer/rules/hardcoded_secret_rule.rb +3 -0
  44. data/lib/scryer/rules/host_authorization_disabled_rule.rb +50 -0
  45. data/lib/scryer/rules/idor_rule.rb +63 -9
  46. data/lib/scryer/rules/insecure_cookie_serializer_rule.rb +3 -0
  47. data/lib/scryer/rules/job_raw_params_rule.rb +40 -7
  48. data/lib/scryer/rules/jwt_insecure_rule.rb +3 -0
  49. data/lib/scryer/rules/mass_assignment_rule.rb +32 -5
  50. data/lib/scryer/rules/missing_authorization_rule.rb +103 -0
  51. data/lib/scryer/rules/missing_policy_scope_rule.rb +134 -0
  52. data/lib/scryer/rules/open_redirect_rule.rb +3 -0
  53. data/lib/scryer/rules/path_traversal_rule.rb +22 -1
  54. data/lib/scryer/rules/security_headers_rule.rb +3 -0
  55. data/lib/scryer/rules/sql_injection_rule.rb +3 -0
  56. data/lib/scryer/rules/ssrf_rule.rb +67 -13
  57. data/lib/scryer/rules/unsafe_deserialization_rule.rb +3 -0
  58. data/lib/scryer/rules/verbose_production_log_level_rule.rb +53 -0
  59. data/lib/scryer/rules/weak_crypto_rule.rb +37 -2
  60. data/lib/scryer/rules/weak_session_cookie_rule.rb +3 -0
  61. data/lib/scryer/rules/xss_unsafe_html_rule.rb +41 -0
  62. data/lib/scryer/scanner.rb +25 -12
  63. data/lib/scryer/style_rules/frozen_string_literal_rule.rb +1 -0
  64. data/lib/scryer/version.rb +1 -1
  65. data/lib/scryer.rb +30 -1
  66. data/lib/tasks/scryer.rake +447 -20
  67. metadata +52 -12
@@ -0,0 +1,268 @@
1
+ # Architecture, Performance & Security Model
2
+
3
+ [← Back to Scryer — Ruby on Rails Security Auditor](../README.md)
4
+
5
+ ## Architecture
6
+
7
+ Ruby's stdlib `Ripper` parses each file into an S-expression tree (see
8
+ [lib/scryer/ast.rb](../lib/scryer/ast.rb) for the small set of tree-walking helpers every rule uses).
9
+ Each check is a self-registering `Scryer::Rule` subclass
10
+ ([lib/scryer/rule.rb](../lib/scryer/rule.rb)/[lib/scryer/rule_set.rb](../lib/scryer/rule_set.rb)) that
11
+ walks that tree looking for one specific shape — a call to a known-dangerous method, a
12
+ Rails-config assignment, a missing guard — and emits a `Scryer::Finding` when it matches.
13
+ `Scryer::Scanner` finds every Ruby file under the configured directories, parses each once, and
14
+ runs every registered rule against the resulting tree; `Scryer::ReportRenderer` merges the results
15
+ (plus a separate `Scryer::DependencyAudit` pass over `Gemfile.lock`) into JSON/HTML/CSV/SARIF and
16
+ computes the cross-category "Top priorities" ranking and security score. This is heuristic
17
+ pattern-matching on syntax, not [taint/data-flow
18
+ analysis](https://ramlaxmanyadav.github.io/scryer/taint-analysis-vs-heuristic-pattern-matching.html)
19
+ — no attempt is made to trace whether a specific value actually flows from an untrusted source to
20
+ a dangerous sink, which is exactly what makes it possible to run with zero runtime dependencies and
21
+ no Rails/database boot.
22
+
23
+ ## Performance
24
+
25
+ Parsing is the only real cost, and it's linear in file count/size — there's no Rails boot, no
26
+ database connection, and (with `--no-deps`) no network call at all. A real, unscoped scan of a
27
+ 236-file production Rails app (the same one behind the [Security score](#security-score) example
28
+ above) completes in about 2.5 seconds end to end on a laptop, `--no-deps` set. The one deliberately
29
+ slower path is the [dependency audit](./rules.md#dependency-audit) (on by default): it queries
30
+ [OSV.dev](https://osv.dev) once per unique gem in `Gemfile.lock`, so total time scales with distinct
31
+ dependency count and network latency, not codebase size — skip it with `--no-deps` (or `nodeps`)
32
+ for a fast, fully offline run when that matters more than dependency coverage on a given run (e.g.
33
+ a pre-commit hook, versus a full CI job).
34
+
35
+ ## Security model
36
+
37
+ - **Static analysis only, by default.** The core scan reads source files and never executes your
38
+ code. The [runtime query watcher](./rails-integration.md#runtime-query-watcher) and [runtime authorization
39
+ watcher](./rails-integration.md#runtime-authorization-watcher) are the only things that hook into a running
40
+ process, and both are opt-in (you register them explicitly in an initializer) — a plain `scryer`
41
+ scan does neither.
42
+ - **No network calls except two, both skippable/opt-in.** The [dependency audit](./rules.md#dependency-audit)
43
+ queries OSV.dev (skip with `--no-deps`); [AI-assisted fix
44
+ suggestions](./fix-mode.md#ai-assisted-fix-suggestions) call whatever LLM endpoint you explicitly configure via
45
+ `c.ai_client` (nothing is called with no client configured, and no code snippet leaves your
46
+ machine unless you set one up).
47
+ - **Nothing is auto-applied without independent re-verification.** Every finding's `suggested_fix`
48
+ is just text until you act on it — except `scryer fix` ([Fix mode](./fix-mode.md#fix-mode)), the one command
49
+ that writes to disk, and even there only after re-parsing the file with that exact rewrite
50
+ applied and re-running the rule against it to confirm it actually clears the finding it targeted.
51
+ - **Zero runtime dependencies** beyond Ruby's own stdlib (`ripper`, `json`, `digest`,
52
+ `securerandom`, `set`, `net/http`, `uri`, `date`) — no transitive gem supply-chain surface to
53
+ audit beyond Ruby itself.
54
+ - **Safe to run against untrusted source.** Because it's a syntax-level static parse (never
55
+ `eval`, never `require`s the scanned code, never boots the target app), running Scryer against a
56
+ codebase you don't fully trust doesn't execute anything in it.
57
+
58
+ ## False-positive handling
59
+
60
+ Every finding carries a `confidence` (`high`/`medium`/`low`, distinct from severity) — Scryer's own
61
+ best-effort assessment of how likely that specific match is a real issue, not a false positive. The
62
+ `idor` rule is explicitly the least precise check in the gem (see the [comparison
63
+ table](#scryer-vs-rubocop-vs-brakeman-vs-bundler-audit) footnotes) — real false-positive risk on
64
+ admin-gated or genuinely-global-model controllers; several other rules' false-positive risks and
65
+ mitigations are documented alongside them in [What Scryer
66
+ detects](./rules.md#what-scryer-detects). When a specific rule doesn't fit your codebase:
67
+
68
+ - **Silence it entirely**: `--skip RULE_ID` (repeatable) for a one-off run, or `c.skip_rules` in
69
+ the initializer for every run — see [Skipping rules](./usage.md#skipping-rules).
70
+ - **Suppress only pre-existing findings, not new ones**: [Baseline mode](./usage.md#baseline-mode) snapshots
71
+ the current findings and only reports/gates on what's new since then — the practical way to adopt
72
+ Scryer on a codebase with real pre-existing security debt without either fixing everything on day
73
+ one or turning the gate off entirely.
74
+ - **Confirm a specific fix actually worked**: `scryer verify` ([Verifying a
75
+ fix](./fix-mode.md#verifying-a-fix)) re-runs one rule against one file in isolation, independent of a full
76
+ scan.
77
+
78
+ ## A note on how this gem was actually verified
79
+
80
+ Unlike most hand-written Rails work, this gem's core scanning engine was genuinely executed
81
+ during development — `Ripper` needs no Rails or bundler to run, so every rule here was tested
82
+ against real vulnerable-code and clean/fixed-code fixtures, not just written and hoped for. Where
83
+ a rule produced a false positive during that testing (an actual one did — `BCrypt::Password.create`
84
+ initially tripped the mass-assignment rule meant for `Model.create(params)`), it was caught and
85
+ fixed before being considered done. That's a meaningfully higher bar of confidence than most of
86
+ this account's Rails-app work, which can't be executed in the sandbox it was built in.
87
+
88
+ That said: this is a heuristic tool, not full data-flow/taint analysis (that's what a mature tool
89
+ like Brakeman spends years building). Expect some false positives and false negatives — that's
90
+ normal for this class of tool, not a bug. An already-guarded call can still get flagged since
91
+ rules don't trace surrounding conditionals — always review a finding in its surrounding context
92
+ before acting on it.
93
+
94
+ ## Security score
95
+
96
+ Every scan produces a single 0-100 score plus a letter grade (A-F), shown in the console summary
97
+ and as a badge at the very top of the HTML report:
98
+
99
+ ```
100
+ Security Score: 11/100 (F)
101
+ ```
102
+
103
+ The formula (`ReportRenderer#security_score` in `lib/scryer/report_renderer.rb`): every security
104
+ and dependency finding costs points, weighted by both its severity *and* its confidence (a
105
+ `low`-confidence `idor` finding costs less than a `high`-confidence `sql_injection` finding at the
106
+ same severity), combined via exponential decay from 100 rather than linear subtraction — one
107
+ critical finding visibly moves the score (100 → ~86) without a handful of findings driving any
108
+ real app straight to a hard-clamped 0.
109
+
110
+ Two things worth being precise about before you treat this number as meaningful:
111
+
112
+ - **It's not normalized by app size.** A 10-file app and a 1,000-file app with the same finding
113
+ *density* will score very differently here — this score reflects a scan's absolute finding
114
+ exposure, not a rate. That makes it useful for tracking *one project's own trend* over time (did
115
+ the next scan score higher or lower), not for comparing two differently-sized codebases against
116
+ each other.
117
+ - **Performance and code-quality findings aren't part of it.** This is a *security* score — only
118
+ security and dependency findings count. A slow app with clean security findings still scores
119
+ well here; check the "Performance"/"Code Quality" rows in the summary box separately for that.
120
+
121
+ Alongside the score, every scan also reports a **rule-level pass rate** — "how many of Scryer's
122
+ registered checks fired zero findings":
123
+
124
+ ```
125
+ Checks: 23/36 rules clean (63.9%)
126
+ ```
127
+
128
+ This is the closest thing Scryer has to Brakeman's "X checks, Y warnings" framing — a coverage
129
+ signal, not a risk signal. It's deliberately a *different* number from the security score, and the
130
+ two won't always agree: a codebase can have a high clean rate (few distinct rules ever fire) and
131
+ still a low score (the few that did fire were severe and high-confidence), or the reverse (many
132
+ different rules each firing once, none of them serious). `ReportRenderer#rules_clean_rate` computes
133
+ it from `Scryer::RuleSet.all` (every registered security/performance/style rule) against which
134
+ rule_ids actually appeared in this scan's findings — dependency checks aren't counted here since
135
+ they're not backed by a `Scryer::Rule` subclass. Report both; neither alone tells the whole story.
136
+
137
+ The 10/F above is a real score from the acme-app example, not a cherry-picked good result — see
138
+ [A note on how this gem was actually verified](#a-note-on-how-this-gem-was-actually-verified) for
139
+ why every example in this README is real output.
140
+
141
+ ## Accuracy benchmark
142
+
143
+ Every rule in this gem is heuristic pattern-matching — stated throughout this README, but until now
144
+ never backed by a measured number, just "expect some false positives, that's normal for this class
145
+ of tool." [`benchmark/`](../benchmark/) turns that into an actual precision/recall benchmark: a
146
+ hand-labeled corpus of small Ruby/Rails snippets per rule (`benchmark/corpus.rb`), each marked
147
+ `vulnerable` (should fire) or `safe` (should NOT fire — including deliberately close "near miss"
148
+ samples that resemble the vulnerable shape, not just obviously unrelated code), run through the
149
+ real `Scryer::Rule#scan` — the same code path an actual scan uses, not a simulation.
150
+
151
+ ```bash
152
+ rake benchmark # or: ruby benchmark/run.rb
153
+ ```
154
+
155
+ Current numbers, across all 36 registered rules and 180 labeled samples:
156
+
157
+ | | TP | FN | FP | TN | Precision | Recall | F1 |
158
+ |---|---|---|---|---|---|---|---|
159
+ | **All 36 rules** | 82 | 3 | 1 | 94 | **98.8%** | **96.5%** | **97.6%** |
160
+
161
+ Two rules score below 100% — both are gaps this gem already disclosed in the rule's own top comment
162
+ before this benchmark existed, now actually measured instead of just asserted:
163
+
164
+ - **`graphql_missing_query_limits`** (50% precision, 50% recall on its 4 samples): misses a schema
165
+ that gets its query limits through a shared custom base class or an `include`d module — both
166
+ require cross-file resolution this per-file rule doesn't attempt.
167
+ - **`unbounded_table_scan`** (recall 66.7%): misses an unbounded scan once the query is assigned to
168
+ a variable before `.each` instead of chained directly (`orders = Order.where(...); orders.each`).
169
+ - **`mass_assignment`** (recall 66.7%): the one gap this benchmark actually *found*, not just
170
+ measured — see the CHANGELOG entry on the ivar-receiver fix. The corpus's other documented
171
+ false-negative sample for this rule (a namespaced `Admin::Order.create(...)` receiver) is still
172
+ an open, disclosed gap.
173
+
174
+ **Read `benchmark/README.md` before quoting any of these numbers** — this is a corpus we wrote
175
+ ourselves specifically to stress-test our own rules, not an independent third-party benchmark (the
176
+ way, say, the OWASP Benchmark project is for Java tools), and not real production code the way the
177
+ acme-app numbers used throughout this README are. Treat it as a lower bound on rigor (something
178
+ concrete was actually measured) and an upper bound on confidence (real-world false-positive/
179
+ false-negative rates on *your* codebase, with its own idioms and libraries, can and will differ).
180
+
181
+ ## Scryer vs RuboCop vs Brakeman vs bundler-audit
182
+
183
+ Brakeman is the deeper, more mature tool for the security categories it's spent years on — real
184
+ taint/data-flow analysis, not heuristic pattern-matching (footnote [i]). RuboCop owns style/lint
185
+ conventions; Scryer stays out of that territory except for one narrow, deliberately-scoped check
186
+ (footnote [h]). Scryer isn't trying to out-analyze either of them at their own specialty. What it
187
+ does instead: look at security, performance, dependency, and code-quality findings *together* and
188
+ rank the result by severity, so "what should I fix first" has one answer instead of four separate
189
+ tool outputs (and four different severity scales) to reconcile by hand — see footnote [j]. If you
190
+ already run Brakeman for its deeper security analysis or RuboCop for style, keep doing that; Scryer
191
+ sits alongside them and adds the cross-category picture neither one (nor bundler-audit) produces.
192
+
193
+ | Capability | Scryer | RuboCop | Brakeman | bundler-audit |
194
+ |-------------------------------------------|:------:|:--------:|:--------:|:-------------:|
195
+ | Style/lint conventions | Partial [h] | ✅ | ❌ | ❌ |
196
+ | Rails security scanning | ✅ [i] | ❌ | ✅ | ❌ |
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
+ | SARIF report (GitHub Code Scanning, etc.) | ✅ | ❌ | ✅ | ❌ |
204
+ | Human-reviewable fix suggestions | ✅ | Partial [e] | Partial [f] | Limited [g] |
205
+ | Cross-category risk ranking ("fix this first") | ✅ [j] | ❌ | ❌ | ❌ |
206
+ | CWE / OWASP Top 10 category tags | ✅ [k] | ❌ | ✅ [k] | ❌ |
207
+ | Confidence level per finding | ✅ | ❌ | ✅ | ❌ |
208
+ | Official GitHub Action | ✅ [l] | ❌ | ❌ | ❌ |
209
+ | Baseline / new-vs-existing-findings diffing | ✅ [m] | ❌ | Partial [m] | ❌ |
210
+ | Overall security score | ✅ | ❌ | ❌ | ❌ |
211
+ | Fix verified against an actual re-scan | ✅ [n] | ❌ | ❌ | ❌ |
212
+ | Single command covering all of the above | ✅ | ❌ | ❌ | ❌ |
213
+
214
+ - **[a]** `rubocop-performance` adds some Ruby/Rails performance cops, but nothing like N+1-query
215
+ or missing-pagination detection.
216
+ - **[b]** A handful of cops catch exact-duplicate patterns (e.g. `Lint/DuplicateMethods`) — no
217
+ near-duplicate/similarity detection across methods.
218
+ - **[c]** RuboCop's built-in HTML formatter is a plain findings list, not an interactive report.
219
+ - **[d]** bundler-audit's output is primarily console text; no first-class JSON formatter.
220
+ - **[e]** RuboCop's `-A` auto-corrects many style violations directly — an automatic rewrite, not
221
+ a human-reviewable explanation, and only for auto-correctable cops.
222
+ - **[f]** Brakeman's warnings describe the issue and a confidence level, not a concrete
223
+ before/after code fix.
224
+ - **[g]** bundler-audit names the patched version to upgrade to; no code-level remediation (it
225
+ doesn't operate on your code at all, only `Gemfile.lock`).
226
+ - **[h]** One check only: a missing `# frozen_string_literal: true` magic comment
227
+ (`frozen_string_literal`, info severity). Everything else in RuboCop's style/lint domain —
228
+ naming, layout, quote style, line length, and hundreds more — is intentionally out of scope; see
229
+ [Skipping rules](./usage.md#skipping-rules) if you don't want even this one.
230
+ - **[i]** Heuristic pattern-matching, not taint/data-flow analysis — Brakeman traces whether user
231
+ input can actually *reach* a sink; Scryer checks whether a known-dangerous call shape and a
232
+ `params` reference appear in the same expression. That's a real precision gap on the harder
233
+ checks especially (`idor` in particular has real false-positive risk — see
234
+ [What Scryer detects](./rules.md#what-scryer-detects)), and it's why every finding says "review this,"
235
+ never "this is definitely a bug."
236
+ - **[j]** Each tool ranks findings within its own domain at best (e.g. Brakeman's per-warning
237
+ confidence level). None of them combine security, performance, dependency, and code-quality
238
+ findings into one ranked list — that's a different question than any single tool is built to
239
+ answer. `ReportRenderer#top_risks` sorts every severity-bearing finding from a scan (across all
240
+ four categories) by severity, shown as "Top priorities" in the console summary and at the top of
241
+ the HTML report — see the example near the top of this README.
242
+ - **[k]** Not a Scryer-only capability — Brakeman's own warnings already include a CWE reference.
243
+ Scryer's version is a fuller mapping (every one of its 31 security rules carries both a CWE ID
244
+ and an OWASP Top 10 (2021) category, aggregated into an OWASP coverage scorecard in every
245
+ report), but the underlying idea isn't new; see
246
+ [What Scryer detects](./rules.md#what-scryer-detects) for the honesty caveat on how this mapping was built
247
+ (Scryer's own best-effort categorization, not OWASP-audited).
248
+ - **[l]** [`action.yml`](../action.yml) in this repo — `uses: ramlaxmanyadav/scryer@<version>` runs
249
+ the scan and uploads SARIF to GitHub Code Scanning in one step, instead of hand-writing the
250
+ workflow YAML in [CI/CD integration](./usage.md#cicd-integration) yourself (that manual version still
251
+ works and is documented there too, for anyone who wants full control over the steps).
252
+ - **[m]** Brakeman has an ignore file (`brakeman -I`) that permanently silences specific warnings
253
+ by fingerprint — genuinely similar in mechanism to [Baseline mode](./usage.md#baseline-mode)'s
254
+ fingerprinting, marked Partial rather than ✅ here because it's a permanent suppression list you
255
+ maintain by hand, not an explicit "show me only what's new since this snapshot, and tell me
256
+ what got fixed" diff against a saved baseline the way `scryer --baseline` reports both.
257
+ - **[n]** `scryer verify --rule RULE_ID --file PATH` re-runs one rule against one file to confirm a
258
+ fix actually cleared it, and — when an `ai_client` is configured — the same check runs
259
+ automatically on every AI-rewritten `suggested_fix` before the report is written (see
260
+ [Verifying a fix](./fix-mode.md#verifying-a-fix)). RuboCop's `-A` rewrites code directly rather than verifying
261
+ a *suggested* fix; Brakeman and bundler-audit don't rewrite or re-check at all.
262
+
263
+ Brakeman's security analysis and RuboCop's style checks are still worth running on their own —
264
+ Scryer doesn't try to replace either, and running it alongside them costs nothing (different tools,
265
+ different config files, no shared state). What Scryer adds is the view none of them produce alone:
266
+ one ranked list of what's actually most worth fixing, built from security, performance,
267
+ dependency, and code-quality findings together.
268
+
@@ -0,0 +1,42 @@
1
+ # Contributing & Releasing
2
+
3
+ [← Back to Scryer — Ruby on Rails Security Auditor](../README.md)
4
+
5
+ ## Extending it
6
+
7
+ Every rule is a small class extending `Scryer::Rule` — see `lib/scryer/rules/*.rb` (security),
8
+ `lib/scryer/performance_rules/*.rb` (performance), and `lib/scryer/style_rules/*.rb` (style) for
9
+ the pattern. A new rule file dropped into any of the three directories is picked up automatically
10
+ (rules self-register via `Rule.inherited` — no manual wiring needed, just `self.category =
11
+ "security"|"performance"|"style"`). `Scryer::Ast` has the tree-walking helpers used throughout.
12
+
13
+ Scryer's own rules have their own Minitest suite — `rake test` (from the repo root, not inside a
14
+ host app) runs a bad/clean fixture pair against every registered rule (`test/rule_fixtures_test.rb`),
15
+ plus a completeness check that fails loudly if a new rule ships with no fixture. Add an entry to
16
+ `RuleFixturesTest::FIXTURES` for any new or changed rule — and, for a genuine false positive/
17
+ negative found against real code (not just the regression pair), add it to
18
+ [`benchmark/corpus.rb`](../benchmark/corpus.rb) too (see [Accuracy benchmark](./architecture.md#accuracy-benchmark))
19
+ with a `note:` explaining the real-world shape it represents, so it's measured going forward
20
+ instead of just fixed once and forgotten.
21
+
22
+ ## Releasing
23
+
24
+ Two GitHub Actions workflows handle this repo's own CI/CD (separate from `action.yml`, which is
25
+ what *users* of Scryer drop into their own app's CI):
26
+
27
+ - [`.github/workflows/ci.yml`](.github/workflows/ci.yml) — runs `rake test` + `rake benchmark` +
28
+ a syntax sweep + a `gem build` sanity check on every push/PR to `main`, across every Ruby version
29
+ `required_ruby_version` claims to support (2.7 through 3.3).
30
+ - [`.github/workflows/release.yml`](.github/workflows/release.yml) — pushing a tag matching `v*`
31
+ re-runs the test suite, then publishes to RubyGems via
32
+ [Trusted Publishing](https://guides.rubygems.org/trusted-publishing/) (OIDC — no API key stored
33
+ as a GitHub secret, nothing to leak or rotate).
34
+
35
+ Trusted Publishing needs a one-time setup on rubygems.org before the release workflow can actually
36
+ publish anything (this can't be done from the repo itself — it's a rubygems.org account action):
37
+ sign in, go to the scryer gem's page → **Trusted Publishers** → add one with owner
38
+ `ramlaxmanyadav`, repository `scryer`, workflow filename `release.yml`, no environment. After that,
39
+ `git tag vX.Y.Z && git push origin vX.Y.Z` is the entire release process — bump
40
+ `lib/scryer/version.rb`, update `CHANGELOG.md`, commit, tag, push the tag, and the workflow does
41
+ the rest.
42
+