scryer 1.0.0 → 1.1.1

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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +253 -0
  3. data/README.md +459 -41
  4. data/lib/generators/scryer/USAGE +10 -2
  5. data/lib/generators/scryer/templates/scryer_initializer.rb +15 -0
  6. data/lib/scryer/ai_fix_suggester.rb +29 -9
  7. data/lib/scryer/ast.rb +26 -0
  8. data/lib/scryer/authorization_watcher.rb +156 -0
  9. data/lib/scryer/baseline.rb +75 -0
  10. data/lib/scryer/cli.rb +189 -3
  11. data/lib/scryer/finding.rb +6 -0
  12. data/lib/scryer/fix_verifier.rb +82 -0
  13. data/lib/scryer/minitest.rb +48 -0
  14. data/lib/scryer/performance_rules/inefficient_save_loop_rule.rb +32 -0
  15. data/lib/scryer/performance_rules/missing_pagination_rule.rb +1 -0
  16. data/lib/scryer/performance_rules/n_plus_one_query_rule.rb +1 -0
  17. data/lib/scryer/performance_rules/unbounded_table_scan_rule.rb +1 -0
  18. data/lib/scryer/report_renderer.rb +539 -46
  19. data/lib/scryer/rspec.rb +55 -0
  20. data/lib/scryer/rule.rb +22 -2
  21. data/lib/scryer/rules/action_cable_forgery_protection_rule.rb +3 -0
  22. data/lib/scryer/rules/active_storage_inline_disposition_rule.rb +3 -0
  23. data/lib/scryer/rules/active_storage_missing_content_type_validation_rule.rb +3 -0
  24. data/lib/scryer/rules/authentication_bypass_rule.rb +30 -7
  25. data/lib/scryer/rules/command_injection_rule.rb +3 -0
  26. data/lib/scryer/rules/consider_all_requests_local_rule.rb +51 -0
  27. data/lib/scryer/rules/cors_misconfiguration_rule.rb +51 -20
  28. data/lib/scryer/rules/csrf_protection_rule.rb +60 -11
  29. data/lib/scryer/rules/force_ssl_rule.rb +3 -0
  30. data/lib/scryer/rules/graphql_missing_query_limits_rule.rb +31 -0
  31. data/lib/scryer/rules/hardcoded_basic_auth_rule.rb +3 -0
  32. data/lib/scryer/rules/hardcoded_secret_key_base_rule.rb +3 -0
  33. data/lib/scryer/rules/hardcoded_secret_rule.rb +3 -0
  34. data/lib/scryer/rules/host_authorization_disabled_rule.rb +50 -0
  35. data/lib/scryer/rules/idor_rule.rb +63 -9
  36. data/lib/scryer/rules/insecure_cookie_serializer_rule.rb +3 -0
  37. data/lib/scryer/rules/job_raw_params_rule.rb +40 -7
  38. data/lib/scryer/rules/jwt_insecure_rule.rb +3 -0
  39. data/lib/scryer/rules/mass_assignment_rule.rb +32 -5
  40. data/lib/scryer/rules/missing_authorization_rule.rb +103 -0
  41. data/lib/scryer/rules/missing_policy_scope_rule.rb +134 -0
  42. data/lib/scryer/rules/open_redirect_rule.rb +3 -0
  43. data/lib/scryer/rules/path_traversal_rule.rb +22 -1
  44. data/lib/scryer/rules/security_headers_rule.rb +3 -0
  45. data/lib/scryer/rules/sql_injection_rule.rb +3 -0
  46. data/lib/scryer/rules/ssrf_rule.rb +67 -13
  47. data/lib/scryer/rules/unsafe_deserialization_rule.rb +3 -0
  48. data/lib/scryer/rules/verbose_production_log_level_rule.rb +53 -0
  49. data/lib/scryer/rules/weak_crypto_rule.rb +37 -2
  50. data/lib/scryer/rules/weak_session_cookie_rule.rb +3 -0
  51. data/lib/scryer/rules/xss_unsafe_html_rule.rb +41 -0
  52. data/lib/scryer/style_rules/frozen_string_literal_rule.rb +1 -0
  53. data/lib/scryer/version.rb +1 -1
  54. data/lib/scryer.rb +15 -0
  55. data/lib/tasks/scryer.rake +117 -4
  56. metadata +24 -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: b897d15fb27a51e0bce5737d3a03c6130ee398b6f17920ee7f065781333894d6
