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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4d053e64db4a99c6ea79407677ebe96ac57391e4e9e8442e0246286bbc5f02b3
4
- data.tar.gz: c3f2850013d4e85d18c317b6c208a97efd79b29f88fa56d573d98788e8527219
3
+ metadata.gz: 688af786218666d530e02b72ec75afbd2e64fb811cda54e7bb732531bb69af1d
4
+ data.tar.gz: ffb6e7eba883bfaa1165462db1278a59e2cbe97e0d16adbf19b44fe25ac27293
5
5
  SHA512:
6
- metadata.gz: 9cb23f7a30cdf2660cd208589134b265d213547d73d014ccfafba7729665b78ed5dad3125fd08c30d637c067d6051584e8ffa4a578bd9a40b9b932a96071c78d
7
- data.tar.gz: 6ae2daecc08965c60a8ce1097a95721993e0fcfb5e772c1f10f88f552f07f8609212248a03588026cb180b1b1e50de7ec8f2188672163aaf7118d2560aaaebfb
6
+ metadata.gz: 02c51d96853d124cdf7c33b5072f29e35fede7d1df080b41131429daa1e251d4f807043ad1eb51de081d49486b08d7932a34925671895332dec22c88dcba37e7
7
+ data.tar.gz: ce8d5f7770abff15b0d8a37979982a3ba1a11a07f44f217d83fb0277e038029b44cce8fb7623ef96c2cbd5f29a30efec3084015c7a99ae751d85e16b64eb376a
data/CHANGELOG.md CHANGED
@@ -3,6 +3,420 @@
3
3
  All notable changes to this project are documented here. Format loosely follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
