scryer 1.2.0 → 1.2.2

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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +107 -9
  3. data/README.md +6 -3
  4. data/docs/architecture.md +45 -31
  5. data/docs/rails-integration.md +1 -1
  6. data/docs/rules.md +8 -1
  7. data/docs/usage.md +5 -2
  8. data/lib/generators/scryer/install_generator.rb +1 -0
  9. data/lib/generators/scryer/templates/scryer_initializer.rb +1 -0
  10. data/lib/scryer/ai_client.rb +1 -0
  11. data/lib/scryer/ai_fix_suggester.rb +1 -0
  12. data/lib/scryer/ast.rb +53 -0
  13. data/lib/scryer/authorization_watcher.rb +1 -0
  14. data/lib/scryer/baseline.rb +1 -0
  15. data/lib/scryer/cache_extractor.rb +1 -0
  16. data/lib/scryer/cli.rb +19 -11
  17. data/lib/scryer/colorizer.rb +1 -0
  18. data/lib/scryer/dependency_audit.rb +17 -1
  19. data/lib/scryer/dependency_fixer.rb +1 -0
  20. data/lib/scryer/duplicate_detector.rb +1 -0
  21. data/lib/scryer/finding.rb +1 -0
  22. data/lib/scryer/fix_runner.rb +1 -0
  23. data/lib/scryer/fix_verifier.rb +1 -0
  24. data/lib/scryer/mechanical_fixer.rb +1 -0
  25. data/lib/scryer/method_extractor.rb +1 -0
  26. data/lib/scryer/minitest.rb +1 -0
  27. data/lib/scryer/performance_rules/inefficient_save_loop_rule.rb +1 -0
  28. data/lib/scryer/performance_rules/missing_pagination_rule.rb +1 -0
  29. data/lib/scryer/performance_rules/n_plus_one_query_rule.rb +2 -0
  30. data/lib/scryer/performance_rules/unbounded_table_scan_rule.rb +9 -1
  31. data/lib/scryer/query_extractor.rb +1 -0
  32. data/lib/scryer/query_watcher.rb +1 -0
  33. data/lib/scryer/railtie.rb +1 -0
  34. data/lib/scryer/report_renderer.rb +185 -84
  35. data/lib/scryer/rspec.rb +1 -0
  36. data/lib/scryer/rule.rb +15 -2
  37. data/lib/scryer/rule_set.rb +1 -0
  38. data/lib/scryer/rules/action_cable_forgery_protection_rule.rb +1 -0
  39. data/lib/scryer/rules/active_storage_inline_disposition_rule.rb +1 -0
  40. data/lib/scryer/rules/active_storage_missing_content_type_validation_rule.rb +1 -0
  41. data/lib/scryer/rules/authentication_bypass_rule.rb +1 -0
  42. data/lib/scryer/rules/command_injection_rule.rb +1 -0
  43. data/lib/scryer/rules/consider_all_requests_local_rule.rb +1 -0
  44. data/lib/scryer/rules/cors_misconfiguration_rule.rb +1 -0
  45. data/lib/scryer/rules/csrf_protection_rule.rb +1 -0
  46. data/lib/scryer/rules/dangerous_eval_rule.rb +117 -0
  47. data/lib/scryer/rules/force_ssl_rule.rb +1 -0
  48. data/lib/scryer/rules/graphql_missing_query_limits_rule.rb +1 -0
  49. data/lib/scryer/rules/hardcoded_basic_auth_rule.rb +1 -0
  50. data/lib/scryer/rules/hardcoded_secret_key_base_rule.rb +1 -0
  51. data/lib/scryer/rules/hardcoded_secret_rule.rb +1 -0
  52. data/lib/scryer/rules/host_authorization_disabled_rule.rb +1 -0
  53. data/lib/scryer/rules/idor_rule.rb +6 -14
  54. data/lib/scryer/rules/insecure_cookie_serializer_rule.rb +1 -0
  55. data/lib/scryer/rules/job_raw_params_rule.rb +1 -0
  56. data/lib/scryer/rules/jwt_insecure_rule.rb +1 -0
  57. data/lib/scryer/rules/mass_assignment_rule.rb +10 -16
  58. data/lib/scryer/rules/missing_authorization_rule.rb +1 -0
  59. data/lib/scryer/rules/missing_policy_scope_rule.rb +2 -9
  60. data/lib/scryer/rules/open_redirect_rule.rb +1 -0
  61. data/lib/scryer/rules/path_traversal_rule.rb +1 -0
  62. data/lib/scryer/rules/security_headers_rule.rb +1 -0
  63. data/lib/scryer/rules/sql_injection_rule.rb +1 -0
  64. data/lib/scryer/rules/ssrf_rule.rb +1 -0
  65. data/lib/scryer/rules/unsafe_deserialization_rule.rb +1 -0
  66. data/lib/scryer/rules/verbose_production_log_level_rule.rb +1 -0
  67. data/lib/scryer/rules/weak_crypto_rule.rb +1 -0
  68. data/lib/scryer/rules/weak_session_cookie_rule.rb +1 -0
  69. data/lib/scryer/rules/xss_unsafe_html_rule.rb +1 -0
  70. data/lib/scryer/scanner.rb +126 -7
  71. data/lib/scryer/style_rules/frozen_string_literal_rule.rb +1 -0
  72. data/lib/scryer/version.rb +2 -1
  73. data/lib/scryer.rb +1 -0
  74. data/lib/tasks/scryer.rake +8 -2
  75. metadata +4 -7
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 688af786218666d530e02b72ec75afbd2e64fb811cda54e7bb732531bb69af1d
4
- data.tar.gz: ffb6e7eba883bfaa1165462db1278a59e2cbe97e0d16adbf19b44fe25ac27293
3
+ metadata.gz: d2d73db1916cc56075446233dfaa8e88d449bd77218e85fa41fe96e11d56df14
4
+ data.tar.gz: b4a5c41ff1f7132c4ba4807177ee31629614376b2cb0228e24231f44ab3c25e0
5
5
  SHA512:
