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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +414 -0
- data/README.md +114 -649
- data/docs/architecture.md +268 -0
- data/docs/contributing.md +42 -0
- data/docs/fix-mode.md +364 -0
- data/docs/rails-integration.md +162 -0
- data/docs/rules.md +310 -0
- data/docs/usage.md +301 -0
- data/lib/generators/scryer/USAGE +10 -2
- data/lib/generators/scryer/templates/scryer_initializer.rb +15 -0
- data/lib/scryer/ai_fix_suggester.rb +37 -11
- data/lib/scryer/ast.rb +26 -0
- data/lib/scryer/authorization_watcher.rb +156 -0
- data/lib/scryer/baseline.rb +75 -0
- data/lib/scryer/cli.rb +688 -14
- data/lib/scryer/colorizer.rb +56 -0
- data/lib/scryer/dependency_fixer.rb +96 -0
- data/lib/scryer/finding.rb +6 -0
- data/lib/scryer/fix_runner.rb +161 -0
- data/lib/scryer/fix_verifier.rb +169 -0
- data/lib/scryer/mechanical_fixer.rb +288 -0
- data/lib/scryer/minitest.rb +48 -0
- data/lib/scryer/performance_rules/inefficient_save_loop_rule.rb +32 -0
- data/lib/scryer/performance_rules/missing_pagination_rule.rb +1 -0
- data/lib/scryer/performance_rules/n_plus_one_query_rule.rb +1 -0
- data/lib/scryer/performance_rules/unbounded_table_scan_rule.rb +1 -0
- data/lib/scryer/report_renderer.rb +539 -46
- data/lib/scryer/rspec.rb +55 -0
- data/lib/scryer/rule.rb +22 -2
- data/lib/scryer/rules/action_cable_forgery_protection_rule.rb +3 -0
- data/lib/scryer/rules/active_storage_inline_disposition_rule.rb +3 -0
- data/lib/scryer/rules/active_storage_missing_content_type_validation_rule.rb +3 -0
- data/lib/scryer/rules/authentication_bypass_rule.rb +30 -7
- data/lib/scryer/rules/command_injection_rule.rb +3 -0
- data/lib/scryer/rules/consider_all_requests_local_rule.rb +51 -0
- data/lib/scryer/rules/cors_misconfiguration_rule.rb +51 -20
- data/lib/scryer/rules/csrf_protection_rule.rb +60 -11
- data/lib/scryer/rules/force_ssl_rule.rb +3 -0
- data/lib/scryer/rules/graphql_missing_query_limits_rule.rb +31 -0
- data/lib/scryer/rules/hardcoded_basic_auth_rule.rb +3 -0
- data/lib/scryer/rules/hardcoded_secret_key_base_rule.rb +3 -0
- data/lib/scryer/rules/hardcoded_secret_rule.rb +3 -0
- data/lib/scryer/rules/host_authorization_disabled_rule.rb +50 -0
- data/lib/scryer/rules/idor_rule.rb +63 -9
- data/lib/scryer/rules/insecure_cookie_serializer_rule.rb +3 -0
- data/lib/scryer/rules/job_raw_params_rule.rb +40 -7
- data/lib/scryer/rules/jwt_insecure_rule.rb +3 -0
- data/lib/scryer/rules/mass_assignment_rule.rb +32 -5
- data/lib/scryer/rules/missing_authorization_rule.rb +103 -0
- data/lib/scryer/rules/missing_policy_scope_rule.rb +134 -0
- data/lib/scryer/rules/open_redirect_rule.rb +3 -0
- data/lib/scryer/rules/path_traversal_rule.rb +22 -1
- data/lib/scryer/rules/security_headers_rule.rb +3 -0
- data/lib/scryer/rules/sql_injection_rule.rb +3 -0
- data/lib/scryer/rules/ssrf_rule.rb +67 -13
- data/lib/scryer/rules/unsafe_deserialization_rule.rb +3 -0
- data/lib/scryer/rules/verbose_production_log_level_rule.rb +53 -0
- data/lib/scryer/rules/weak_crypto_rule.rb +37 -2
- data/lib/scryer/rules/weak_session_cookie_rule.rb +3 -0
- data/lib/scryer/rules/xss_unsafe_html_rule.rb +41 -0
- data/lib/scryer/scanner.rb +25 -12
- data/lib/scryer/style_rules/frozen_string_literal_rule.rb +1 -0
- data/lib/scryer/version.rb +1 -1
- data/lib/scryer.rb +30 -1
- data/lib/tasks/scryer.rake +447 -20
- metadata +52 -12
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 688af786218666d530e02b72ec75afbd2e64fb811cda54e7bb732531bb69af1d
|
|
4
|
+
data.tar.gz: ffb6e7eba883bfaa1165462db1278a59e2cbe97e0d16adbf19b44fe25ac27293
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|