+ ## [Unreleased]
7
+
8
+ ## [1.2.0] - 2026-08-16
9
+
10
+ - Added `c.detect_duplicates` (`Scryer::Configuration`, default `true`): duplicate-code detection
11
+ (method/query/cache-key similarity across models, controllers, helpers, and concerns) previously
12
+ ran unconditionally with no way to turn it off, since it isn't a `Scryer::Rule` and has no
13
+ `rule_id` for `skip_rules` to address. Set `c.detect_duplicates = false` in
14
+ `config/initializers/scryer.rb` to disable it project-wide, or override for a single run with
15
+ the standalone executable's new `--no-duplicates` flag or the rake task's new `noduplicates`
16
+ bracket token (`rails 'scryer:report[html,noduplicates]'`, alongside the existing `nodeps`) —
17
+ either can only turn detection off, never back on over an already-`false` config. `scryer
18
+ verify`/`scryer fix`/the fix-verification re-scan now always skip this pass internally too (none
19
+ of them ever read `duplicate_groups`), a small unconditional performance improvement independent
20
+ of this new config. Covered by new tests in `test/scanner_test.rb` and `test/cli_scan_test.rb`.
21
+ - The standalone `scryer`/`scryer fix` executable no longer requires `-r`/`--require` to pick up
22
+ an `ai_client` — if it's omitted entirely, `config/initializers/scryer.rb` under `--path` is now
23
+ auto-required when it exists (the same file a Rails app booted against the project would already
24
+ autoload). This was the single most common cause of "AI is configured but nothing gets fixed"
25
+ seen in real usage: forgetting `-r` silently left `ai_client` `nil`, indistinguishable from no
26
+ client being configured at all. An explicit `-r` always wins and is never overridden; a one-line
27
+ notice is printed whenever auto-discovery fires so it's never a silent behavior switch. Covered
28
+ by new tests in `test/cli_fix_test.rb`.
29
+ - `scryer fix`/`rails scryer:fix`'s interactive per-finding review prompt is now a numbered menu
30
+ (`1) Yes 2) Skip 3) Yes to all remaining 4) Cancel`) instead of `[y]es/[n]o/[a]ll/[s]kip`
31
+ letters — numeric choices only, and a blank or unrecognized answer re-prompts instead of
32
+ defaulting to "no." New: option 4, "Cancel," stops the review immediately — every remaining
33
+ candidate is marked skipped without even asking the AI client or mechanical fixer for a rewrite
34
+ (previously "skip all remaining" still ran an AI call for every not-yet-reviewed finding before
35
+ discarding the result). `Scryer::FixRunner.apply`'s `confirm:` callback can now return the symbol
36
+ `:cancel` to trigger this; a new `:cancelled` status is yielded to the progress block. `--yes`
37
+ (`SCRYER_FIX_YES=1`) remains the one-shot non-interactive way to fix everything in a single
38
+ command. Covered by new tests in `test/cli_fix_test.rb` and `test/fix_runner_test.rb`.
39
+ - Added `scryer fix --deps` (`SCRYER_FIX_DEPS=1 rails scryer:fix`): the 24-ish dependency findings
40
+ from `scryer --audit-deps`/`rails scryer:audit_dependencies` were previously report-only — nothing
41
+ in `scryer fix` ever touched them, since a `DependencyAudit::Finding` has no `.line`/`.file`/
42
+ `.rule_id` for `FixRunner`/`MechanicalFixer`/`FixVerifier` to target. New `Scryer::DependencyFixer`
43
+ runs `bundle update GEM --conservative` once per distinct vulnerable gem that has a published
44
+ patched version, then re-queries OSV.dev for that gem to confirm the bump actually cleared every
45
+ advisory before reporting it fixed. Gems with no fixed version yet, insecure `git://`/`http://`
46
+ sources, a past-EOL Ruby version, and an exposed `config/master.key` are always left for manual
47
+ review — none of those are a gem-version bump. Supports `--dry-run`. Covered by
48
+ `test/dependency_fixer_test.rb` and `test/cli_fix_deps_test.rb`.
49
+ - `scryer fix`/`rails scryer:fix` now try the configured `ai_client` *before* `Scryer::MechanicalFixer`
50
+ for every rule, including the six the mechanical fixer can already handle on its own
51
+ (`frozen_string_literal`, `sql_injection`'s sole-argument case, and the four config-flip rules) —
52
+ previously the mechanical fixer always won when it applied, even with an `ai_client` configured.
53
+ The mechanical fixer is now the fallback: it only runs when AI isn't configured, declined to
54
+ produce anything usable, raised, or its rewrite didn't verify. Same verify-then-write safety gate
55
+ either way. Fixed `FixVerifier.apply_line_replacement` to restore a line's original indentation
56
+ when an AI reply's `AFTER:` block drops it (confirmed against a real AI-generated fix in
57
+ production that came back flush against the left margin instead of matching the original 8-space
58
+ indent) — not a correctness bug, but a fix that silently strips indentation looks nothing like
59
+ something a developer would actually commit. Covered by new tests in `test/fix_runner_test.rb`.
60
+ - Fixed: `scryer fix`/`rails scryer:fix` silently swallowed an `ai_client` that raised an exception
61
+ (a bad API key, a network timeout, a malformed response) — it looked identical to "no ai_client
62
+ configured at all," both showing up as a generic `Skipped (needs manual review)` with the rule's
63
+ own default `suggested_fix` text, no way to tell which one actually happened. `AiFixSuggester.enhance!`
64
+ now accepts an `on_error:` callback (the failure still never propagates or breaks the run);
65
+ `FixRunner.apply` surfaces it as a new `:ai_error` status distinct from `:skipped`, and both
66
+ `scryer fix` and `rails scryer:fix` now print the actual exception class and message
67
+ (`Skipped (AI client error): mass_assignment — ...` / `RuntimeError: 401 Unauthorized`) instead
68
+ of leaving you to guess. Covered by new tests in `test/fix_runner_test.rb` and `test/cli_fix_test.rb`.
69
+ - `scryer verify` no longer requires both `--rule` and `--file` — omit either or both to broaden
70
+ scope: `--file PATH` alone checks every rule against that one file; `--rule ID` alone checks the
71
+ whole project for that one rule; neither flag checks the whole project against every rule (same
72
+ security/performance/style scope `--baseline` uses; duplicate-code groups excluded for the same
73
+ reason `--baseline` excludes them — they don't fit the same single-finding shape). The original
74
+ `--rule ID --file PATH` case is unchanged. The three broader cases print grouped by severity
75
+ (critical first) with each finding's message truncated to a scannable length, instead of a wall
76
+ of full-length messages — full untruncated detail is what a real `scryer -o report.json`/
77
+ `report.html` is for. Covered by `test/cli_verify_test.rb` (10 tests).
78
+ - Colored console output: the summary box, top priorities, and `scryer fix`/`scryer verify` output
79
+ are colored automatically at a real terminal (severity labels, the security score's letter
80
+ grade, fixed/skipped status lines) via a new hand-rolled `Scryer::Colorizer` — no new gem
81
+ dependency, consistent with the zero-runtime-dependency design. Off automatically when it would
82
+ be wrong to color (piped/redirected output, [`NO_COLOR`](https://no-color.org) set, `TERM=dumb`);
83
+ `--color`/`--no-color` (CLI) or `SCRYER_COLOR`/`SCRYER_NO_COLOR` (rake) force it either way,
84
+ taking precedence over `NO_COLOR`/auto-detection (an explicit per-invocation flag is more
85
+ specific than a session-wide env var — the same precedence ripgrep/eslint use). Covered by a new
86
+ `test/colorizer_test.rb` (13 tests).
87
+ - New `Scryer::MechanicalFixer`: deterministic, no-AI fixes for `frozen_string_literal`,
88
+ `sql_injection` (sole-argument case only), `force_ssl_disabled`, `insecure_cookie_serializer`,
89
+ `weak_session_cookie`, and `security_headers_disabled` (plain-assignment case only) — the narrow
90
+ set of rules where there's exactly one correct rewrite, so `scryer fix`/`rails scryer:fix` no
91
+ longer require an `ai_client` to fix these. A mechanical fixer always wins over the AI path when
92
+ one applies; falls through to `ai_client` (or manual review) otherwise. Flows through the exact
93
+ same verify-then-write pipeline as an AI-generated fix — nothing here is trusted more than an
94
+ LLM's guess would be. Fixes correctly strip quote marks directly hugging a `#{...}` interpolation
95
+ (the common `"id = '#{x}'"` manual-SQL-quoting style) rather than leaving them around the new `?`
96
+ placeholder — naively swapping just the interpolation would produce `where("id = '?'", x)`, which
97
+ double-quotes the bound value and silently breaks the query while still looking "verified" (the
98
+ rule only checks for interpolation, not query correctness) — covered by a dedicated regression
99
+ test. `scryer fix`'s usage error is now only raised when there's genuinely nothing an invocation
100
+ could fix (no `ai_client` AND nothing matched has a built-in fixer). Covered by
101
+ `test/mechanical_fixer_test.rb` (13 tests) plus updated coverage in `test/fix_runner_test.rb` and
102
+ `test/cli_fix_test.rb`.
103
+ - Fix mode: `scryer fix` (and `rails scryer:fix`) — the third leg of scan → fix → verify. Requires
104
+ an `ai_client`; asks it for a rewrite of every qualifying finding and **writes to the real file**
105
+ the ones `Scryer::FixVerifier` independently confirms actually clear the finding (re-parses the
106
+ file with that one line replaced and re-runs the flagged rule against it) — the same safety gate
107
+ AI-verified remediation already used for `fix_verified`, just now acting on it instead of only
108
+ reporting it. Anything not verified is left alone and listed as needing manual review, same as a
109
+ normal report. `--dry-run` previews without writing; `--rule`/`--file` scope to specific findings.
110
+ New `Scryer::FixRunner` module holds the apply/re-verify loop shared by both the CLI and rake
111
+ task (multiple fixes in one file are applied highest-line-number-first, so an earlier fix
112
+ expanding into several lines can't invalidate a not-yet-processed finding's line number earlier
113
+ in the same file); a final whole-project re-scan after every write confirms nothing regressed.
114
+ Covered by a new `test/fix_runner_test.rb` (dry-run never writes, real run writes only verified
115
+ fixes and skips the rest, the line-shift-ordering scenario specifically, and the re-verify step)
116
+ — 5 new tests, all passing alongside the existing suite.
117
+ - Fixed: `scryer fix`'s final re-scan verify step was silently skipped whenever every candidate
118
+ fixed successfully (it only ran when something was left for manual review) — the CLI never
119
+ actually confirmed its own writes on the common, all-clean path. `rails scryer:fix` never had
120
+ this bug. Caught by new CLI-level tests (`test/cli_fix_test.rb`) exercising `Scryer::CLI#run_fix`
121
+ directly, which the original `FixRunner`-only tests didn't cover.
122
+ - `scryer fix`/`rails scryer:fix`: pick specific findings by number when `--rule`/`--file` still
123
+ leave more than one candidate — `--list` (`SCRYER_FIX_LIST=1` for the rake task) prints a stable,
124
+ numbered candidate list without calling the AI client or writing anything; `--number 2` /
125
+ `--number 1,3` (`SCRYER_FIX_NUMBERS=...`) fixes only those positions.
126
+ - Fixed: `FixVerifier` marked a fix as "not verified" whenever the same rule fired *anywhere else in
127
+ the file*, instead of checking whether the specific offending code it targeted was gone — so a
128
+ file with two separate findings of the same rule (e.g. two `sql_injection` occurrences) could
129
+ never have either one verified, even when both individual fixes were completely correct. Now
130
+ matched by rule_id + `code_snippet`, the same identity `Baseline` fingerprints already use, so
131
+ each finding verifies independently of any others sharing its rule_id. Found by dry-running
132
+ `scryer fix --rule sql_injection` against a real app with two such findings in one file; covered
133
+ by a new regression test in `test/fix_runner_test.rb`.
134
+ - `scryer fix`/`rails scryer:fix` now print a live progress line for each finding as it's resolved
135
+ ("Fixed: ...", "Would fix: ...", "Skipped (needs manual review): ...") instead of only the final
136
+ summary once every candidate is done — each `Fixed`/`Would fix` line includes a short 1-2
137
+ sentence explanation of the change, pulled from the AI's own reply (`Scryer::FixRunner.explain`)
138
+ rather than only a bare rule_id/file/line. The final summary and re-scan verify step are
139
+ unchanged.
140
+ - `frozen_string_literal` is now opt-in for an unscoped `scryer fix`/`rails scryer:fix` sweep (no
141
+ `--rule`/rule_id given) — it's a cosmetic, `info`-severity finding that would otherwise touch
142
+ nearly every file in a project. At a real terminal it asks once, up front; non-interactively
143
+ (CI, piped stdin) it's excluded with a one-line notice, discoverable via
144
+ `--rule frozen_string_literal` / `SCRYER_FIX_YES=1`. `Scryer::MechanicalFixer.fix_frozen_string_literal`
145
+ also now actually analyzes the file before recommending the fix — it declines (falls back to
146
+ manual review) if it finds a plausible in-place string mutation (a bang-method or `<<` on a
147
+ string literal, or on a local variable assigned one earlier in the file), since freezing a file's
148
+ literals would otherwise raise `FrozenError` at runtime while still looking "verified" by
149
+ Scryer's own narrow check (it only looks for the magic comment's absence, not query/mutation
150
+ correctness).
151
+ - `scryer fix`/`rails scryer:fix`: per-finding interactive review. At a real terminal (not
152
+ `--dry-run`/`--yes`/`SCRYER_FIX_YES`), every independently-verified fix — rule, message,
153
+ explanation, and the actual `AFTER:` rewrite — is shown and confirmed one at a time before being
154
+ written, instead of silently applying everything that verified clean: `[y]es` / `[n]o` / `[a]ll
155
+ remaining` / `[s]kip all remaining`. Non-interactive runs keep applying everything automatically,
156
+ same as before this existed. `Scryer::FixRunner.apply` gained an optional `confirm:` callback
157
+ (model-layer, testable without a real terminal); the CLI/rake task supply the actual prompt. This
158
+ replaces the old upfront "pick which numbered findings to fix" prompt that used to appear
159
+ automatically in an interactive terminal with more than one match and no `--number` — the new
160
+ per-finding review covers the same need with more information (you see the actual diff) and less
161
+ friction (no numbers to type); `--number`/`--list` remain for scripted/non-interactive scoping.
162
+ - Fixed: every `Scryer::MechanicalFixer` rewrite was built from `finding.code_snippet`, which
163
+ `Ast.source_line` deliberately `.strip`s for display in a report — so a mechanically-fixed line
164
+ silently lost its original indentation on every real write (not a syntax break, since Ruby
165
+ doesn't require indentation, but a real diff-correctness bug no prior test caught, since they all
166
+ used `assert_includes`, which doesn't care about leading whitespace). Fixers now read the actual
167
+ on-disk line via a new `raw_line` helper; `security_headers_disabled`'s comment-out rewrite also
168
+ now preserves the original line's indentation instead of jumping to column 0. Covered by a new
169
+ regression test in `test/fix_runner_test.rb` that checks the exact written line, not just a
170
+ substring.
171
+
172
+ ## [1.1.1] - 2026-08-14
173
+
174
+ - Docs-only, generic file/controller names — the finding counts, severities, rule IDs, and line numbers in
175
+ every example are unchanged (still real, still exactly as scanned), only the identifying labels
176
+ are redacted. 1.1.0 is yanked from RubyGems for this reason; no code changed between 1.1.0 and
177
+ 1.1.1.
178
+
179
+ ## [1.1.0] - 2026-08-14
180
+
181
+ - `Scryer::AuthorizationWatcher` (`require "scryer/authorization_watcher"`, opt-in): a runtime
182
+ companion to the static `idor`/`missing_authorization`/`missing_policy_scope` rules. Registers
183
+ one `after_action` and checks Pundit's `pundit_policy_authorized?`/`pundit_policy_scoped?` and
184
+ CanCanCan's `@_authorized` (both real, verified-by-reading-the-actual-gem-source signals, not
185
+ guessed) to catch a write action (`create`/`update`/`destroy`, or any `POST`/`PUT`/`PATCH`/
186
+ `DELETE`) that completed successfully with neither library's authorization check actually
187
+ invoked during that request — a live, much lower-false-positive-risk signal than the static
188
+ rules, which can only ever see "no call is visible in this file's source," not whether it ran.
189
+ Silently a no-op on apps using neither library (checked per-request, not just at boot). Verified
190
+ end-to-end against real `ActionController::Base`/`ActionController::API` + real installed
191
+ `pundit`/`cancancan` gems (not mocked) — 7 scenarios (authorized, unauthorized,
192
+ GET-to-a-write-action-name, plain read action, both libraries, neither library present, an
193
+ API-only base class) all behave as designed.
194
+ - Accuracy benchmark (`benchmark/`): a hand-labeled corpus of `vulnerable`/`safe` Ruby/Rails
195
+ snippets per rule, run through the real `Rule#scan` path, reporting measured precision/recall/F1
196
+ per rule and in aggregate (`rake benchmark` / `ruby benchmark/run.rb`) — replacing "expect some
197
+ false positives, that's normal for this class of tool" with an actual number. Explicitly framed
198
+ as a synthetic, self-authored corpus, not an independent benchmark — see `benchmark/README.md`'s
199
+ honesty caveat. Directly caught a real bug while building it: `mass_assignment` never fired on
200
+ `@record.update(params[...])` (an instance-variable receiver) — only a bare constant or implicit
201
+ receiver was ever recognized as "likely a model" — even though that's at least as common a
202
+ real-world shape as `Model.new(...)`. Fixed (`IVAR_RECEIVER_METHODS`, scoped to `update`/
203
+ `update!`/`assign_attributes`/`attributes=` only, not `new`/`create`/`create!`, to avoid matching
204
+ `Hash#update`-style receivers) and verified against the full test suite and a real external app
205
+ with zero regressions. Now populated for all 36 registered rules, 180 hand-labeled samples total:
206
+ **82 TP / 3 FN / 1 FP / 94 TN — 98.8% precision, 96.5% recall, 97.6% F1** (`rake benchmark`). The
207
+ two rules scoring below 100% (`graphql_missing_query_limits`, `unbounded_table_scan`) are gaps
208
+ already disclosed in each rule's own top comment before this benchmark existed — this just
209
+ measured them for the first time instead of only asserting them.
210
+ - Test suite: Scryer's own rules now have automated coverage — `rake test` (Minitest, no new
211
+ runtime dependency) runs a bad/clean fixture pair against every one of the 36 registered rules,
212
+ plus a completeness check that fails if a new rule ships with no fixture. Previously this gem had
213
+ zero automated tests of its own; every rule change was verified by hand against ad hoc fixtures
214
+ and a real external Rails app each time. 37/37 passing, 0 rule bugs found while building the
215
+ fixtures — every rule's fixture behaved exactly as its own doc comment described.
216
+ - Testing: two new opt-in files let a host app assert its own scan stays clean as part of its
217
+ normal test suite instead of only via a separate `scryer` run — `require "scryer/rspec"`
218
+ (`have_no_critical_findings`, `have_no_findings_for(rule_id)` matchers) and
219
+ `require "scryer/minitest"` (`Scryer::MinitestAssertions#assert_no_critical_scryer_findings`/
220
+ `#assert_no_scryer_findings_for`). Neither loads automatically with the gem (RSpec/Minitest stay
221
+ out of the runtime dependency list). New `Scryer.scan(root:)` convenience method backs both,
222
+ reusing `c.dirs`/`c.skip_rules` from the host app's own configuration.
223
+ - `scryer verify --rule RULE_ID --file PATH`: a new CLI subcommand that re-parses one file and
224
+ re-runs one rule against it, independent of a full scan — confirms a specific fix actually
225
+ cleared the finding it targeted (exit 0 clean / 1 still firing), meant to run right after
226
+ applying a fix by hand or reviewing an AI-suggested one. `--list-rules` prints every known
227
+ rule_id.
228
+ - AI-verified remediation: when an `ai_client` is configured, every AI-rewritten `suggested_fix` is
229
+ now automatically re-checked — the rewritten line is substituted into an in-memory copy of the
230
+ real file (nothing written to disk) and the same rule is re-run against it. Each finding gets a
231
+ new `fix_verified` field (`true`/`false`/`nil`; JSON/HTML only — CSV/SARIF unchanged), and the
232
+ HTML report shows a green "AI fix verified" or red "AI fix NOT verified" line under the suggested
233
+ fix. Required extending the AI prompt to ask for a machine-parseable `AFTER:` code block as the
234
+ last thing in the model's reply (`Scryer::FixVerifier` parses it); shares its core re-scan logic
235
+ with `scryer verify` above.
236
+ - 3 new Rails config-audit rules, all file-scoped to avoid flagging Rails' own development/test
237
+ defaults: `consider_all_requests_local_production` (`config.consider_all_requests_local = true`
238
+ in `config/environments/production.rb` — shows full debug error pages in production; critical,
239
+ CWE-209), `verbose_production_log_level` (`config.log_level = :debug` in production — logs full
240
+ request params and SQL bind values; warning, CWE-532), `host_authorization_disabled`
241
+ (`config.hosts.clear` — disables Rails' Host-header allowlist entirely; any file, warning,
242
+ CWE-350). 31 security rules total now (up from 28), 36 registered rules overall.
243
+ - Generators: the generated `config/initializers/scryer.rb` now shows commented examples for
244
+ `c.skip_rules` and `c.ai_client`, not just `project_name`/`dirs`/`branch` — both were already
245
+ documented in USAGE but missing from the actual template. New `bin/rails scryer:ci` rake task —
246
+ a memorable shortcut for `scryer:report[json,sarif]`'s CI-sensible defaults.
247
+ - HTML report: every count in the Summary table and the "Warnings by type" table now links to the
248
+ matching findings, same click-to-drill-down as the OWASP coverage table and severity chart —
249
+ the Warnings-by-type rule-id column already linked to its rule group, but the count column next
250
+ to it didn't; Summary's category × severity cells (e.g. "Security × Critical") had no existing
251
+ anchor to link to at all, so each finding now carries a category tag (`security`/`performance`/
252
+ `style`, alongside the existing CWE/OWASP/confidence tags) and the search filter was extended to
253
+ match multiple space-separated terms (all required, not a literal multi-word phrase) so a cell
254
+ like that can link with a two-word filter (`security critical`) precisely. Renamed the
255
+ `.owasp-link` CSS class to `.filter-link` throughout, since it's no longer OWASP-specific.
256
+ - HTML report: the severity distribution chart's counts (Critical/Warning/Info, at the very top
257
+ next to the score badge) are now links to the matching section under Findings — same
258
+ click-to-drill-down as the OWASP coverage table's counts. The Findings filter box now also
259
+ applies to Top priorities (a non-matching item hides, same as a non-matching finding elsewhere
260
+ — previously Top priorities never changed at all while filtering), and any finding matched by
261
+ an active filter (typed or via a click-through link) gets a visible highlight, not just "still
262
+ shown" — the point of filtering/linking is to show *which* finding is the target, not just to
263
+ hide the rest.
264
+ - HTML report: each finding now shows its CWE ID, OWASP category, and confidence level as small
265
+ tags (previously this data existed in JSON/CSV/SARIF but was never actually displayed per-finding
266
+ in the HTML report, even though the OWASP coverage scorecard summarized it). The Findings search
267
+ box matches on these tags for free, and the OWASP coverage table's counts are now links that
268
+ jump to Findings with that exact category pre-filled into the search — the aggregate count and
269
+ the underlying findings are one click apart instead of two disconnected views.
270
+ - New **Security Score** — `ReportRenderer#security_score` combines every security + dependency
271
+ finding's severity and confidence into a single 0-100 number and a letter grade (A-F), via
272
+ exponential decay from 100 (not linear subtraction, so a handful of findings doesn't
273
+ hard-clamp every real app to 0). Shown in the console summary (`Security Score: 11/100 (F)`)
274
+ and as a badge + severity distribution chart at the very top of the HTML report. Deliberately
275
+ NOT normalized by app size — see the README's new "Security score" section for what that means
276
+ and doesn't mean. Performance/code-quality findings aren't part of it (this is a *security*
277
+ score).
278
+ - New **rules-clean-rate** — `ReportRenderer#rules_clean_rate` reports "N/M rules clean," a
279
+ rule-level pass rate (how many of the registered checks fired zero findings) distinct from the
280
+ finding-weighted security score — printed alongside it (`Checks: 20/33 rules clean (60.6%)`)
281
+ and in the HTML score panel.
282
+ - HTML report: leads with the score badge + severity chart, then Overview/Summary/OWASP
283
+ coverage, collapsed-by-default "Checks performed" reference tables, and the Findings section —
284
+ Top priorities first, then a new text search/filter box (rule/file/message, plain JS, no page
285
+ reload) above the severity-grouped, accordion-collapsed finding list.
286
+ - Removed the report footer's "heuristic static analysis, not full data-flow/taint analysis —
287
+ review every finding in its surrounding context" line — the caveat itself hasn't changed
288
+ (still true, still documented at length in the README and in individual rule comments), but
289
+ repeating it on every single generated report read as undermining confidence in the tool
290
+ rather than informing. The `Generated by Scryer vX (Ruby Y)` attribution stays.
291
+ - New **baseline mode** — `scryer --save-baseline PATH` snapshots every current finding
292
+ (security/performance/style/dependency) as a set of fingerprints; `scryer --baseline PATH`
293
+ on a later scan reports only findings new since that snapshot (plus how many were fixed),
294
+ so an app with real pre-existing security debt can gate CI on new issues without being forced
295
+ to fix everything on day one. Fingerprints are `rule_id`/`kind` + file/gem + offending source
296
+ text — deliberately NOT tied to line number, so an unrelated edit earlier in the same file
297
+ doesn't make an existing finding look simultaneously new and fixed. Rails/rake equivalent:
298
+ `rails 'scryer:save_baseline[PATH]'` to save, `SCRYER_BASELINE=PATH rails scryer:report` to
299
+ compare (an env var rather than another bracket-arg token, since rake's arg parsing was
300
+ already stretched thin). See `Scryer::Baseline` (`lib/scryer/baseline.rb`) and the README's
301
+ new "Baseline mode" section.
302
+ - Fixed a real, shared precision bug affecting `idor`, `authentication_bypass`, and
303
+ `csrf_protection_disabled`: all three checked whether a class was a controller via a class-name
304
+ extraction that only handled a plain `class Foo` — a namespaced class (`class
305
+ Admin::PostsController`, `class Api::V1::UsersController`) parses its name as a different Ripper
306
+ shape (`:const_path_ref`, not `:const_ref`), so the class name came back `nil` and the whole
307
+ class was silently never examined by any of the three rules. Fixed with a new shared
308
+ `Ast.class_name` helper (verified against `Ripper.sexp` for 2- and 3-level namespacing) and
309
+ re-verified against a real production Rails app ("acme-app" elsewhere in these docs), which uses
310
+ namespacing extensively:
311
+ `idor` findings 2 → 4, `csrf_protection_disabled` 0 → 3, all genuinely namespaced controllers
312
+ that were previously invisible to these checks.
313
+ - New `idor`-adjacent rules, broadening authorization coverage beyond IDOR's specific
314
+ find-with-no-guard pattern:
315
+ - `missing_authorization` — a controller's `create`/`update`/`destroy` action with no
316
+ authorization evidence anywhere in the class (catches write actions that don't call `.find`
317
+ at all, which `idor` can't see).
318
+ - `missing_policy_scope` — a Pundit-using controller's `index` action querying a model
319
+ directly instead of through `policy_scope` — the common "remembered `authorize`, forgot
320
+ `policy_scope`" gotcha, since `index` has no single record for `authorize` to check.
321
+ - Every security rule (all 28) now carries a **CWE ID**, an **OWASP Top 10 (2021) category**, and
322
+ a **confidence level** (`high`/`medium`/`low`, distinct from `severity` — see `Rule.confidence`
323
+ in `lib/scryer/rule.rb`). All three appear on every `Finding` and flow through every report
324
+ format (new JSON fields, new CSV columns, and in SARIF: `external/cwe/cwe-NN` + the OWASP
325
+ category as rule-level `tags`, plus `confidence`/`cwe`/`owasp_category` as per-result
326
+ `properties` and a combined severity+confidence `rank`). Performance/style rules get a
327
+ `confidence` too (no CWE/OWASP — that taxonomy is security-specific). This is Scryer's own
328
+ best-effort categorization for practitioner convenience, not an OWASP-endorsed or independently
329
+ audited mapping — see the README's "What Scryer detects" section for the full caveat. Brakeman
330
+ already tags CWE and reports a confidence level for its own warnings; this isn't a novel
331
+ capability, just a fuller version of something that idea already existed elsewhere (see the
332
+ comparison table's new footnote).
333
+ - New `ReportRenderer#owasp_coverage` — counts security findings per OWASP Top 10 category, shown
334
+ as a console summary block ("OWASP Top 10 (2021) coverage:") and a new HTML report section, a
335
+ direct byproduct of every security rule now carrying an `owasp_category`.
336
+ - New [`action.yml`](action.yml) — a reusable composite GitHub Action wrapping install + scan +
337
+ SARIF upload, correctly ordered so the upload still happens on runs where findings were found
338
+ (previously only documented as a manual YAML snippet in the README's CI/CD section, which still
339
+ works and is kept as the "full control" alternative).
340
+ - New `ReportRenderer#top_risks` — merges every severity-bearing finding from a scan (security,
341
+ performance, code quality, and dependencies) into one list, sorted by severity first and
342
+ category second, so a scan produces one ranked "what to fix first" answer instead of four
343
+ separate category counts. Shown as a "Top priorities" list in the CLI/rake console summary
344
+ (right after the finding-count box) and, in the HTML report, at the top of the Findings section
345
+ — inside it rather than as its own top-level section, so it reads as "here's what to look at
346
+ first within these findings" rather than a fifth thing to scroll past before the findings even
347
+ start. Pure aggregation of data every format already collects — no new detection logic, and
348
+ JSON/CSV/SARIF are unaffected (each is consumed by tooling that does its own sorting).
349
+ - HTML report: the "Checks performed" section's three rule-reference tables (Security/
350
+ Performance/Style — every rule that *can* fire, not what actually did) are now collapsed-by-
351
+ default accordions instead of always-expanded tables, matching the same pattern individual rule
352
+ groups under Findings already used. With 28 security rules alone, showing all three tables
353
+ expanded on load buried Findings and Top priorities under a wall of reference rows nobody asked
354
+ to see first.
355
+ - HTML report: "Duplicate code groups" moved to after "Dependency audit"/"Files that couldn't be
356
+ parsed", as the last section before the footer.
357
+ - Fixed: the "Dependency audit" section's "Expand all"/"Collapse all" controls did nothing —
358
+ dependency findings rendered as plain, always-expanded `.finding` divs, never actually wrapped
359
+ in the `.accordion` markup those buttons operate on. Each of the 24 dependency findings in a
360
+ typical scan is now its own collapsed-by-default accordion, same pattern as Findings/Checks
361
+ performed.
362
+ - Repositioned the README, gemspec, docs site, and llms.txt around this: Scryer's differentiator
363
+ isn't "one command instead of several tools," it's ranking risk *across* security, performance,
364
+ dependencies, and code quality — something none of RuboCop/Brakeman/bundler-audit do even
365
+ within their own domain, let alone across all four. The honest heuristic-vs-taint-analysis
366
+ framing versus Brakeman (and the `idor` false-positive caveat) carries over unchanged from the
367
+ 1.0.0 comparison table — this is a reframing of what Scryer adds, not a new claim about
368
+ detection accuracy.
369
+
370
+ - False-positive hardening pass across every rule added in 1.0.0 (10 security rules) plus every
371
+ pre-1.0.0 rule (the original 9 security checks, 4 performance rules, `frozen_string_literal`):
372
+ each rule was tested against realistic *safe* Rails idioms (not just its original single "clean"
373
+ fixture) run through the actual scanner, not reasoned about in the abstract. Real false
374
+ positives found and fixed:
375
+ - `job_raw_params` — `params.dig(:x)` and `params.permit(...)` (Rails' own narrow/allowlisting
376
+ extraction idioms) were treated the same as forwarding the raw `params` hash; now recognized
377
+ as narrow, alongside the existing `params[:x]` exemption.
378
+ - `weak_crypto` — a same-line substring match on "password" false-positived on
379
+ `passwordless_token`-style identifiers and on a defensive comment explicitly disclaiming
380
+ password use (`# cache key, not a password hash`); both now excluded.
381
+ - `cors_misconfiguration` — checked wildcard-origin and `credentials: true` file-wide instead of
382
+ per `allow do...end` block, flagging a common legitimate pattern (a public wildcard-origin API
383
+ and a separate, properly-credentialed partner API in the same initializer). Now scoped per
384
+ block.
385
+ - `idor` — `after_action :verify_authorized`/`:verify_policy_scoped` (Pundit's own safety-net
386
+ callbacks) weren't recognized as guards since they're passed as callback-name arguments, not
387
+ called directly; also added CanCanCan's `load_and_authorize_resource`/`authorize_resource`.
388
+ - `ssrf` — a URL with a fixed, literal host and only a path segment derived from `params`
389
+ (`"https://api.example.com/users/#{params[:id]}"`) was flagged with the same severity/message
390
+ as a fully attacker-controlled URL; now downgraded to a warning with an accurate message
391
+ (still flagged — a dynamic path on a fixed host isn't zero-risk — just not overstated).
392
+ - `path_traversal` — `send_file`/`send_data`'s keyword options (`filename:`, `type:`,
393
+ `disposition:`, which only affect response headers, never the filesystem) were checked for
394
+ `params` the same as the actual path argument.
395
+ - `inefficient_save_loop` — added a `db/seeds.rb`/`db/seeds/**` exemption (a one-time setup
396
+ script, not a request-handling hot path) but the first version compared against the file path
397
+ without accounting for the `./` prefix `Scanner` actually produces, so the exemption silently
398
+ never matched; fixed and re-verified against a real `Scryer::Scanner` run, not a hand-built
399
+ `Rule` instance.
400
+ - `authentication_bypass` — an `only:`-scoped skip (a common, often-legitimate pattern for
401
+ public actions on an authenticated controller) got the same "this is reachable without login"
402
+ wording as an unscoped skip; message now distinguishes the two (detection unchanged — a scoped
403
+ skip is still worth a human glance, just not implied to be a bug on its own).
404
+ - Two disclosed-but-not-fixed gaps, documented in-code rather than silently left: `idor`'s
405
+ already-known imprecision is joined by `graphql_missing_query_limits` (misses limits set via
406
+ an `include`d module rather than the class body directly — real cross-file resolution, not a
407
+ same-file AST tweak) and `active_storage_inline_disposition` (doesn't cross-reference the
408
+ separate content-type-validation rule, since the realistic case spans two different files).
409
+ - Everything else audited (`sql_injection`, `mass_assignment`, `command_injection`,
410
+ `hardcoded_secret`, `unsafe_deserialization`, `xss_unsafe_html`, `csrf_protection`,
411
+ `open_redirect`, `force_ssl_disabled`, `weak_session_cookie`, `insecure_cookie_serializer`,
412
+ `hardcoded_basic_auth`, `security_headers_disabled`, `jwt_insecure_usage`,
413
+ `action_cable_forgery_protection_disabled`, `hardcoded_secret_key_base`,
414
+ `n_plus_one_query`, `missing_pagination`, `unbounded_table_scan`, `frozen_string_literal`)
415
+ was verified clean against the same battery of realistic safe-idiom fixtures — no changes
416
+ needed.
417
+ - Every fix was re-verified against its rule's original true-positive case to confirm nothing
418
+ was silenced along with the false positive.
419
+
6
420
  ## [1.0.0] - 2026-08-13
7
421
 
8
422
  Seventeen new security rules, a SARIF report format, a Ruby end-of-life check, and a