6
- metadata.gz: 02c51d96853d124cdf7c33b5072f29e35fede7d1df080b41131429daa1e251d4f807043ad1eb51de081d49486b08d7932a34925671895332dec22c88dcba37e7
7
- data.tar.gz: ce8d5f7770abff15b0d8a37979982a3ba1a11a07f44f217d83fb0277e038029b44cce8fb7623ef96c2cbd5f29a30efec3084015c7a99ae751d85e16b64eb376a
6
+ metadata.gz: 6f9cac00b59b7f44d5939ec66bd4a63280acf60c2b011f7a0f5977c323a28595a85bf9f1a6a622dc9e6c55704bd664c6df85428b189b5e74edc4990785c1af76
7
+ data.tar.gz: f56d186dfcae4d6344a7d03a8e43dcc2508f68e4276d7df3c9bffaa46fe8cf1d2591edd078ccc8627258d45a1d6649d5ae391ee225e7a88886afff0f3b912f10
data/CHANGELOG.md CHANGED
@@ -5,6 +5,104 @@ All notable changes to this project are documented here. Format loosely follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ - **Breaking (report shape): one blended `Security Score` replaced with four independent
9
+ scores** — `security_score`, `performance_score`, `style_score`, and `dependency_score`
10
+ (`ReportRenderer#category_score` runs the same severity+confidence-weighted formula for each,
11
+ scoped to only that category's own findings; see `SCORE_SEVERITY_WEIGHT`/
12
+ `SCORE_CONFIDENCE_WEIGHT`). Previously `security_score` folded performance findings in at a
13
+ diluted weight and dropped style findings entirely, which meant a codebase could be
14
+ security-clean but drowning in N+1 queries and the console/HTML report would barely show it. Now
15
+ each category gets its own 0-100 score and letter grade, and none of them dilute or leak into
16
+ another: `scryer`'s console summary prints all four (`Security Score`/`Performance Score`/
17
+ `Style Score`/`Dependency Score`, each `N/100 (grade)`; `Dependency Score` shows
18
+ `skipped (--no-deps)`/`skipped (nodeps)` when the dependency audit didn't run), and the HTML
19
+ report's top panel shows four badges instead of one. `as_hash`/JSON output gains
20
+ `performance_score`/`style_score`/`dependency_score` keys alongside the existing
21
+ `security_score` (whose own meaning changed: it's now security findings only, no longer
22
+ security + dependency + diluted-performance combined). `rules_clean_rate` is unchanged (still one
23
+ aggregate rule-level pass rate across all three rule-backed categories together — a different,
24
+ complementary signal, not itself split per-category). Covered by a rewritten
25
+ `test/security_score_test.rb`, including a dedicated independence test proving a finding in one
26
+ category never affects another's score.
27
+ - Added `dangerous_eval` (new `Scryer::Rules::DangerousEvalRule`, CWE-95, critical severity): flags
28
+ `eval`/`instance_eval`/`class_eval`/`module_eval` called with anything other than a hardcoded
29
+ string literal with no interpolation — a real coverage gap (Scryer had no eval-injection check at
30
+ all), reported against a real `eval(singular_instance)` finding another scanner caught and Scryer
31
+ didn't. Deliberately broader than this gem's other injection rules (sql_injection/mass_assignment/
32
+ ssrf/path_traversal only fire when the argument traces back to `params` specifically): an
33
+ eval-family argument has essentially no legitimate reason to be anything but a literal the
34
+ developer wrote, so this fires on *any* non-literal argument — a local variable, an ivar, a method
35
+ call, an interpolated string — regardless of where the value actually came from. Confidence varies
36
+ the same way a taint-analysis scanner's own "Dangerous Eval" check varies confidence with its
37
+ taint engine (High vs Weak): `high` when `params` is directly visible in the argument's own expression (e.g.
38
+ `eval(params[:code])`), `medium` otherwise — the closest signal available without real data-flow
39
+ tracing. Severity stays `critical` either way regardless of confidence: the blast radius if the
40
+ argument is ever attacker-influenced doesn't depend on how sure Scryer is today, and `eval` is
41
+ never the *correct* way to do dynamic dispatch even when today's value happens to be safe
42
+ (`const_get`/`safe_constantize` do the same job with zero code-execution risk). A block-only call
43
+ (`obj.instance_eval { ... }`, no string argument) is never flagged. Covered by
44
+ `test/rule_fixtures_test.rb`, a new `test/dangerous_eval_rule_test.rb` (9 tests, including the
45
+ confidence split), and a new `benchmark/corpus.rb` entry (100%/100%/100% precision/recall/F1).
46
+ - Fixed: `mass_assignment`, `idor`, and `missing_policy_scope` false-positived on a plain Ruby
47
+ service/command object's constructor or class-level entry point (e.g. an internal
48
+ `Server.new(params).call` convention, or the equivalent `interactor`-gem-style
49
+ `SomeThingInteractor.call(params)`) — syntactically identical to `Order.new(params)` (a bare
50
+ constant, a `.new`/`.find`/`.where`-adjacent verb, a raw `params` argument), but none of these
51
+ calls write request data onto ActiveRecord attributes by themselves. Two fixes, in order of
52
+ precedence:
53
+ - **Project-wide model resolution** (`Scanner#call`, new): every scan now walks every
54
+ `class X < Y` declaration across every scanned file once, up front, and resolves which class
55
+ names actually descend from `ApplicationRecord`/`ActiveRecord::Base` (transitively — an
56
+ abstract per-shard base class or an STI subclass still resolves correctly), and which classes
57
+ are declared with *no* superclass at all or with a superclass matching `/\AApplication(?!Record\z)/`
58
+ (`ApplicationJob`, `ApplicationMailer`, a project's own `ApplicationService`, ...) — neither
59
+ shape an ActiveRecord model is ever declared with, so that's an unconditional "definitely not a
60
+ model" signal regardless of the class's name. This is the general fix: a service object with
61
+ *any* name is now excluded as long as it's declared somewhere in the scanned project, and a
62
+ real model is never wrongly excluded just because its name happens to look like a service
63
+ object (e.g. a model genuinely named `Command`). See `Ast.likely_model_name?` and
64
+ `Scanner#collect_class_declarations`/`#resolve_known_models`.
65
+ - **Naming-convention fallback** (unchanged from the original fix, only reached when a class
66
+ isn't declared anywhere Scryer scanned — a gem-provided constant, or outside `c.dirs`):
67
+ `Ast::NON_MODEL_RECEIVER_SUFFIXES` excludes constants ending in `Service`, `Server`,
68
+ `Interactor`, `Operation`, `Command`, or `UseCase`.
69
+ All three rules' previously-duplicated `NON_MODEL_RECEIVERS` exclusion lists are now one shared
70
+ `Ast::NON_MODEL_RECEIVER_NAMES`. Covered by new `test/mass_assignment_rule_test.rb` and
71
+ `test/scanner_known_models_test.rb` — no prior dedicated test file existed for `MassAssignmentRule`
72
+ at all, and no prior test exercised known-model resolution across files (inherently impossible to
73
+ test via a single-file `scan_with` call).
74
+
75
+ ## [1.2.1] - 2026-09-04
76
+
77
+ - Fixed: a platform-specific gem's `Gemfile.lock` spec line (e.g. `nokogiri (1.19.4-x86_64-linux-musl)`)
78
+ fed its unstripped `1.19.4-x86_64-linux-musl` version straight into `DependencyAudit.vulnerable_gems`'s
79
+ OSV.dev query, which parsed the platform suffix as a semver prerelease marker — sorting the
80
+ version as *older* than the plain release and matching every advisory fixed at-or-before it as
81
+ still open. Confirmed directly against OSV.dev: querying the unstripped string for `nokogiri`
82
+ returned 8 vulnerabilities; querying the real `1.19.4` returned 0. `DependencyAudit.parse_lockfile`
83
+ now strips the hyphen-separated platform suffix (RubyGems versions never contain a hyphen, so this
84
+ never touches a real prerelease version like `1.0.0.pre1`). Covered by a new `test/dependency_audit_test.rb`.
85
+ - Fixed: the HTML report's Summary table showed a `—` placeholder for every severity column on the
86
+ Dependency audit row (no breakdown at all), and its Total row's sum silently excluded dependency
87
+ findings entirely — so a report with only dependency findings showed `0/0/0/0` in this table's
88
+ Total row while the severity bars in the executive summary just above it (which do count
89
+ dependency findings) showed nonzero counts, two different totals on the same page. Both rows now
90
+ count dependency findings by severity like every other row. Covered by a new
91
+ `test/report_renderer_summary_table_test.rb`.
92
+ - Security score changes: `info`-severity findings weighed too heavily — `SCORE_SEVERITY_WEIGHT["info"]`
93
+ dropped from `1` to `0.25` (critical stays `15`, warning stays `6`); an info finding is closer to
94
+ a cosmetic note than a real risk (e.g. `csrf_protection_disabled`'s narrowly-scoped-skip case), so
95
+ a codebase with only info-severity findings should barely move off 100/A even with several of
96
+ them. Performance findings (N+1 queries, missing pagination, etc.) now count toward the score too,
97
+ at a new `SCORE_CATEGORY_WEIGHT["performance"] = 0.2` — a fifth of security's weight at the same
98
+ severity/confidence, since a slow app is a real cost but not a *security* one; previously
99
+ performance findings were excluded from the score entirely. Duplicate-code groups and style
100
+ findings (`frozen_string_literal`) remain permanently excluded — a `DuplicateDetector::DuplicateGroup`
101
+ isn't backed by a `Finding` with a severity to weigh in the first place, and style findings are
102
+ cosmetic, not risk. No change to critical/warning weighting or confidence weighting. Covered by a
103
+ new `test/security_score_test.rb` (11 tests) — there was no prior test coverage of the score
104
+ formula at all.
105
+
8
106
  ## [1.2.0] - 2026-08-16
9
107
 
10
108
  - Added `c.detect_duplicates` (`Scryer::Configuration`, default `true`): duplicate-code detection
@@ -326,10 +424,10 @@ All notable changes to this project are documented here. Format loosely follows
326
424
  `properties` and a combined severity+confidence `rank`). Performance/style rules get a
327
425
  `confidence` too (no CWE/OWASP — that taxonomy is security-specific). This is Scryer's own
328
426
  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).
427
+ audited mapping — see the README's "What Scryer detects" section for the full caveat.
428
+ Taint-analysis scanners already tag CWE and report a confidence level for their own warnings;
429
+ this isn't a novel capability, just a fuller version of something that idea already existed
430
+ elsewhere (see the comparison table's new footnote).
333
431
  - New `ReportRenderer#owasp_coverage` — counts security findings per OWASP Top 10 category, shown
334
432
  as a console summary block ("OWASP Top 10 (2021) coverage:") and a new HTML report section, a
335
433
  direct byproduct of every security rule now carrying an `owasp_category`.
@@ -361,9 +459,9 @@ All notable changes to this project are documented here. Format loosely follows
361
459
  performed.
362
460
  - Repositioned the README, gemspec, docs site, and llms.txt around this: Scryer's differentiator
363
461
  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
462
+ dependencies, and code quality — something none of RuboCop/a taint-analysis scanner/bundler-audit
463
+ do even within their own domain, let alone across all four. The honest heuristic-vs-taint-analysis
464
+ framing versus taint-analysis scanners (and the `idor` false-positive caveat) carries over unchanged from the
367
465
  1.0.0 comparison table — this is a reframing of what Scryer adds, not a new claim about
368
466
  detection accuracy.
369
467
 
@@ -420,8 +518,8 @@ All notable changes to this project are documented here. Format loosely follows
420
518
  ## [1.0.0] - 2026-08-13
421
519
 
422
520
  Seventeen new security rules, a SARIF report format, a Ruby end-of-life check, and a
423
- credentials-exposure check — see the README's "Scryer vs RuboCop vs Brakeman vs bundler-audit"
424
- section for how the new security coverage is positioned (heuristic pattern-matching, not taint
521
+ credentials-exposure check — see the README's "Scryer vs RuboCop vs a taint-analysis scanner vs
522
+ bundler-audit" section for how the new security coverage is positioned (heuristic pattern-matching, not taint
425
523
  analysis; `idor` in particular carries real false-positive risk by nature of the problem).
426
524
 
427
525
  - New security rules (all `category: "security"`, skippable individually via `--skip RULE_ID` /
data/README.md CHANGED
@@ -58,8 +58,11 @@ HTML report: tmp/scryer_report.html
58
58
  That's real output from a scan of a live 236-file Rails app (an internal production codebase we
59
59
  call "acme-app" here — file/controller names above are anonymized, since we don't publish that
60
60
  app's source; the finding counts, severities, rule IDs, and line numbers are exactly as scanned,
61
- unedited) — not a mockup, and the harsh grade is real too (see
62
- [Security score](docs/architecture.md#security-score) for what it does and doesn't mean). "Top priorities" is the same
61
+ unedited) — not a mockup, and the harsh grade is real too. This capture predates the current
62
+ version, which reports four independent scores (Security/Performance/Style/Dependency, each with
63
+ its own grade) instead of the single `Security Score` line shown above — see
64
+ [Scores](docs/architecture.md#scores) for the current format and what each one does and doesn't
65
+ mean. "Top priorities" is the same
63
66
  severity ranking [`ReportRenderer#top_risks`](lib/scryer/report_renderer.rb) applies across *all*
64
67
  categories — security, dependencies, performance, code quality — not just within each one; in the
65
68
  HTML report it's at the top of the Findings section, ahead of the 309 individual findings
@@ -73,7 +76,7 @@ below for exactly how this differs from what those tools do.
73
76
  security vulnerabilities, performance heuristics, dependency risk, and code-quality issues in one
74
77
  scan, ranked by severity across all four categories. See
75
78
  [What Scryer detects](docs/rules.md#what-scryer-detects).
76
- - **Is it Rails-specific?** Most of the 31 security rules target Rails conventions specifically
79
+ - **Is it Rails-specific?** Most of the 32 security rules target Rails conventions specifically
77
80
  (controllers, `config/environments/*`, Active Storage, Action Cable, `params`), but the scanning
78
81
  engine itself only needs Ruby source — no Rails app or database required to run it. See
79
82
  [Designed for Ruby](docs/rules.md#designed-for-ruby).
data/docs/architecture.md CHANGED
@@ -24,7 +24,7 @@ no Rails/database boot.
24
24
 
25
25
  Parsing is the only real cost, and it's linear in file count/size — there's no Rails boot, no
26
26
  database connection, and (with `--no-deps`) no network call at all. A real, unscoped scan of a
27
- 236-file production Rails app (the same one behind the [Security score](#security-score) example
27
+ 236-file production Rails app (the same one behind the [Scores](#scores) example
28
28
  above) completes in about 2.5 seconds end to end on a laptop, `--no-deps` set. The one deliberately
29
29
  slower path is the [dependency audit](./rules.md#dependency-audit) (on by default): it queries
30
30
  [OSV.dev](https://osv.dev) once per unique gem in `Gemfile.lock`, so total time scales with distinct
@@ -91,50 +91,64 @@ normal for this class of tool, not a bug. An already-guarded call can still get
91
91
  rules don't trace surrounding conditionals — always review a finding in its surrounding context
92
92
  before acting on it.
93
93
 
94
- ## Security score
94
+ ## Scores
95
95
 
96
- Every scan produces a single 0-100 score plus a letter grade (A-F), shown in the console summary
97
- and as a badge at the very top of the HTML report:
96
+ Every scan produces **four independent 0-100 scores**, each with its own letter grade (A-F) one
97
+ per category — shown in the console summary and as four badges at the very top of the HTML report:
98
98
 
99
99
  ```
100
- Security Score: 11/100 (F)
100
+ Security Score: 11/100 (F)
101
+ Performance Score: 62/100 (D)
102
+ Style Score: 100/100 (A)
103
+ Dependency Score: 88/100 (B)
101
104
  ```
102
105
 
103
- The formula (`ReportRenderer#security_score` in `lib/scryer/report_renderer.rb`): every security
104
- and dependency finding costs points, weighted by both its severity *and* its confidence (a
105
- `low`-confidence `idor` finding costs less than a `high`-confidence `sql_injection` finding at the
106
- same severity), combined via exponential decay from 100 rather than linear subtraction one
107
- critical finding visibly moves the score (100 ~86) without a handful of findings driving any
108
- real app straight to a hard-clamped 0.
109
-
110
- Two things worth being precise about before you treat this number as meaningful:
111
-
112
- - **It's not normalized by app size.** A 10-file app and a 1,000-file app with the same finding
113
- *density* will score very differently here this score reflects a scan's absolute finding
114
- exposure, not a rate. That makes it useful for tracking *one project's own trend* over time (did
115
- the next scan score higher or lower), not for comparing two differently-sized codebases against
116
- each other.
117
- - **Performance and code-quality findings aren't part of it.** This is a *security* score — only
118
- security and dependency findings count. A slow app with clean security findings still scores
119
- well here; check the "Performance"/"Code Quality" rows in the summary box separately for that.
120
-
121
- Alongside the score, every scan also reports a **rule-level pass rate** "how many of Scryer's
122
- registered checks fired zero findings":
106
+ The formula is the same for all four (`ReportRenderer#category_score` in
107
+ `lib/scryer/report_renderer.rb`, called once per category by `#security_score`/
108
+ `#performance_score`/`#style_score`/`#dependency_score`): every finding *in that category* costs
109
+ points, weighted by both its severity *and* its confidence (a `low`-confidence `idor` finding costs
110
+ less than a `high`-confidence `sql_injection` finding at the same severity), combined via
111
+ exponential decay from 100 rather than linear subtraction — one critical finding visibly moves that
112
+ category's score (100 → ~86) without a handful of findings driving it straight to a hard-clamped 0.
113
+ Crucially, the four scores never mix: a finding only ever affects the score for its own category, so
114
+ a codebase that's security-clean but drowning in N+1 queries gets an honest `Performance Score: F`
115
+ instead of that risk being invisible or diluted inside a single blended number. (Earlier versions of
116
+ this gem had exactly one combined score that folded performance findings in at a diluted weight and
117
+ dropped style findings entirely that's what these four replaced.)
118
+
119
+ Two things worth being precise about before you treat these numbers as meaningful:
120
+
121
+ - **None of them are normalized by app size.** A 10-file app and a 1,000-file app with the same
122
+ finding *density* will score very differently here each score reflects that category's absolute
123
+ finding exposure in this scan, not a rate. That makes them useful for tracking *one project's own
124
+ trend* over time (did the next scan score higher or lower, in this category), not for comparing
125
+ two differently-sized codebases against each other.
126
+ - **Duplicate-code groups have no score of their own.** A `DuplicateDetector::DuplicateGroup` isn't
127
+ even a `Finding` — no severity/confidence to weigh in the first place (see
128
+ `Scryer::DuplicateDetector`) — so it's excluded from all four; check the "Duplicate code" row in
129
+ the summary box directly for that. `Style Score` today is really just `frozen_string_literal`, the
130
+ one rule-based style check this gem has.
131
+
132
+ Alongside the four scores, every scan also reports one overall **rule-level pass rate** — "how many
133
+ of Scryer's registered checks fired zero findings":
123
134
 
124
135
  ```
125
136
  Checks: 23/36 rules clean (63.9%)
126
137
  ```
127
138
 
128
139
  This is the closest thing Scryer has to Brakeman's "X checks, Y warnings" framing — a coverage
129
- signal, not a risk signal. It's deliberately a *different* number from the security score, and the
130
- two won't always agree: a codebase can have a high clean rate (few distinct rules ever fire) and
131
- still a low score (the few that did fire were severe and high-confidence), or the reverse (many
132
- different rules each firing once, none of them serious). `ReportRenderer#rules_clean_rate` computes
140
+ signal, not a risk signal. It's deliberately a *different* number from the four scores above (and
141
+ spans all three rule-backed categories together, rather than being split per-category itself), and
142
+ they won't always agree: a codebase can have a high clean rate (few distinct rules ever fire) and
143
+ still a low score in one category (the few that did fire there were severe and high-confidence), or
144
+ the reverse (many different rules each firing once, none of them serious). `ReportRenderer#rules_clean_rate` computes
133
145
  it from `Scryer::RuleSet.all` (every registered security/performance/style rule) against which
134
146
  rule_ids actually appeared in this scan's findings — dependency checks aren't counted here since
135
147
  they're not backed by a `Scryer::Rule` subclass. Report both; neither alone tells the whole story.
136
148
 
137
- The 10/F above is a real score from the acme-app example, not a cherry-picked good result — see
149
+ The Security Score above is illustrative of the format (four independent scores, one letter grade
150
+ each) rather than one specific captured scan — see the README's own top-of-page example for a real,
151
+ unmodified `Security Score: 10/100 (F)` from an actual live app, and
138
152
  [A note on how this gem was actually verified](#a-note-on-how-this-gem-was-actually-verified) for
139
153
  why every example in this README is real output.
140
154
 
@@ -240,7 +254,7 @@ sits alongside them and adds the cross-category picture neither one (nor bundler
240
254
  four categories) by severity, shown as "Top priorities" in the console summary and at the top of
241
255
  the HTML report — see the example near the top of this README.
242
256
  - **[k]** Not a Scryer-only capability — Brakeman's own warnings already include a CWE reference.
243
- Scryer's version is a fuller mapping (every one of its 31 security rules carries both a CWE ID
257
+ Scryer's version is a fuller mapping (every one of its 32 security rules carries both a CWE ID
244
258
  and an OWASP Top 10 (2021) category, aggregated into an OWASP coverage scorecard in every
245
259
  report), but the underlying idea isn't new; see
246
260
  [What Scryer detects](./rules.md#what-scryer-detects) for the honesty caveat on how this mapping was built
@@ -152,7 +152,7 @@ end
152
152
  `Scryer.scan(root:)` runs the same static scan `scryer:report`/the `scryer` executable do (using
153
153
  `c.dirs`/`c.skip_rules` from your initializer), without needing a report written to disk —
154
154
  `have_no_critical_findings`/`assert_no_critical_scryer_findings` only look at *security* findings
155
- (same scoping as the [security score](./architecture.md#security-score)); `have_no_findings_for`/
155
+ (same scoping as the [Security Score](./architecture.md#scores)); `have_no_findings_for`/
156
156
  `assert_no_scryer_findings_for` check a specific `rule_id` across all three static categories,
157
157
  for pinning a specific bug so it can't come back unnoticed. This talks to the live filesystem on
158
158
  every test run (a real `Ripper`-based scan, same cost as running `scryer` itself) — for a large app
data/docs/rules.md CHANGED
@@ -9,6 +9,13 @@
9
9
  * SQL injection
10
10
  * Mass assignment
11
11
  * Command injection
12
+ * Dangerous `eval` (`eval`/`instance_eval`/`class_eval`/`module_eval` called with anything other
13
+ than a hardcoded string literal) — broader than most other checks here: it fires on any
14
+ non-literal argument, not just one tracing back to `params`, since there's essentially no
15
+ legitimate reason for an eval-family argument to be anything else. Always `critical` severity;
16
+ confidence is `high` when `params` is directly visible in the argument, `medium` otherwise (the
17
+ closest signal available without real data-flow tracing — same axis Brakeman's own "Dangerous
18
+ Eval" check varies High/Weak confidence on)
12
19
  * Hardcoded secrets (and hardcoded HTTP Basic Auth credentials)
13
20
  * Unsafe deserialization
14
21
  * XSS-prone HTML
@@ -157,7 +164,7 @@ Generate detailed JSON or self-contained HTML reports:
157
164
  scryer -o report.json -o report.html
158
165
  ```
159
166
 
160
- The HTML report leads with a [security score](./architecture.md#security-score) badge and a severity distribution
167
+ The HTML report leads with four [score](./architecture.md#scores) badges and a severity distribution
161
168
  chart, then Overview/Summary/OWASP coverage, collapsed-by-default reference tables (every rule
162
169
  that *can* fire, not a wall of always-expanded detail), and the Findings section itself — Top
163
170
  priorities first, then a text filter box (rule/file/message, no page reload) above the full
data/docs/usage.md CHANGED
@@ -61,7 +61,7 @@ example — followed by where each report was written.
61
61
 
62
62
  **Color.** The summary box, top priorities, and `scryer fix`/`scryer verify` output are colored
63
63
  automatically at a real terminal — severity labels (`critical`/`warning`/`info` → red/yellow/cyan),
64
- the security score's letter grade (A/B green, C yellow, D/F red), fixed/skipped status lines, and
64
+ each score's letter grade (A/B green, C yellow, D/F red), fixed/skipped status lines, and
65
65
  so on. Off automatically whenever it would be wrong to color: piped/redirected output (e.g. `-o -`
66
66
  to a file, or piped to `less` without `-R`), [`NO_COLOR`](https://no-color.org) set, or
67
67
  `TERM=dumb`. `--color`/`--no-color` force it either way regardless of any of that — e.g. `scryer
@@ -282,7 +282,10 @@ scryer --baseline tmp/scryer_baseline.json -o scryer.sarif
282
282
  ```
283
283
  Baseline: tmp/scryer_baseline.json — showing new findings only (2 fixed since baseline).
284
284
 
285
- Security Score: 96/100 (A)
285
+ Security Score: 96/100 (A)
286
+ Performance Score: 90/100 (A)
287
+ Style Score: 100/100 (A)
288
+ Dependency Score: 88/100 (B)
286
289
 
287
290
  Security 1 finding
288
291
  ...
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require "rails/generators"
2
3
 
3
4
  module Scryer
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  # Configuration for the scryer gem (static code analysis: security,
2
3
  # duplicate code, performance heuristics). Run `bin/rails scryer:report`
3
4
  # to scan this app and generate tmp/scryer_report.{json,html} locally.
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require "json"
2
3
  require "net/http"
3
4
  require "uri"
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Scryer
2
3
  # Optional, opt-in enrichment of a finding's `suggested_fix` using an
3
4
  # external LLM. Every rule already ships a generic, human-reviewable
data/lib/scryer/ast.rb CHANGED
@@ -1,4 +1,6 @@
1
+ # frozen_string_literal: true
1
2
  require "ripper"
3
+ require "set"
2
4
 
3
5
  module Scryer
4
6
  # Small set of helpers for walking the S-expression tree that Ripper.sexp
@@ -322,6 +324,57 @@ module Scryer
322
324
  node[1][1]
323
325
  end
324
326
 
327
+ # Shared by MassAssignmentRule/IdorRule/MissingPolicyScopeRule — every
328
+ # one of them needs "is this bare constant receiver likely an
329
+ # ActiveRecord model" and, until this method existed, each rule
330
+ # answered it with its own copy of a purely name-based guess (an exact
331
+ # exclusion list of stdlib/gem constants with their own `.new`/`.find`/
332
+ # `.where`-shaped methods). That guess had a real, reported false
333
+ # positive: a plain Ruby service/command object — `Server.new(params)
334
+ # .call`, the well-known "interactor" pattern — looks syntactically
335
+ # identical to `Order.new(params)`, and no fixed exclusion list can
336
+ # anticipate every project's own naming convention for that pattern.
337
+ #
338
+ # `known_models`/`known_non_models` (both Sets of unqualified, last-
339
+ # segment class names — see Scanner#call, which builds them once per
340
+ # scan by walking every `class X < Y` declaration across every scanned
341
+ # file) give this a real, Brakeman-like signal instead of a guess,
342
+ # whenever the receiver's class is actually declared somewhere in the
343
+ # project: `known_models` wins outright (a model genuinely named
344
+ # e.g. `Command` still gets flagged), and `known_non_models` (a class
345
+ # declared with literally no superclass, or with a superclass matching
346
+ # NON_MODEL_SUPERCLASS_PATTERN below — neither shape an ActiveRecord
347
+ # model ever has) excludes it outright regardless of name. Only when
348
+ # neither set has an answer (the class isn't declared anywhere Scryer
349
+ # scanned — a gem-provided constant, or `c.dirs` not covering it) does
350
+ # this fall back to the same purely-name-based guessing as before:
351
+ # NON_MODEL_RECEIVER_NAMES (stdlib/gem constants) and
352
+ # NON_MODEL_RECEIVER_SUFFIXES (a project's own service/command objects,
353
+ # covering the common "Service"/"Interactor"/etc. conventions when the
354
+ # class itself wasn't visible to this scan). Defaults (assume it likely
355
+ # *is* a model) when none of the above says otherwise — same
356
+ # false-positive-favoring direction as every other heuristic in this
357
+ # gem: a missed real mass-assignment/IDOR finding is worse than an
358
+ # occasional nudge to double-check a service object.
359
+ NON_MODEL_RECEIVER_NAMES = %w[
360
+ Struct OpenStruct Data Class Module BCrypt OpenSSL Net URI Digest
361
+ JSON YAML Marshal String Array Hash Integer Float Symbol Comparable
362
+ Enumerable File Dir
363
+ ].freeze
364
+
365
+ NON_MODEL_RECEIVER_SUFFIXES = %w[Service Server Interactor Operation Command UseCase].freeze
366
+
367
+ EMPTY_SET = Set.new.freeze
368
+
369
+ def likely_model_name?(const_name, known_models: EMPTY_SET, known_non_models: EMPTY_SET)
370
+ return true if known_models.include?(const_name)
371
+ return false if known_non_models.include?(const_name)
372
+ return false if NON_MODEL_RECEIVER_NAMES.include?(const_name)
373
+ return false if NON_MODEL_RECEIVER_SUFFIXES.any? { |suffix| const_name.end_with?(suffix) }
374
+
375
+ true
376
+ end
377
+
325
378
  # True if `node` is `params`, `params[:x]`, or contains such a reference
326
379
  # anywhere in its subtree — the shared "does this touch raw request data"
327
380
  # check behind mass assignment, IDOR, SSRF, and path traversal detection.
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Scryer
2
3
  # Runtime companion to the static `idor`/`missing_authorization`/
3
4
  # `missing_policy_scope` rules — those can only ever say "no call to a
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require "digest"
2
3
  require "json"
3
4
  require "set"
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require "ripper"
2
3
 
3
4
  module Scryer
data/lib/scryer/cli.rb CHANGED
@@ -1,13 +1,14 @@
1
+ # frozen_string_literal: true
1
2
  require "optparse"
2
3
  require "fileutils"
3
- require "shellwords"
4
+ require "open3"
4
5
 
5
6
  module Scryer
6
7
  # Backs the `scryer` executable (see exe/scryer) — a standalone,
7
8
  # Rails-free way to run a scan, mirroring `brakeman -o report.json`.
8
- # Deliberately separate from lib/scryer.rb: OptionParser/Shellwords are
9
- # only needed for this CLI entry point, not when the gem is required
10
- # inside a host app.
9
+ # Deliberately separate from lib/scryer.rb: OptionParser/Open3 are only
10
+ # needed for this CLI entry point, not when the gem is required inside a
11
+ # host app.
11
12
  class CLI
12
13
  EXTENSION_FORMATS = { ".json" => "json", ".html" => "html", ".htm" => "html", ".csv" => "csv", ".sarif" => "sarif" }.freeze
13
14
 
@@ -85,9 +86,9 @@ module Scryer
85
86
  renderer = ReportRenderer.new(
86
87
  result: result,
87
88
  project_name: options[:project_name] || File.basename(root),
88
- release_label: git(root, "describe --tags --always"),
89
- git_commit_sha: git(root, "rev-parse HEAD"),
90
- git_branch: options[:branch] || git(root, "rev-parse --abbrev-ref HEAD"),
89
+ release_label: git(root, "describe", "--tags", "--always"),
90
+ git_commit_sha: git(root, "rev-parse", "HEAD"),
91
+ git_branch: options[:branch] || git(root, "rev-parse", "--abbrev-ref", "HEAD"),
91
92
  dependency_findings: dependency_findings
92
93
  )
93
94
 
@@ -192,7 +193,6 @@ module Scryer
192
193
  ]
193
194
 
194
195
  divider = paint("─" * 32, :gray)
195
- score = renderer.security_score
196
196
  @stdout.puts ""
197
197
  @stdout.puts paint("Scryer Audit — #{result.files_scanned} files scanned", :bold)
198
198
  @stdout.puts divider
@@ -203,7 +203,10 @@ module Scryer
203
203
  @stdout.puts ""
204
204
  end
205
205
  clean_rate = renderer.rules_clean_rate
206
- @stdout.puts "Security Score: #{score["score"]}/100 (#{paint_grade(score["grade"], score["grade"])})"
206
+ @stdout.puts score_row("Security Score", renderer.security_score)
207
+ @stdout.puts score_row("Performance Score", renderer.performance_score)
208
+ @stdout.puts score_row("Style Score", renderer.style_score)
209
+ @stdout.puts(ran_deps ? score_row("Dependency Score", renderer.dependency_score) : "#{"Dependency Score".ljust(20)}skipped (--no-deps)")
207
210
  @stdout.puts "Checks: #{clean_rate["clean"]}/#{clean_rate["total"]} rules clean (#{clean_rate["percent"]}%)"
208
211
  @stdout.puts ""
209
212
  rows.each { |label, count| @stdout.puts summary_row(label, count) }
@@ -220,6 +223,10 @@ module Scryer
220
223
  "#{label.ljust(14)}#{value.rjust(20)}"
221
224
  end
222
225
 
226
+ def score_row(label, score)
227
+ "#{label.ljust(20)}#{score["score"]}/100 (#{paint_grade(score["grade"], score["grade"])})"
228
+ end
229
+
223
230
  # The categories above are counted separately, but nothing else ranks
224
231
  # across them — this is what actually backs "tells you what to fix
225
232
  # first" rather than just splitting findings into four buckets. Same
@@ -917,8 +924,9 @@ module Scryer
917
924
  [File.join(dir, "scryer_report.json"), File.join(dir, "scryer_report.html")]
918
925
  end
919
926
 
920
- def git(root, cmd)
921
- output = `git -C #{Shellwords.escape(root)} #{cmd} 2>/dev/null`.strip
927
+ def git(root, *cmd)
928
+ output, = Open3.capture3("git", "-C", root, *cmd)
929
+ output = output.strip
922
930
  output.empty? ? nil : output
923
931
  rescue StandardError
924
932
  nil
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Scryer
2
3
  # Hand-rolled ANSI color/style helper for console output — deliberately
3
4
  # not a gem dependency (`pastel`, `colorize`, ...), consistent with
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require "json"
2
3
  require "date"
3
4
 
@@ -108,7 +109,22 @@ module Scryer
108
109
  next unless in_specs
109
110
 
110
111
  name = Regexp.last_match(1)
111
- version = Regexp.last_match(2)
112
+ # A platform-specific gem's lockfile spec line looks like
113
+ # "nokogiri (1.19.4-x86_64-linux-musl)" — Bundler appends the
114
+ # platform as a hyphen-separated suffix on the version itself.
115
+ # RubyGems version numbers (Gem::Version) never contain a
116
+ # hyphen — only dots — so a hyphen here always demarcates that
117
+ # suffix, never part of the version proper (including for
118
+ # prerelease gems, which use a dot: "1.0.0.pre1", not
119
+ # "1.0.0-pre1"). Left unstripped, this string gets sent
120
+ # straight to OSV.dev's version filter in vulnerable_gems,
121
+ # which parses "-x86_64-linux-musl" as a semver prerelease
122
+ # marker — sorting the version as *older* than the plain
123
+ # release and matching every advisory fixed at-or-before it as
124
+ # if it were still open. Confirmed directly against OSV.dev:
125
+ # querying nokogiri "1.19.4-x86_64-linux-musl" returns 8 vulns;
126
+ # querying "1.19.4" (the real, fully-patched version) returns 0.
127
+ version = Regexp.last_match(2).split("-", 2).first
112
128
  # A gem can legitimately appear under more than one block only
113
129
  # in pathological Gemfiles; last one wins, consistent with how
114
130
  # Bundler itself resolves a single spec per gem name.
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require "open3"
2
3
 
3
4
  module Scryer
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require "set"
2
3
 
3
4
  module Scryer
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module Scryer
2
3
  # A single reported issue. `suggested_fix` is always a human-reviewable text
3
4
  # explanation + example patch, never something auto-applied — see the
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require "set"
2
3
 
3
4
  module Scryer
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require "ripper"
2
3
 
3
4
  module Scryer
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require "set"
2
3
  require "ripper"
3
4
 
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require "ripper"
2
3
 
3
4
  module Scryer
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  # Opt-in Minitest integration — require this file yourself (e.g. `require
2
3
  # "scryer/minitest"` in test_helper.rb) rather than it loading automatically
3
4
  # with the gem; same reasoning as lib/scryer/rspec.rb (Minitest is never a