4
+ data.tar.gz: 91c26cdd7faa290f83595921aa9c017e9ed1a66cbdb000a8341307eb707312d5
5
5
  SHA512:
6
- metadata.gz: 9cb23f7a30cdf2660cd208589134b265d213547d73d014ccfafba7729665b78ed5dad3125fd08c30d637c067d6051584e8ffa4a578bd9a40b9b932a96071c78d
7
- data.tar.gz: 6ae2daecc08965c60a8ce1097a95721993e0fcfb5e772c1f10f88f552f07f8609212248a03588026cb180b1b1e50de7ec8f2188672163aaf7118d2560aaaebfb
6
+ metadata.gz: 0a50d223f834e04699a0f0f4a985a514b4e2fc8aad2638fe1950742fd57ee81a39314dddd2deacbfe750af5be36e923735705c2b09259d79bdb27c8567399ce0
7
+ data.tar.gz: '07786b335ebcbd4abc812c31f2e92255ef26019300a2bb14929976a7171d52e293d575850e5f4be0ea2d20f9541a9ca51041c167ab383f89ae45bf11380d8c1e'
data/CHANGELOG.md CHANGED
@@ -3,6 +3,259 @@
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.1.1] - 2026-08-14
9
+
10
+ - Docs-only release: 1.1.0's bundled README.md/CHANGELOG.md/benchmark/README.md referenced the
11
+ real internal Rails app this gem was verified against by an identifying name and used its actual
12
+ controller/file paths in example output. Replaced with an anonymized placeholder ("acme-app") and
13
+ generic file/controller names — the finding counts, severities, rule IDs, and line numbers in
14
+ every example are unchanged (still real, still exactly as scanned), only the identifying labels
15
+ are redacted. 1.1.0 is yanked from RubyGems for this reason; no code changed between 1.1.0 and
16
+ 1.1.1.
17
+
18
+ ## [1.1.0] - 2026-08-14
19
+
20
+ - `Scryer::AuthorizationWatcher` (`require "scryer/authorization_watcher"`, opt-in): a runtime
21
+ companion to the static `idor`/`missing_authorization`/`missing_policy_scope` rules. Registers
22
+ one `after_action` and checks Pundit's `pundit_policy_authorized?`/`pundit_policy_scoped?` and
23
+ CanCanCan's `@_authorized` (both real, verified-by-reading-the-actual-gem-source signals, not
24
+ guessed) to catch a write action (`create`/`update`/`destroy`, or any `POST`/`PUT`/`PATCH`/
25
+ `DELETE`) that completed successfully with neither library's authorization check actually
26
+ invoked during that request — a live, much lower-false-positive-risk signal than the static
27
+ rules, which can only ever see "no call is visible in this file's source," not whether it ran.
28
+ Silently a no-op on apps using neither library (checked per-request, not just at boot). Verified
29
+ end-to-end against real `ActionController::Base`/`ActionController::API` + real installed
30
+ `pundit`/`cancancan` gems (not mocked) — 7 scenarios (authorized, unauthorized,
31
+ GET-to-a-write-action-name, plain read action, both libraries, neither library present, an
32
+ API-only base class) all behave as designed.
33
+ - Accuracy benchmark (`benchmark/`): a hand-labeled corpus of `vulnerable`/`safe` Ruby/Rails
34
+ snippets per rule, run through the real `Rule#scan` path, reporting measured precision/recall/F1
35
+ per rule and in aggregate (`rake benchmark` / `ruby benchmark/run.rb`) — replacing "expect some
36
+ false positives, that's normal for this class of tool" with an actual number. Explicitly framed
37
+ as a synthetic, self-authored corpus, not an independent benchmark — see `benchmark/README.md`'s
38
+ honesty caveat. Directly caught a real bug while building it: `mass_assignment` never fired on
39
+ `@record.update(params[...])` (an instance-variable receiver) — only a bare constant or implicit
40
+ receiver was ever recognized as "likely a model" — even though that's at least as common a
41
+ real-world shape as `Model.new(...)`. Fixed (`IVAR_RECEIVER_METHODS`, scoped to `update`/
42
+ `update!`/`assign_attributes`/`attributes=` only, not `new`/`create`/`create!`, to avoid matching
43
+ `Hash#update`-style receivers) and verified against the full test suite and a real external app
44
+ with zero regressions. Now populated for all 36 registered rules, 180 hand-labeled samples total:
45
+ **82 TP / 3 FN / 1 FP / 94 TN — 98.8% precision, 96.5% recall, 97.6% F1** (`rake benchmark`). The
46
+ two rules scoring below 100% (`graphql_missing_query_limits`, `unbounded_table_scan`) are gaps
47
+ already disclosed in each rule's own top comment before this benchmark existed — this just
48
+ measured them for the first time instead of only asserting them.
49
+ - Test suite: Scryer's own rules now have automated coverage — `rake test` (Minitest, no new
50
+ runtime dependency) runs a bad/clean fixture pair against every one of the 36 registered rules,
51
+ plus a completeness check that fails if a new rule ships with no fixture. Previously this gem had
52
+ zero automated tests of its own; every rule change was verified by hand against ad hoc fixtures
53
+ and a real external Rails app each time. 37/37 passing, 0 rule bugs found while building the
54
+ fixtures — every rule's fixture behaved exactly as its own doc comment described.
55
+ - Testing: two new opt-in files let a host app assert its own scan stays clean as part of its
56
+ normal test suite instead of only via a separate `scryer` run — `require "scryer/rspec"`
57
+ (`have_no_critical_findings`, `have_no_findings_for(rule_id)` matchers) and
58
+ `require "scryer/minitest"` (`Scryer::MinitestAssertions#assert_no_critical_scryer_findings`/
59
+ `#assert_no_scryer_findings_for`). Neither loads automatically with the gem (RSpec/Minitest stay
60
+ out of the runtime dependency list). New `Scryer.scan(root:)` convenience method backs both,
61
+ reusing `c.dirs`/`c.skip_rules` from the host app's own configuration.
62
+ - `scryer verify --rule RULE_ID --file PATH`: a new CLI subcommand that re-parses one file and
63
+ re-runs one rule against it, independent of a full scan — confirms a specific fix actually
64
+ cleared the finding it targeted (exit 0 clean / 1 still firing), meant to run right after
65
+ applying a fix by hand or reviewing an AI-suggested one. `--list-rules` prints every known
66
+ rule_id.
67
+ - AI-verified remediation: when an `ai_client` is configured, every AI-rewritten `suggested_fix` is
68
+ now automatically re-checked — the rewritten line is substituted into an in-memory copy of the
69
+ real file (nothing written to disk) and the same rule is re-run against it. Each finding gets a
70
+ new `fix_verified` field (`true`/`false`/`nil`; JSON/HTML only — CSV/SARIF unchanged), and the
71
+ HTML report shows a green "AI fix verified" or red "AI fix NOT verified" line under the suggested
72
+ fix. Required extending the AI prompt to ask for a machine-parseable `AFTER:` code block as the
73
+ last thing in the model's reply (`Scryer::FixVerifier` parses it); shares its core re-scan logic
74
+ with `scryer verify` above.
75
+ - 3 new Rails config-audit rules, all file-scoped to avoid flagging Rails' own development/test
76
+ defaults: `consider_all_requests_local_production` (`config.consider_all_requests_local = true`
77
+ in `config/environments/production.rb` — shows full debug error pages in production; critical,
78
+ CWE-209), `verbose_production_log_level` (`config.log_level = :debug` in production — logs full
79
+ request params and SQL bind values; warning, CWE-532), `host_authorization_disabled`
80
+ (`config.hosts.clear` — disables Rails' Host-header allowlist entirely; any file, warning,
81
+ CWE-350). 31 security rules total now (up from 28), 36 registered rules overall.
82
+ - Generators: the generated `config/initializers/scryer.rb` now shows commented examples for
83
+ `c.skip_rules` and `c.ai_client`, not just `project_name`/`dirs`/`branch` — both were already
84
+ documented in USAGE but missing from the actual template. New `bin/rails scryer:ci` rake task —
85
+ a memorable shortcut for `scryer:report[json,sarif]`'s CI-sensible defaults.
86
+ - HTML report: every count in the Summary table and the "Warnings by type" table now links to the
87
+ matching findings, same click-to-drill-down as the OWASP coverage table and severity chart —
88
+ the Warnings-by-type rule-id column already linked to its rule group, but the count column next
89
+ to it didn't; Summary's category × severity cells (e.g. "Security × Critical") had no existing
90
+ anchor to link to at all, so each finding now carries a category tag (`security`/`performance`/
91
+ `style`, alongside the existing CWE/OWASP/confidence tags) and the search filter was extended to
92
+ match multiple space-separated terms (all required, not a literal multi-word phrase) so a cell
93
+ like that can link with a two-word filter (`security critical`) precisely. Renamed the
94
+ `.owasp-link` CSS class to `.filter-link` throughout, since it's no longer OWASP-specific.
95
+ - HTML report: the severity distribution chart's counts (Critical/Warning/Info, at the very top
96
+ next to the score badge) are now links to the matching section under Findings — same
97
+ click-to-drill-down as the OWASP coverage table's counts. The Findings filter box now also
98
+ applies to Top priorities (a non-matching item hides, same as a non-matching finding elsewhere
99
+ — previously Top priorities never changed at all while filtering), and any finding matched by
100
+ an active filter (typed or via a click-through link) gets a visible highlight, not just "still
101
+ shown" — the point of filtering/linking is to show *which* finding is the target, not just to
102
+ hide the rest.
103
+ - HTML report: each finding now shows its CWE ID, OWASP category, and confidence level as small
104
+ tags (previously this data existed in JSON/CSV/SARIF but was never actually displayed per-finding
105
+ in the HTML report, even though the OWASP coverage scorecard summarized it). The Findings search
106
+ box matches on these tags for free, and the OWASP coverage table's counts are now links that
107
+ jump to Findings with that exact category pre-filled into the search — the aggregate count and
108
+ the underlying findings are one click apart instead of two disconnected views.
109
+ - New **Security Score** — `ReportRenderer#security_score` combines every security + dependency
110
+ finding's severity and confidence into a single 0-100 number and a letter grade (A-F), via
111
+ exponential decay from 100 (not linear subtraction, so a handful of findings doesn't
112
+ hard-clamp every real app to 0). Shown in the console summary (`Security Score: 11/100 (F)`)
113
+ and as a badge + severity distribution chart at the very top of the HTML report. Deliberately
114
+ NOT normalized by app size — see the README's new "Security score" section for what that means
115
+ and doesn't mean. Performance/code-quality findings aren't part of it (this is a *security*
116
+ score).
117
+ - New **rules-clean-rate** — `ReportRenderer#rules_clean_rate` reports "N/M rules clean," a
118
+ rule-level pass rate (how many of the registered checks fired zero findings) distinct from the
119
+ finding-weighted security score — printed alongside it (`Checks: 20/33 rules clean (60.6%)`)
120
+ and in the HTML score panel.
121
+ - HTML report: leads with the score badge + severity chart, then Overview/Summary/OWASP
122
+ coverage, collapsed-by-default "Checks performed" reference tables, and the Findings section —
123
+ Top priorities first, then a new text search/filter box (rule/file/message, plain JS, no page
124
+ reload) above the severity-grouped, accordion-collapsed finding list.
125
+ - Removed the report footer's "heuristic static analysis, not full data-flow/taint analysis —
126
+ review every finding in its surrounding context" line — the caveat itself hasn't changed
127
+ (still true, still documented at length in the README and in individual rule comments), but
128
+ repeating it on every single generated report read as undermining confidence in the tool
129
+ rather than informing. The `Generated by Scryer vX (Ruby Y)` attribution stays.
130
+ - New **baseline mode** — `scryer --save-baseline PATH` snapshots every current finding
131
+ (security/performance/style/dependency) as a set of fingerprints; `scryer --baseline PATH`
132
+ on a later scan reports only findings new since that snapshot (plus how many were fixed),
133
+ so an app with real pre-existing security debt can gate CI on new issues without being forced
134
+ to fix everything on day one. Fingerprints are `rule_id`/`kind` + file/gem + offending source
135
+ text — deliberately NOT tied to line number, so an unrelated edit earlier in the same file
136
+ doesn't make an existing finding look simultaneously new and fixed. Rails/rake equivalent:
137
+ `rails 'scryer:save_baseline[PATH]'` to save, `SCRYER_BASELINE=PATH rails scryer:report` to
138
+ compare (an env var rather than another bracket-arg token, since rake's arg parsing was
139
+ already stretched thin). See `Scryer::Baseline` (`lib/scryer/baseline.rb`) and the README's
140
+ new "Baseline mode" section.
141
+ - Fixed a real, shared precision bug affecting `idor`, `authentication_bypass`, and
142
+ `csrf_protection_disabled`: all three checked whether a class was a controller via a class-name
143
+ extraction that only handled a plain `class Foo` — a namespaced class (`class
144
+ Admin::PostsController`, `class Api::V1::UsersController`) parses its name as a different Ripper
145
+ shape (`:const_path_ref`, not `:const_ref`), so the class name came back `nil` and the whole
146
+ class was silently never examined by any of the three rules. Fixed with a new shared
147
+ `Ast.class_name` helper (verified against `Ripper.sexp` for 2- and 3-level namespacing) and
148
+ re-verified against a real production Rails app ("acme-app" elsewhere in these docs), which uses
149
+ namespacing extensively:
150
+ `idor` findings 2 → 4, `csrf_protection_disabled` 0 → 3, all genuinely namespaced controllers
151
+ that were previously invisible to these checks.
152
+ - New `idor`-adjacent rules, broadening authorization coverage beyond IDOR's specific
153
+ find-with-no-guard pattern:
154
+ - `missing_authorization` — a controller's `create`/`update`/`destroy` action with no
155
+ authorization evidence anywhere in the class (catches write actions that don't call `.find`
156
+ at all, which `idor` can't see).
157
+ - `missing_policy_scope` — a Pundit-using controller's `index` action querying a model
158
+ directly instead of through `policy_scope` — the common "remembered `authorize`, forgot
159
+ `policy_scope`" gotcha, since `index` has no single record for `authorize` to check.
160
+ - Every security rule (all 28) now carries a **CWE ID**, an **OWASP Top 10 (2021) category**, and
161
+ a **confidence level** (`high`/`medium`/`low`, distinct from `severity` — see `Rule.confidence`
162
+ in `lib/scryer/rule.rb`). All three appear on every `Finding` and flow through every report
163
+ format (new JSON fields, new CSV columns, and in SARIF: `external/cwe/cwe-NN` + the OWASP
164
+ category as rule-level `tags`, plus `confidence`/`cwe`/`owasp_category` as per-result
165
+ `properties` and a combined severity+confidence `rank`). Performance/style rules get a
166
+ `confidence` too (no CWE/OWASP — that taxonomy is security-specific). This is Scryer's own
167
+ best-effort categorization for practitioner convenience, not an OWASP-endorsed or independently
168
+ audited mapping — see the README's "What Scryer detects" section for the full caveat. Brakeman
169
+ already tags CWE and reports a confidence level for its own warnings; this isn't a novel
170
+ capability, just a fuller version of something that idea already existed elsewhere (see the
171
+ comparison table's new footnote).
172
+ - New `ReportRenderer#owasp_coverage` — counts security findings per OWASP Top 10 category, shown
173
+ as a console summary block ("OWASP Top 10 (2021) coverage:") and a new HTML report section, a
174
+ direct byproduct of every security rule now carrying an `owasp_category`.
175
+ - New [`action.yml`](action.yml) — a reusable composite GitHub Action wrapping install + scan +
176
+ SARIF upload, correctly ordered so the upload still happens on runs where findings were found
177
+ (previously only documented as a manual YAML snippet in the README's CI/CD section, which still
178
+ works and is kept as the "full control" alternative).
179
+ - New `ReportRenderer#top_risks` — merges every severity-bearing finding from a scan (security,
180
+ performance, code quality, and dependencies) into one list, sorted by severity first and
181
+ category second, so a scan produces one ranked "what to fix first" answer instead of four
182
+ separate category counts. Shown as a "Top priorities" list in the CLI/rake console summary
183
+ (right after the finding-count box) and, in the HTML report, at the top of the Findings section
184
+ — inside it rather than as its own top-level section, so it reads as "here's what to look at
185
+ first within these findings" rather than a fifth thing to scroll past before the findings even
186
+ start. Pure aggregation of data every format already collects — no new detection logic, and
187
+ JSON/CSV/SARIF are unaffected (each is consumed by tooling that does its own sorting).
188
+ - HTML report: the "Checks performed" section's three rule-reference tables (Security/
189
+ Performance/Style — every rule that *can* fire, not what actually did) are now collapsed-by-
190
+ default accordions instead of always-expanded tables, matching the same pattern individual rule
191
+ groups under Findings already used. With 28 security rules alone, showing all three tables
192
+ expanded on load buried Findings and Top priorities under a wall of reference rows nobody asked
193
+ to see first.
194
+ - HTML report: "Duplicate code groups" moved to after "Dependency audit"/"Files that couldn't be
195
+ parsed", as the last section before the footer.
196
+ - Fixed: the "Dependency audit" section's "Expand all"/"Collapse all" controls did nothing —
197
+ dependency findings rendered as plain, always-expanded `.finding` divs, never actually wrapped
198
+ in the `.accordion` markup those buttons operate on. Each of the 24 dependency findings in a
199
+ typical scan is now its own collapsed-by-default accordion, same pattern as Findings/Checks
200
+ performed.
201
+ - Repositioned the README, gemspec, docs site, and llms.txt around this: Scryer's differentiator
202
+ isn't "one command instead of several tools," it's ranking risk *across* security, performance,
203
+ dependencies, and code quality — something none of RuboCop/Brakeman/bundler-audit do even
204
+ within their own domain, let alone across all four. The honest heuristic-vs-taint-analysis
205
+ framing versus Brakeman (and the `idor` false-positive caveat) carries over unchanged from the
206
+ 1.0.0 comparison table — this is a reframing of what Scryer adds, not a new claim about
207
+ detection accuracy.
208
+
209
+ - False-positive hardening pass across every rule added in 1.0.0 (10 security rules) plus every
210
+ pre-1.0.0 rule (the original 9 security checks, 4 performance rules, `frozen_string_literal`):
211
+ each rule was tested against realistic *safe* Rails idioms (not just its original single "clean"
212
+ fixture) run through the actual scanner, not reasoned about in the abstract. Real false
213
+ positives found and fixed:
214
+ - `job_raw_params` — `params.dig(:x)` and `params.permit(...)` (Rails' own narrow/allowlisting
215
+ extraction idioms) were treated the same as forwarding the raw `params` hash; now recognized
216
+ as narrow, alongside the existing `params[:x]` exemption.
217
+ - `weak_crypto` — a same-line substring match on "password" false-positived on
218
+ `passwordless_token`-style identifiers and on a defensive comment explicitly disclaiming
219
+ password use (`# cache key, not a password hash`); both now excluded.
220
+ - `cors_misconfiguration` — checked wildcard-origin and `credentials: true` file-wide instead of
221
+ per `allow do...end` block, flagging a common legitimate pattern (a public wildcard-origin API
222
+ and a separate, properly-credentialed partner API in the same initializer). Now scoped per
223
+ block.
224
+ - `idor` — `after_action :verify_authorized`/`:verify_policy_scoped` (Pundit's own safety-net
225
+ callbacks) weren't recognized as guards since they're passed as callback-name arguments, not
226
+ called directly; also added CanCanCan's `load_and_authorize_resource`/`authorize_resource`.
227
+ - `ssrf` — a URL with a fixed, literal host and only a path segment derived from `params`
228
+ (`"https://api.example.com/users/#{params[:id]}"`) was flagged with the same severity/message
229
+ as a fully attacker-controlled URL; now downgraded to a warning with an accurate message
230
+ (still flagged — a dynamic path on a fixed host isn't zero-risk — just not overstated).
231
+ - `path_traversal` — `send_file`/`send_data`'s keyword options (`filename:`, `type:`,
232
+ `disposition:`, which only affect response headers, never the filesystem) were checked for
233
+ `params` the same as the actual path argument.
234
+ - `inefficient_save_loop` — added a `db/seeds.rb`/`db/seeds/**` exemption (a one-time setup
235
+ script, not a request-handling hot path) but the first version compared against the file path
236
+ without accounting for the `./` prefix `Scanner` actually produces, so the exemption silently
237
+ never matched; fixed and re-verified against a real `Scryer::Scanner` run, not a hand-built
238
+ `Rule` instance.
239
+ - `authentication_bypass` — an `only:`-scoped skip (a common, often-legitimate pattern for
240
+ public actions on an authenticated controller) got the same "this is reachable without login"
241
+ wording as an unscoped skip; message now distinguishes the two (detection unchanged — a scoped
242
+ skip is still worth a human glance, just not implied to be a bug on its own).
243
+ - Two disclosed-but-not-fixed gaps, documented in-code rather than silently left: `idor`'s
244
+ already-known imprecision is joined by `graphql_missing_query_limits` (misses limits set via
245
+ an `include`d module rather than the class body directly — real cross-file resolution, not a
246
+ same-file AST tweak) and `active_storage_inline_disposition` (doesn't cross-reference the
247
+ separate content-type-validation rule, since the realistic case spans two different files).
248
+ - Everything else audited (`sql_injection`, `mass_assignment`, `command_injection`,
249
+ `hardcoded_secret`, `unsafe_deserialization`, `xss_unsafe_html`, `csrf_protection`,
250
+ `open_redirect`, `force_ssl_disabled`, `weak_session_cookie`, `insecure_cookie_serializer`,
251
+ `hardcoded_basic_auth`, `security_headers_disabled`, `jwt_insecure_usage`,
252
+ `action_cable_forgery_protection_disabled`, `hardcoded_secret_key_base`,
253
+ `n_plus_one_query`, `missing_pagination`, `unbounded_table_scan`, `frozen_string_literal`)
254
+ was verified clean against the same battery of realistic safe-idiom fixtures — no changes
255
+ needed.
256
+ - Every fix was re-verified against its rule's original true-positive case to confirm nothing
257
+ was silenced along with the false positive.
258
+
6
259
  ## [1.0.0] - 2026-08-13
7
260
 
8
261
  Seventeen new security rules, a SARIF report format, a Ruby end-of-life check, and a