scryer 1.2.1 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +76 -9
- data/README.md +6 -3
- data/docs/architecture.md +45 -36
- data/docs/rails-integration.md +1 -1
- data/docs/rules.md +8 -1
- data/docs/usage.md +5 -2
- data/lib/generators/scryer/install_generator.rb +1 -0
- data/lib/generators/scryer/templates/scryer_initializer.rb +1 -0
- data/lib/scryer/ai_client.rb +1 -0
- data/lib/scryer/ai_fix_suggester.rb +1 -0
- data/lib/scryer/ast.rb +53 -0
- data/lib/scryer/authorization_watcher.rb +1 -0
- data/lib/scryer/baseline.rb +1 -0
- data/lib/scryer/cache_extractor.rb +1 -0
- data/lib/scryer/cli.rb +19 -11
- data/lib/scryer/colorizer.rb +1 -0
- data/lib/scryer/dependency_audit.rb +1 -0
- data/lib/scryer/dependency_fixer.rb +1 -0
- data/lib/scryer/duplicate_detector.rb +1 -0
- data/lib/scryer/finding.rb +1 -0
- data/lib/scryer/fix_runner.rb +1 -0
- data/lib/scryer/fix_verifier.rb +1 -0
- data/lib/scryer/mechanical_fixer.rb +1 -0
- data/lib/scryer/method_extractor.rb +1 -0
- data/lib/scryer/minitest.rb +1 -0
- data/lib/scryer/performance_rules/inefficient_save_loop_rule.rb +1 -0
- data/lib/scryer/performance_rules/missing_pagination_rule.rb +1 -0
- data/lib/scryer/performance_rules/n_plus_one_query_rule.rb +2 -0
- data/lib/scryer/performance_rules/unbounded_table_scan_rule.rb +9 -1
- data/lib/scryer/query_extractor.rb +1 -0
- data/lib/scryer/query_watcher.rb +1 -0
- data/lib/scryer/railtie.rb +1 -0
- data/lib/scryer/report_renderer.rb +161 -99
- data/lib/scryer/rspec.rb +1 -0
- data/lib/scryer/rule.rb +15 -2
- data/lib/scryer/rule_set.rb +1 -0
- data/lib/scryer/rules/action_cable_forgery_protection_rule.rb +1 -0
- data/lib/scryer/rules/active_storage_inline_disposition_rule.rb +1 -0
- data/lib/scryer/rules/active_storage_missing_content_type_validation_rule.rb +1 -0
- data/lib/scryer/rules/authentication_bypass_rule.rb +1 -0
- data/lib/scryer/rules/command_injection_rule.rb +1 -0
- data/lib/scryer/rules/consider_all_requests_local_rule.rb +1 -0
- data/lib/scryer/rules/cors_misconfiguration_rule.rb +1 -0
- data/lib/scryer/rules/csrf_protection_rule.rb +1 -0
- data/lib/scryer/rules/dangerous_eval_rule.rb +117 -0
- data/lib/scryer/rules/force_ssl_rule.rb +1 -0
- data/lib/scryer/rules/graphql_missing_query_limits_rule.rb +1 -0
- data/lib/scryer/rules/hardcoded_basic_auth_rule.rb +1 -0
- data/lib/scryer/rules/hardcoded_secret_key_base_rule.rb +1 -0
- data/lib/scryer/rules/hardcoded_secret_rule.rb +1 -0
- data/lib/scryer/rules/host_authorization_disabled_rule.rb +1 -0
- data/lib/scryer/rules/idor_rule.rb +6 -14
- data/lib/scryer/rules/insecure_cookie_serializer_rule.rb +1 -0
- data/lib/scryer/rules/job_raw_params_rule.rb +1 -0
- data/lib/scryer/rules/jwt_insecure_rule.rb +1 -0
- data/lib/scryer/rules/mass_assignment_rule.rb +10 -16
- data/lib/scryer/rules/missing_authorization_rule.rb +1 -0
- data/lib/scryer/rules/missing_policy_scope_rule.rb +2 -9
- data/lib/scryer/rules/open_redirect_rule.rb +1 -0
- data/lib/scryer/rules/path_traversal_rule.rb +1 -0
- data/lib/scryer/rules/security_headers_rule.rb +1 -0
- data/lib/scryer/rules/sql_injection_rule.rb +1 -0
- data/lib/scryer/rules/ssrf_rule.rb +1 -0
- data/lib/scryer/rules/unsafe_deserialization_rule.rb +1 -0
- data/lib/scryer/rules/verbose_production_log_level_rule.rb +1 -0
- data/lib/scryer/rules/weak_crypto_rule.rb +1 -0
- data/lib/scryer/rules/weak_session_cookie_rule.rb +1 -0
- data/lib/scryer/rules/xss_unsafe_html_rule.rb +1 -0
- data/lib/scryer/scanner.rb +126 -7
- data/lib/scryer/style_rules/frozen_string_literal_rule.rb +1 -0
- data/lib/scryer/version.rb +2 -1
- data/lib/scryer.rb +1 -0
- data/lib/tasks/scryer.rake +8 -2
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d2d73db1916cc56075446233dfaa8e88d449bd77218e85fa41fe96e11d56df14
|
|
4
|
+
data.tar.gz: b4a5c41ff1f7132c4ba4807177ee31629614376b2cb0228e24231f44ab3c25e0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6f9cac00b59b7f44d5939ec66bd4a63280acf60c2b011f7a0f5977c323a28595a85bf9f1a6a622dc9e6c55704bd664c6df85428b189b5e74edc4990785c1af76
|
|
7
|
+
data.tar.gz: f56d186dfcae4d6344a7d03a8e43dcc2508f68e4276d7df3c9bffaa46fe8cf1d2591edd078ccc8627258d45a1d6649d5ae391ee225e7a88886afff0f3b912f10
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,73 @@ 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
|
+
|
|
8
75
|
## [1.2.1] - 2026-09-04
|
|
9
76
|
|
|
10
77
|
- Fixed: a platform-specific gem's `Gemfile.lock` spec line (e.g. `nokogiri (1.19.4-x86_64-linux-musl)`)
|
|
@@ -357,10 +424,10 @@ All notable changes to this project are documented here. Format loosely follows
|
|
|
357
424
|
`properties` and a combined severity+confidence `rank`). Performance/style rules get a
|
|
358
425
|
`confidence` too (no CWE/OWASP — that taxonomy is security-specific). This is Scryer's own
|
|
359
426
|
best-effort categorization for practitioner convenience, not an OWASP-endorsed or independently
|
|
360
|
-
audited mapping — see the README's "What Scryer detects" section for the full caveat.
|
|
361
|
-
already
|
|
362
|
-
capability, just a fuller version of something that idea already existed
|
|
363
|
-
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).
|
|
364
431
|
- New `ReportRenderer#owasp_coverage` — counts security findings per OWASP Top 10 category, shown
|
|
365
432
|
as a console summary block ("OWASP Top 10 (2021) coverage:") and a new HTML report section, a
|
|
366
433
|
direct byproduct of every security rule now carrying an `owasp_category`.
|
|
@@ -392,9 +459,9 @@ All notable changes to this project are documented here. Format loosely follows
|
|
|
392
459
|
performed.
|
|
393
460
|
- Repositioned the README, gemspec, docs site, and llms.txt around this: Scryer's differentiator
|
|
394
461
|
isn't "one command instead of several tools," it's ranking risk *across* security, performance,
|
|
395
|
-
dependencies, and code quality — something none of RuboCop/
|
|
396
|
-
within their own domain, let alone across all four. The honest heuristic-vs-taint-analysis
|
|
397
|
-
framing versus
|
|
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
|
|
398
465
|
1.0.0 comparison table — this is a reframing of what Scryer adds, not a new claim about
|
|
399
466
|
detection accuracy.
|
|
400
467
|
|
|
@@ -451,8 +518,8 @@ All notable changes to this project are documented here. Format loosely follows
|
|
|
451
518
|
## [1.0.0] - 2026-08-13
|
|
452
519
|
|
|
453
520
|
Seventeen new security rules, a SARIF report format, a Ruby end-of-life check, and a
|
|
454
|
-
credentials-exposure check — see the README's "Scryer vs RuboCop vs
|
|
455
|
-
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
|
|
456
523
|
analysis; `idor` in particular carries real false-positive risk by nature of the problem).
|
|
457
524
|
|
|
458
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
|
|
62
|
-
|
|
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
|
|
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 [
|
|
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,55 +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
|
-
##
|
|
94
|
+
## Scores
|
|
95
95
|
|
|
96
|
-
Every scan produces
|
|
97
|
-
and as
|
|
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:
|
|
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#
|
|
104
|
-
|
|
105
|
-
`
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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":
|
|
128
134
|
|
|
129
135
|
```
|
|
130
136
|
Checks: 23/36 rules clean (63.9%)
|
|
131
137
|
```
|
|
132
138
|
|
|
133
139
|
This is the closest thing Scryer has to Brakeman's "X checks, Y warnings" framing — a coverage
|
|
134
|
-
signal, not a risk signal. It's deliberately a *different* number from the
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
|
138
145
|
it from `Scryer::RuleSet.all` (every registered security/performance/style rule) against which
|
|
139
146
|
rule_ids actually appeared in this scan's findings — dependency checks aren't counted here since
|
|
140
147
|
they're not backed by a `Scryer::Rule` subclass. Report both; neither alone tells the whole story.
|
|
141
148
|
|
|
142
|
-
The
|
|
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
|
|
143
152
|
[A note on how this gem was actually verified](#a-note-on-how-this-gem-was-actually-verified) for
|
|
144
153
|
why every example in this README is real output.
|
|
145
154
|
|
|
@@ -245,7 +254,7 @@ sits alongside them and adds the cross-category picture neither one (nor bundler
|
|
|
245
254
|
four categories) by severity, shown as "Top priorities" in the console summary and at the top of
|
|
246
255
|
the HTML report — see the example near the top of this README.
|
|
247
256
|
- **[k]** Not a Scryer-only capability — Brakeman's own warnings already include a CWE reference.
|
|
248
|
-
Scryer's version is a fuller mapping (every one of its
|
|
257
|
+
Scryer's version is a fuller mapping (every one of its 32 security rules carries both a CWE ID
|
|
249
258
|
and an OWASP Top 10 (2021) category, aggregated into an OWASP coverage scorecard in every
|
|
250
259
|
report), but the underlying idea isn't new; see
|
|
251
260
|
[What Scryer detects](./rules.md#what-scryer-detects) for the honesty caveat on how this mapping was built
|
data/docs/rails-integration.md
CHANGED
|
@@ -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 [
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
...
|
data/lib/scryer/ai_client.rb
CHANGED
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.
|
data/lib/scryer/baseline.rb
CHANGED
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 "
|
|
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/
|
|
9
|
-
#
|
|
10
|
-
#
|
|
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
|
|
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 =
|
|
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
|
data/lib/scryer/colorizer.rb
CHANGED
data/lib/scryer/finding.rb
CHANGED
data/lib/scryer/fix_runner.rb
CHANGED
data/lib/scryer/fix_verifier.rb
CHANGED
data/lib/scryer/minitest.rb
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
1
2
|
module Scryer
|
|
2
3
|
module PerformanceRules
|
|
3
4
|
# Flags likely N+1 queries: inside a `.each`/`.map` block whose receiver
|
|
@@ -111,6 +112,7 @@ module Scryer
|
|
|
111
112
|
|
|
112
113
|
const = root[1]
|
|
113
114
|
return false unless const.is_a?(Array) && const[0] == :@const
|
|
115
|
+
return false unless Ast.likely_model_name?(const[1], known_models: known_models, known_non_models: known_non_models)
|
|
114
116
|
|
|
115
117
|
Ast.each_node(value).any? do |n|
|
|
116
118
|
next false unless Ast.tagged?(n, :call, :method_add_arg, :command, :vcall, :fcall)
|