scryer 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +414 -0
  3. data/README.md +114 -649
  4. data/docs/architecture.md +268 -0
  5. data/docs/contributing.md +42 -0
  6. data/docs/fix-mode.md +364 -0
  7. data/docs/rails-integration.md +162 -0
  8. data/docs/rules.md +310 -0
  9. data/docs/usage.md +301 -0
  10. data/lib/generators/scryer/USAGE +10 -2
  11. data/lib/generators/scryer/templates/scryer_initializer.rb +15 -0
  12. data/lib/scryer/ai_fix_suggester.rb +37 -11
  13. data/lib/scryer/ast.rb +26 -0
  14. data/lib/scryer/authorization_watcher.rb +156 -0
  15. data/lib/scryer/baseline.rb +75 -0
  16. data/lib/scryer/cli.rb +688 -14
  17. data/lib/scryer/colorizer.rb +56 -0
  18. data/lib/scryer/dependency_fixer.rb +96 -0
  19. data/lib/scryer/finding.rb +6 -0
  20. data/lib/scryer/fix_runner.rb +161 -0
  21. data/lib/scryer/fix_verifier.rb +169 -0
  22. data/lib/scryer/mechanical_fixer.rb +288 -0
  23. data/lib/scryer/minitest.rb +48 -0
  24. data/lib/scryer/performance_rules/inefficient_save_loop_rule.rb +32 -0
  25. data/lib/scryer/performance_rules/missing_pagination_rule.rb +1 -0
  26. data/lib/scryer/performance_rules/n_plus_one_query_rule.rb +1 -0
  27. data/lib/scryer/performance_rules/unbounded_table_scan_rule.rb +1 -0
  28. data/lib/scryer/report_renderer.rb +539 -46
  29. data/lib/scryer/rspec.rb +55 -0
  30. data/lib/scryer/rule.rb +22 -2
  31. data/lib/scryer/rules/action_cable_forgery_protection_rule.rb +3 -0
  32. data/lib/scryer/rules/active_storage_inline_disposition_rule.rb +3 -0
  33. data/lib/scryer/rules/active_storage_missing_content_type_validation_rule.rb +3 -0
  34. data/lib/scryer/rules/authentication_bypass_rule.rb +30 -7
  35. data/lib/scryer/rules/command_injection_rule.rb +3 -0
  36. data/lib/scryer/rules/consider_all_requests_local_rule.rb +51 -0
  37. data/lib/scryer/rules/cors_misconfiguration_rule.rb +51 -20
  38. data/lib/scryer/rules/csrf_protection_rule.rb +60 -11
  39. data/lib/scryer/rules/force_ssl_rule.rb +3 -0
  40. data/lib/scryer/rules/graphql_missing_query_limits_rule.rb +31 -0
  41. data/lib/scryer/rules/hardcoded_basic_auth_rule.rb +3 -0
  42. data/lib/scryer/rules/hardcoded_secret_key_base_rule.rb +3 -0
  43. data/lib/scryer/rules/hardcoded_secret_rule.rb +3 -0
  44. data/lib/scryer/rules/host_authorization_disabled_rule.rb +50 -0
  45. data/lib/scryer/rules/idor_rule.rb +63 -9
  46. data/lib/scryer/rules/insecure_cookie_serializer_rule.rb +3 -0
  47. data/lib/scryer/rules/job_raw_params_rule.rb +40 -7
  48. data/lib/scryer/rules/jwt_insecure_rule.rb +3 -0
  49. data/lib/scryer/rules/mass_assignment_rule.rb +32 -5
  50. data/lib/scryer/rules/missing_authorization_rule.rb +103 -0
  51. data/lib/scryer/rules/missing_policy_scope_rule.rb +134 -0
  52. data/lib/scryer/rules/open_redirect_rule.rb +3 -0
  53. data/lib/scryer/rules/path_traversal_rule.rb +22 -1
  54. data/lib/scryer/rules/security_headers_rule.rb +3 -0
  55. data/lib/scryer/rules/sql_injection_rule.rb +3 -0
  56. data/lib/scryer/rules/ssrf_rule.rb +67 -13
  57. data/lib/scryer/rules/unsafe_deserialization_rule.rb +3 -0
  58. data/lib/scryer/rules/verbose_production_log_level_rule.rb +53 -0
  59. data/lib/scryer/rules/weak_crypto_rule.rb +37 -2
  60. data/lib/scryer/rules/weak_session_cookie_rule.rb +3 -0
  61. data/lib/scryer/rules/xss_unsafe_html_rule.rb +41 -0
  62. data/lib/scryer/scanner.rb +25 -12
  63. data/lib/scryer/style_rules/frozen_string_literal_rule.rb +1 -0
  64. data/lib/scryer/version.rb +1 -1
  65. data/lib/scryer.rb +30 -1
  66. data/lib/tasks/scryer.rake +447 -20
  67. metadata +52 -12
data/README.md CHANGED
@@ -1,18 +1,21 @@
1
- # Scryer — Ruby & Rails Code Security Auditor
2
-
3
- Scryer is a Ruby static code analysis and security auditing tool. It analyzes Ruby and Rails
4
- projects, audits `Gemfile.lock` dependencies for known vulnerabilities, identifies potential
5
- security, performance, and code-quality issues, and gives you an actionable, human-reviewable
6
- suggestion for fixing each one.
7
-
8
- Most Rails teams already run several separate tools to cover code quality and security: RuboCop
9
- for style, Brakeman for security, bundler-audit for dependency CVEs, Reek for code smells, plus
10
- whatever custom scripts glue their outputs together in CI — different gems, different config
11
- files, different report formats, different CI steps to maintain.
12
-
13
- **Scryer's job is to be the one audit command that covers all of it** security vulnerabilities,
14
- performance problems, duplicate/smelly code, and dependency vulnerabilities in a single scan
15
- with a single report:
1
+ # Scryer — Ruby on Rails Security Auditor
2
+
3
+ Scryer is an all-in-one security auditing and static analysis tool for Ruby on Rails
4
+ applications tells you what to fix first, across security, performance, dependencies, and code
5
+ quality. It answers a different question than a single-purpose linter does: not just "what's wrong," but
6
+ **what's actually worth fixing first**. It looks across security vulnerabilities, performance
7
+ problems, dependency risk, and code quality in one pass, ranks what it finds by severity, and
8
+ surfaces the handful of issues that matter most at the top of every report — with a
9
+ human-reviewable suggested fix for each one.
10
+
11
+ Brakeman is very good at finding security patterns in Rails apps — years of deep taint/data-flow
12
+ analysis that Scryer's heuristics don't try to match (see the honest comparison below). But a
13
+ security pattern match in isolation doesn't tell you whether it's the most urgent thing on your
14
+ plate this week, or the fifth-most: is that one SQL-injection-shaped call more or less pressing
15
+ than the vulnerable gem sitting in your `Gemfile.lock`, or the N+1 query burning production
16
+ database time right now? Answering that means looking at security, performance, dependencies, and
17
+ code quality *together*, not as four separate tool outputs to reconcile by hand. That's Scryer's
18
+ job:
16
19
 
17
20
  ```bash
18
21
  gem install scryer
@@ -23,649 +26,111 @@ scryer
23
26
  Scryer Audit — 236 files scanned
24
27
  ────────────────────────────────
25
28
 
26
- Security 13 findings
29
+ Security Score: 10/100 (F)
30
+ Checks: 23/36 rules clean (63.9%)
31
+
32
+ Security 27 findings
27
33
  Performance 10 findings
28
34
  Code Quality 248 findings
29
35
  Dependencies 24 findings
30
36
  ────────────────────────────────
31
- Total 295 findings
37
+ Total 309 findings
38
+
39
+ Top priorities:
40
+ 1. [critical] security — mass_assignment (app/helpers/api/v1/create_order_helper.rb:14)
41
+ 2. [critical] security — mass_assignment (app/helpers/api/v1/create_order_helper.rb:45)
42
+ 3. [critical] security — mass_assignment (app/helpers/api/v1/checkout/checkout_helper.rb:92)
43
+ 4. [critical] security — sql_injection (app/controllers/concerns/billing_helper.rb:12)
44
+ 5. [critical] security — mass_assignment (app/controllers/api/v1/orders_controller.rb:8)
45
+
46
+ OWASP Top 10 (2021) coverage:
47
+ A01:2021-Broken Access Control: 16 findings
48
+ A03:2021-Injection: 4 findings
49
+ A08:2021-Software and Data Integrity Failures: 4 findings
50
+ A02:2021-Cryptographic Failures: 1 finding
51
+ A04:2021-Insecure Design: 1 finding
52
+ A09:2021-Security Logging and Monitoring Failures: 1 finding
32
53
 
33
54
  JSON report: tmp/scryer_report.json
34
55
  HTML report: tmp/scryer_report.html
35
56
  ```
36
57
 
37
- That's real output from a scan of a live 236-file Rails app not a mockup. See
38
- [Scryer vs RuboCop vs Brakeman vs bundler-audit](#scryer-vs-rubocop-vs-brakeman-vs-bundler-audit)
39
- below for exactly how it stacks up against the tools it's meant to consolidate.
40
-
41
- ### What Scryer detects
42
-
43
- **Security**
44
-
45
- * SQL injection
46
- * Mass assignment
47
- * Command injection
48
- * Hardcoded secrets (and hardcoded HTTP Basic Auth credentials)
49
- * Unsafe deserialization
50
- * XSS-prone HTML
51
- * CSRF gaps
52
- * Weak cryptography
53
- * Open redirects
54
- * Server-side request forgery (SSRF)
55
- * Path traversal
56
- * Insecure direct object references (IDOR) the least precise check in the gem; see
57
- [comparison table](#scryer-vs-rubocop-vs-brakeman-vs-bundler-audit) for the false-positive tradeoff
58
- * Authentication filters explicitly skipped (`skip_before_action`)
59
- * Rails security configuration: disabled HTTPS enforcement, session cookies missing the secure
60
- flag, the Marshal cookie serializer, disabled default security headers (`X-Frame-Options`,
61
- `X-Content-Type-Options`, Content-Security-Policy), Action Cable request forgery protection
62
- disabled, a hardcoded `secret_key_base`
63
- * Active Storage attachments without a content-type allowlist, or served with inline disposition
64
- * CORS misconfiguration (wildcard origin combined with `credentials: true`)
65
- * Insecure JWT usage (`JWT.decode` with signature verification disabled, `algorithm: 'none'`, or a
66
- hardcoded secret)
67
- * Background jobs (`perform_async`/`perform_later`/`perform_now`) passed raw `params` risks
68
- leaking request data into Sidekiq/ActiveJob logs, Redis, or the Sidekiq web UI
69
- * GraphQL schemas with no query depth/complexity limit (`max_depth`/`max_complexity`)
70
-
71
- **Code quality**
72
-
73
- * Near-duplicate code
74
- * Repeated logic
75
- * Potentially problematic code patterns
76
- * Missing `frozen_string_literal` magic comment (the one deliberate, narrow style check — see
77
- [comparison table](#scryer-vs-rubocop-vs-brakeman-vs-bundler-audit) for why not more)
78
-
79
- **Performance**
80
-
81
- * N+1 queries
82
- * Missing pagination
83
- * Inefficient per-record saves
84
- * Unbounded full-table iteration
85
-
86
- **Dependencies**
87
-
88
- * Known dependency vulnerabilities via OSV.dev
89
- * Insecure gem sources
90
- * Ruby version end-of-life (no more security patches published for it, for any issue)
91
- * `config/master.key` present on disk with no matching `.gitignore` entry
92
-
93
- ### Example findings
94
-
95
- Real output three lines of deliberately flawed Rails code, scanned with plain `scryer`:
96
-
97
- ```ruby
98
- def create
99
- @invoice = Invoice.create(params[:invoice])
100
- end
101
- ```
102
-
103
- ```
104
- [CRITICAL] mass_assignment app/controllers/invoices_controller.rb:3
105
- `create` receives `params` (or a subscript of it) directly, with no `.permit(...)` call — every
106
- attribute in the request can be set, including ones the form/API was never meant to expose (e.g.
107
- `admin`, `role_id`).
108
-
109
- fix: Wrap the params in a strong-parameters method, e.g. `create(order_params)` with
110
- `def order_params; params.require(:order).permit(:status, :total); end` — only the explicitly
111
- permitted keys get through.
112
- ```
113
-
114
- ```ruby
115
- Invoice.where("customer_name = '#{name}'")
116
- ```
117
-
118
- ```
119
- [CRITICAL] sql_injection — app/controllers/invoices_controller.rb:9
120
- `where` is called with a string built via interpolation, which lets user-controlled input change
121
- the SQL executed.
122
-
123
- fix: Use a parameterized form instead, e.g. `where("column = ?", value)` or the hash form
124
- `where(column: value)` — both let Active Record escape the value safely instead of interpolating
125
- it directly into SQL.
126
- ```
127
-
128
- ```ruby
129
- @invoices = Invoice.where(status: "open")
130
- @invoices.each { |invoice| invoice.account.name }
131
- ```
132
-
133
- ```
134
- [WARNING] n_plus_one_query — app/controllers/invoices_controller.rb:14
135
- `invoice.account` is called inside a loop — if `account` is an association, this issues a separate
136
- query per iteration instead of one batched query (a classic N+1).
137
-
138
- fix: Eager-load the association on the base query before the loop, e.g.
139
- `invoices = Model.includes(:account).where(...)` (or add `:account` to an existing `.includes(...)`
140
- call), so Rails fetches it in one extra query instead of one per record.
141
- ```
142
-
143
- Every finding follows this shape, whichever category it's in: exactly where the issue is, why it
144
- matters, and a fix written against your actual code — not a generic paragraph you have to
145
- translate into your own file.
146
-
147
- ### Reports
148
-
149
- Generate detailed JSON or self-contained HTML reports:
150
-
151
- ```bash
152
- scryer -o report.json -o report.html
153
- ```
154
-
155
- ### Developer-friendly suggestions
156
-
157
- Every finding includes a human-reviewable suggested fix. Scryer never automatically modifies your source code.
158
-
159
- ### Runtime analysis
160
-
161
- Scryer can optionally monitor ActiveRecord queries at runtime to detect N+1 queries and unused eager loading.
162
-
163
- ### Designed for Ruby
164
-
165
- Scryer uses Ruby's standard-library `Ripper` parser, allowing source analysis without requiring Rails or Bundler to run the static scan itself.
166
-
167
- Several more things live alongside the static scan, each documented in its own section below:
168
-
169
- - **Skipping rules** ([Skipping rules](#skipping-rules)): silence a specific rule by `rule_id`
170
- (config-wide via `c.skip_rules`, or one-off via `--skip`) without editing or deleting it.
171
- - A **dependency audit** ([Dependency audit](#dependency-audit)), on by default, that checks
172
- `Gemfile.lock` against [OSV.dev](https://osv.dev) for known-vulnerable gem versions and insecure
173
- sources — the same broad goal as [bundler-audit](https://github.com/rubysec/bundler-audit), built
174
- independently on a different data source. Pass `--no-deps` (or the `nodeps` rake arg) for a fast,
175
- fully offline run instead.
176
- - **AI-assisted fix suggestions** ([AI-assisted fix suggestions](#ai-assisted-fix-suggestions)),
177
- optional, provider-agnostic: rewrites each finding's `suggested_fix` against its actual code
178
- using an LLM you configure — any LLM, not a specific vendor.
179
-
180
- **Nothing is auto-applied.** A security or performance fix needs a human's judgment about the
181
- surrounding code; this gem's job is to point at the issue and explain it clearly, not to rewrite
182
- your files. This holds whether `suggested_fix` came from a rule's static template or from the
183
- optional AI enrichment below — either way, it's text for a human to read and act on.
184
-
185
- ## A note on how this gem was actually verified
186
-
187
- Unlike most hand-written Rails work, this gem's core scanning engine was genuinely executed
188
- during development — `Ripper` needs no Rails or bundler to run, so every rule here was tested
189
- against real vulnerable-code and clean/fixed-code fixtures, not just written and hoped for. Where
190
- a rule produced a false positive during that testing (an actual one did — `BCrypt::Password.create`
191
- initially tripped the mass-assignment rule meant for `Model.create(params)`), it was caught and
192
- fixed before being considered done. That's a meaningfully higher bar of confidence than most of
193
- this account's Rails-app work, which can't be executed in the sandbox it was built in.
194
-
195
- That said: this is a heuristic tool, not full data-flow/taint analysis (that's what a mature tool
196
- like Brakeman spends years building). Expect some false positives and false negatives — that's
197
- normal for this class of tool, not a bug. An already-guarded call can still get flagged since
198
- rules don't trace surrounding conditionals — always review a finding in its surrounding context
199
- before acting on it.
200
-
201
- ## Scryer vs RuboCop vs Brakeman vs bundler-audit
202
-
203
- Scryer isn't trying to out-lint RuboCop or out-analyze Brakeman — where an existing tool
204
- specializes, it's still worth running on its own; style/lint conventions are almost entirely
205
- RuboCop's job, and Scryer stays out of that territory except for one narrow, deliberately-scoped
206
- check (see footnote below). Brakeman also remains the deeper, more mature tool for the security
207
- categories it's spent years on — real taint/data-flow analysis, not heuristic pattern-matching.
208
- What Scryer actually replaces is *stitching several of these together yourself*: it covers
209
- categories none of the others do alone (performance heuristics, duplicate-code detection, runtime
210
- query analysis, SSRF/path-traversal/IDOR/auth-config checks), and folds the categories they *do*
211
- cover into one command and one report instead of several separate tools, configs, and CI steps.
212
-
213
- | Capability | Scryer | RuboCop | Brakeman | bundler-audit |
214
- |-------------------------------------------|:------:|:--------:|:--------:|:-------------:|
215
- | Style/lint conventions | Partial [h] | ✅ | ❌ | ❌ |
216
- | Rails security scanning | ✅ [i] | ❌ | ✅ | ❌ |
217
- | Performance heuristics | ✅ | Partial [a] | ❌ | ❌ |
218
- | Duplicate/similar code detection | ✅ | Partial [b] | ❌ | ❌ |
219
- | Dependency vulnerability scanning | ✅ | ❌ | ❌ | ✅ |
220
- | Runtime query analysis (N+1 in production) | ✅ | ❌ | ❌ | ❌ |
221
- | HTML report | ✅ | Partial [c] | ✅ | ❌ |
222
- | JSON report | ✅ | ✅ | ✅ | Limited [d] |
223
- | SARIF report (GitHub Code Scanning, etc.) | ✅ | ❌ | ✅ | ❌ |
224
- | Human-reviewable fix suggestions | ✅ | Partial [e] | Partial [f] | Limited [g] |
225
- | Single command covering all of the above | ✅ | ❌ | ❌ | ❌ |
226
-
227
- - **[a]** `rubocop-performance` adds some Ruby/Rails performance cops, but nothing like N+1-query
228
- or missing-pagination detection.
229
- - **[b]** A handful of cops catch exact-duplicate patterns (e.g. `Lint/DuplicateMethods`) — no
230
- near-duplicate/similarity detection across methods.
231
- - **[c]** RuboCop's built-in HTML formatter is a plain findings list, not an interactive report.
232
- - **[d]** bundler-audit's output is primarily console text; no first-class JSON formatter.
233
- - **[e]** RuboCop's `-A` auto-corrects many style violations directly — an automatic rewrite, not
234
- a human-reviewable explanation, and only for auto-correctable cops.
235
- - **[f]** Brakeman's warnings describe the issue and a confidence level, not a concrete
236
- before/after code fix.
237
- - **[g]** bundler-audit names the patched version to upgrade to; no code-level remediation (it
238
- doesn't operate on your code at all, only `Gemfile.lock`).
239
- - **[h]** One check only: a missing `# frozen_string_literal: true` magic comment
240
- (`frozen_string_literal`, info severity). Everything else in RuboCop's style/lint domain —
241
- naming, layout, quote style, line length, and hundreds more — is intentionally out of scope; see
242
- [Skipping rules](#skipping-rules) if you don't want even this one.
243
- - **[i]** Heuristic pattern-matching, not taint/data-flow analysis — Brakeman traces whether user
244
- input can actually *reach* a sink; Scryer checks whether a known-dangerous call shape and a
245
- `params` reference appear in the same expression. That's a real precision gap on the harder
246
- checks especially (`idor` in particular has real false-positive risk — see
247
- [What Scryer detects](#what-scryer-detects)), and it's why every finding says "review this,"
248
- never "this is definitely a bug."
249
-
250
- If you already run RuboCop for style, keep it — Scryer isn't a replacement for it. If you already
251
- run Brakeman for its deeper security analysis, Scryer is a complement, not a swap — the categories
252
- they both cover benefit from two different heuristics looking; the ones only Scryer covers
253
- (performance, duplication, runtime queries, dependency EOL) are net-new either way. If you're
254
- currently running Brakeman + bundler-audit + a duplicate-code linter as three separate steps,
255
- Scryer is the "run one thing instead" option.
256
-
257
- ## Install
258
-
259
- Scryer is a development-time analysis tool, not something a running production process needs —
260
- scope it to the `:development` group so a production deploy (typically `bundle install --without
261
- development`, or `BUNDLE_WITHOUT=development` in CI) never installs it at all:
262
-
263
- ```ruby
264
- # Gemfile
265
- group :development do
266
- gem "scryer"
267
- end
268
- ```
269
-
270
- ```bash
271
- bundle install
272
- bin/rails generate scryer:install
273
- ```
274
-
275
- This drops a commented `config/initializers/scryer.rb` into your app — see
276
- [Generators](#generators) below for exactly what it does and what each setting controls. Scryer
277
- is entirely local: there is nothing to point at a server and nothing to authenticate, so the
278
- generator's only job is letting you override the defaults (project name, which directories get
279
- scanned, the branch label).
280
-
281
- ## Running a scan
282
-
283
- ```bash
284
- bin/rails scryer:report # writes tmp/scryer_report.{json,html}
285
- ```
286
-
287
- By default this writes both `tmp/scryer_report.json` and `tmp/scryer_report.html`, and includes a
288
- dependency audit against `Gemfile.lock` (needs network — see [Dependency audit](#dependency-audit);
289
- pass the `nodeps` arg for a fast, fully offline run instead). Format and output path are
290
- configurable via task args — any mix of `json`, `html`, `csv`, `sarif` plus at most one path (quote
291
- the whole thing so your shell doesn't eat the brackets/commas, e.g.
292
- `bin/rails 'scryer:report[json,doc/security_report.json]'`). Rake splits bracket args on every
293
- comma, so each format is its own item in the list rather than one comma-joined string
294
- (`scryer:report[json,html]` is two args, not `"json,html"` as one). At most one non-format,
295
- non-`nodeps` token is accepted per call; giving two raises an error rather than guessing which one
296
- you meant.
297
-
298
- Outside a Rails app (or in CI, or anywhere you don't want a rake task), the gem also ships a
299
- `scryer` executable — same idea as `brakeman -o report.json`, with `-o` repeatable and format
300
- inferred per-file from its extension:
301
-
302
- ```bash
303
- scryer # scans ., writes tmp/scryer_report.{json,html}
304
- scryer -o report.json # just one file, exact path, format from extension
305
- scryer -o report.json -o report.html # as many outputs as you like, one -o each
306
- scryer -p /path/to/app -o /tmp/out.html # -p sets the root to scan (default: cwd)
307
- scryer --no-deps # skip the dependency audit for a fast offline run
308
- scryer --help # full option list (--project-name, --branch, --version, ...)
309
- ```
310
-
311
- Console output is a summary box across every category — see the box in the intro above for a real
312
- example — followed by where each report was written.
313
-
314
- It exits `0` when the scan is clean and `1` when there's at least one security or dependency
315
- finding, so `scryer -o report.json` can gate a CI job the same way `brakeman -o report.json` does.
316
- A `2` exit means a usage error (bad flag, unrecognized output extension) rather than anything
317
- about the scan itself.
318
-
319
- Or use the scanning engine directly as a library:
320
-
321
- ```ruby
322
- require "scryer"
323
- result = Scryer::Scanner.new(root: "/path/to/app").call
324
- puts result.security_findings.size
325
- ```
326
-
327
- Open `tmp/scryer_report.html` in a browser — it's a single self-contained file (inline CSS, no
328
- external assets, works offline) with an overview, a summary of counts by severity, the full list
329
- of checks that ran, a breakdown of warnings by type, every finding in detail, and duplicate-code
330
- groups — laid out similarly to a Brakeman report. `tmp/scryer_report.json` has the same data in
331
- machine-readable form, for feeding into your own dashboard or CI gate. `.csv`
332
- (`scryer -o report.csv` / `rails 'scryer:report[csv]'`) is a flat one-row-per-finding table
333
- (security + performance findings, plus dependency findings unless `--no-deps`/`nodeps` was used) —
334
- handy for dropping into a spreadsheet or importing into a ticketing tool; it skips duplicate-code
335
- groups, which don't reduce to a single actionable row. `.sarif` (`scryer -o report.sarif` /
336
- `rails 'scryer:report[sarif]'`) is for feeding into a CI security dashboard instead — see
337
- [CI/CD integration](#cicd-integration).
338
-
339
- ## CI/CD integration
340
-
341
- `scryer -o report.sarif` writes a [SARIF 2.1.0](https://docs.oasis-open.org/sarif/sarif/v2.1.0/)
342
- report — the format GitHub Code Scanning, Azure DevOps, and most CI security dashboards ingest
343
- natively, turning findings into inline pull-request annotations and Security-tab entries instead
344
- of a report file nobody opens. It's a pure mapping of the same data every other format has —
345
- security, performance, and style findings (each mapped from `Scryer::Rule.rule_id`/`severity`) plus
346
- dependency findings (mapped from `kind`, located at `Gemfile.lock`) — so nothing behaves any
347
- differently than it does in HTML/JSON/CSV.
348
-
349
- Upload it with GitHub's own action in a workflow:
350
-
351
- ```yaml
352
- # .github/workflows/scryer.yml
353
- name: Scryer
354
- on: [push, pull_request]
355
-
356
- jobs:
357
- scryer:
358
- runs-on: ubuntu-latest
359
- permissions:
360
- contents: read
361
- security-events: write # required to upload SARIF
362
- steps:
363
- - uses: actions/checkout@v4
364
- - uses: ruby/setup-ruby@v1
365
- with:
366
- ruby-version: "3.3"
367
- - run: gem install scryer
368
- - run: scryer -o scryer.sarif
369
- continue-on-error: true # let the upload step run even on a non-zero exit; see below
370
- - uses: github/codeql-action/upload-sarif@v3
371
- with:
372
- sarif_file: scryer.sarif
373
- ```
374
-
375
- `continue-on-error: true` on the scan step matters: `scryer` exits non-zero when it finds anything
376
- (see [Running a scan](#running-a-scan)), which would otherwise skip the upload step entirely on
377
- the run where you most want to see the results. If you want the job to still fail the build overall
378
- on findings, check `scryer`'s own exit code in a separate step, or drop `continue-on-error` and
379
- accept that the SARIF upload only happens on clean runs.
380
-
381
- ## What gets scanned
382
-
383
- By default: `app/`, `lib/`, `config/`, `db/` (relative to the app root) — configurable via
384
- `c.dirs` in the initializer. `vendor/`, `node_modules/`, `tmp/`, `log/`, `.git/`, `spec/`, `test/`
385
- are always skipped.
386
-
387
- ## Branch reporting
388
-
389
- By default the `git_branch` recorded in the report comes from `git rev-parse --abbrev-ref HEAD` in
390
- the app root. Set `c.branch` in the initializer to override this — useful in CI, where checkouts
391
- often land in a detached-HEAD state and there's no real branch name to detect:
392
-
393
- ```ruby
394
- Scryer.configure do |c|
395
- c.branch = ENV["CI_COMMIT_BRANCH"]
396
- end
397
- ```
398
-
399
- This only changes the label shown in the report; it does not check out a different branch or
400
- restrict which branch can be scanned.
401
-
402
- ## Skipping rules
403
-
404
- Every rule is heuristic, so an occasional false positive on your specific codebase is expected
405
- (see "A note on how this gem was actually verified" above) — `skip_rules` silences one by
406
- `rule_id` without editing or deleting the rule itself, so it still runs (and could still catch a
407
- real issue) on every other codebase that uses this gem:
408
-
409
- ```ruby
410
- # config/initializers/scryer.rb
411
- Scryer.configure do |c|
412
- c.skip_rules = ["mass_assignment"] # rule_id, from the finding's `rule_id` field
413
- end
414
- ```
415
-
416
- The `scryer` executable's `--skip RULE_ID` flag (repeatable) adds to this list for a single run
417
- without touching the initializer — useful for a one-off "does this go away without rule X" check:
418
-
419
- ```bash
420
- scryer --skip mass_assignment --skip weak_crypto
421
- ```
422
-
423
- ## Runtime query watcher
424
-
425
- Everything above is a one-shot static scan — it reads source, never boots Rails, and can't see
426
- what actually happens at request time. `Scryer::QueryWatcher` is different: it's an opt-in
427
- runtime instrumentation that watches a *running* app for two of the problems
428
- [Bullet](https://github.com/flyerhzm/bullet) is best known for catching — a collection query
429
- followed by one repeat query per row ("N+1"), and an `.includes`/`.preload`/`.eager_load`
430
- association that gets fetched but never actually read ("unused eager loading"). It was built
431
- independently of Bullet, on a different mechanism (SQL-shape correlation via
432
- `ActiveSupport::Notifications`, plus a `Module#prepend` on `ActiveRecord::QueryMethods`'s eager-load
433
- methods and `Association#reader` — not Bullet's own per-request association bookkeeping); no
434
- Bullet source was read or copied to build it.
435
-
436
- Enable it from an initializer (typically gated to development/test, though nothing stops you from
437
- running it in production if you want the noise):
438
-
439
- ```ruby
440
- # config/initializers/scryer.rb
441
- if Rails.env.development? || Rails.env.test?
442
- require "scryer/query_watcher"
443
- Scryer::QueryWatcher.enable!
444
- Rails.application.config.middleware.use Scryer::QueryWatcher::Middleware
445
- end
446
- ```
447
-
448
- The middleware opens one tracking scope per request and logs (via `Rails.logger` by default —
449
- override with `Scryer::QueryWatcher.enable!(logger: ...)`) anything it finds when the request
450
- ends. Outside a request — a Sidekiq job, a rake task, a console session — wrap the code yourself:
451
-
452
- ```ruby
453
- findings = Scryer::QueryWatcher.watch { SomeJob.new.perform }
454
- ```
455
-
456
- `enable!(n_plus_one_threshold: 2)` controls how many repeats of the same query shape from the same
457
- call site count as N+1 (default: the second occurrence already means one collection load produced
458
- more than one query). Each finding is a `Scryer::QueryWatcher::Finding` with `kind`
459
- (`n_plus_one_query_runtime` or `unused_eager_load`), `message`, `call_site`, `count`, and
460
- `suggested_fix` — the same shape as everything else in this gem, so it's straightforward to feed
461
- into your own logging/alerting instead of (or alongside) the built-in logger call.
462
-
463
- This is genuinely runtime-only: with no queries running, it finds nothing, and it never appears in
464
- the static `tmp/scryer_report.html` output. Think of it as this gem's answer to "what actually
465
- happened during this request", where the rest of Scryer answers "what does this code look like".
466
-
467
- ## Dependency audit
468
-
469
- `Scryer::DependencyAudit` checks `Gemfile.lock` for the same broad concerns
470
- [bundler-audit](https://github.com/rubysec/bundler-audit) targets — known-vulnerable gem versions
471
- and insecure dependency sources — built independently on a different data source and a different
472
- lockfile parser: rather than bundler-audit's local clone of the `ruby-advisory-db` git repo, this
473
- queries [OSV.dev](https://osv.dev) (Google's Open Source Vulnerabilities database) live, one
474
- lookup per gem+version, over a small stdlib-only thread pool; `Gemfile.lock` is read with a small
475
- hand-rolled parser (see `DependencyAudit.parse_lockfile`) instead of depending on the `bundler`
476
- library, so it works the same whether or not this happens to run under Bundler. No bundler-audit
477
- source was read or copied to build this.
478
-
479
- ```bash
480
- bin/rails scryer:audit_dependencies # inside a Rails app — dependency audit only, no static scan
481
- ```
482
-
483
- This is the one part of Scryer that needs a live network connection (to reach OSV.dev), and it
484
- runs automatically as part of every `scryer`/`scryer:report` scan — pass `--no-deps` (CLI) or the
485
- `nodeps` arg (rake) for a fast, fully offline run instead. `scryer:audit_dependencies` above (and
486
- `scryer --audit-deps` below) are for when you want *only* the dependency audit, the same way
487
- `bundle-audit check` is a separate, standalone command from your test suite. Either way it exits
488
- non-zero if anything is found, so it can gate CI.
489
-
490
- Three checks run, and any of them can be called on its own as a library:
491
-
492
- ```ruby
493
- Scryer::DependencyAudit.insecure_sources(Rails.root.to_s) # offline — no network needed
494
- Scryer::DependencyAudit.vulnerable_gems(Rails.root.to_s) # needs network (OSV.dev)
495
- Scryer::DependencyAudit.ruby_eol_check(Rails.root.to_s) # offline — checked against a small embedded table
496
- ```
497
-
498
- - **Insecure sources**: flags any `GIT`/`PATH` block in `Gemfile.lock` whose `remote:` is
499
- unencrypted (`git://` or plain `http://`) — the same supply-chain concern bundler-audit's
500
- insecure-source check targets.
501
- - **Vulnerable gems**: for every RubyGems-sourced gem (git/path-sourced gems are skipped — their
502
- version string doesn't necessarily correspond to the same code as the published gem of that
503
- name, so checking them against RubyGems advisories could misattribute or miss vulnerabilities),
504
- queries OSV.dev for known vulnerabilities affecting that exact version. Each finding carries the
505
- advisory id, title, a severity bucketed from OSV's own severity level, a link to the advisory,
506
- and the fixed version(s) to upgrade to. This is also where Rails-framework CVEs surface —
507
- `rails`/`actionview`/`activestorage`/etc. are just gems in `Gemfile.lock` like any other.
508
- - **Ruby EOL**: checks the Ruby version pinned in `Gemfile.lock`'s `RUBY VERSION` section against
509
- Ruby's own [published end-of-life dates](https://www.ruby-lang.org/en/downloads/branches/) — once
510
- a series is EOL, no security patches are published for it at all, for any issue, regardless of
511
- how up to date every gem is. Unlike the two checks above, this doesn't call OSV.dev: EOL dates
512
- are announced years in advance and essentially never change, so a small embedded table
513
- (`DependencyAudit::RUBY_EOL_DATES`) is a one-time/occasional-update cost rather than a live feed
514
- — deliberately not a general Ruby-interpreter CVE database, which would mean maintaining exactly
515
- the kind of stale bundled knowledge base this gem avoids elsewhere (OSV.dev has no queryable
516
- Ruby-interpreter ecosystem to query live instead).
517
-
518
- **From the `scryer` executable** (outside a Rails app, or in CI): `scryer --audit-deps` runs the
519
- same three checks standalone (no static scan), same output/exit-code behavior as the rake task above.
520
- Plain `scryer` already folds them into the normal `-o` report — one HTML/JSON/CSV file covering
521
- static findings *and* dependency findings together — so `--audit-deps` is only for when you want
522
- dependency findings *without* the static scan. And for a single gem, without touching
523
- `Gemfile.lock` at all:
524
-
525
- ```bash
526
- scryer --check-gem rack # every advisory ever filed against rack, any version
527
- scryer --check-gem rack:2.0.8 # only advisories affecting exactly this version
528
- ```
529
-
530
- This is the same OSV.dev client `vulnerable_gems` uses, exposed as a one-off lookup — useful for
531
- "is this gem I'm about to add actually fine" before it's even in your `Gemfile.lock`.
532
-
533
- ## AI-assisted fix suggestions
534
-
535
- Every rule already ships a generic, human-reviewable `suggested_fix` — that's always there and
536
- needs nothing configured. `Scryer::AiFixSuggester` optionally rewrites that text per finding using
537
- an LLM, so the suggestion is written against the finding's actual offending line instead of a
538
- generic template. This is entirely opt-in: with no client configured, `AiFixSuggester` makes zero
539
- network calls and every finding keeps its original `suggested_fix` — nothing below is required to
540
- use the rest of Scryer.
541
-
542
- ### Step by step
543
-
544
- **Inside a Rails app** — `config/initializers/scryer.rb` is autoloaded at boot, so setting
545
- `c.ai_client` there is picked up automatically the next time you scan:
546
-
547
- ```ruby
548
- # config/initializers/scryer.rb
549
- Scryer.configure do |c|
550
- c.ai_client = ->(prompt) { MyLlmClient.chat(prompt) } # any callable — see below for real examples
551
- end
552
- ```
553
-
554
- ```bash
555
- bin/rails scryer:report
556
- ```
557
-
558
- That's the whole flow — no extra flag, no second command. Look for `Scryer: rewriting suggested
559
- fixes via the configured AI client...` in the task's own output, then open the report: every
560
- finding's `suggested_fix` is now the LLM's rewrite instead of the generic template.
561
-
562
- **Outside Rails (the `scryer` executable)** — there's no `config/initializers/` to autoload here,
563
- so `Scryer.configure` needs to actually run before the scan starts. That's what `-r`/`--require` is
564
- for: point it at a small Ruby file that calls `Scryer.configure`, and `scryer` requires it first:
565
-
566
- ```ruby
567
- # scryer_config.rb — anywhere in your project, any filename
568
- Scryer.configure do |c|
569
- c.ai_client = ->(prompt) { MyLlmClient.chat(prompt) }
570
- end
571
- ```
572
-
573
- ```bash
574
- scryer -r ./scryer_config.rb
575
- ```
576
-
577
- Same output, same "rewriting suggested fixes..." line, same result — `-r` is the only difference
578
- between the two paths, and it's required precisely because the standalone executable has nothing
579
- else to make `Scryer.configure` code actually run before it scans.
580
-
581
- **Provider-agnostic by design — bring any LLM.** Scryer doesn't depend on or assume any specific
582
- vendor's API or SDK (consistent with the zero-runtime-dependency design described in the gemspec).
583
- `c.ai_client` accepts any object, or even a bare `Proc`/lambda, that responds to `#call(prompt)`
584
- (or `#complete(prompt)`) and returns the model's reply as a `String` — the two examples above used
585
- a placeholder; `Scryer::AiClient` below is a ready-made adapter for a real HTTP endpoint.
586
-
587
- ### `Scryer::AiClient` — a ready-made HTTP adapter
588
-
589
- For the common case of a JSON/HTTP chat endpoint, `Scryer::AiClient` saves writing the request
590
- plumbing by hand. It takes the two pieces of vendor-specific shape as plain `Proc`s and handles the
591
- HTTP call itself (stdlib `Net::HTTP`, no gem):
592
-
593
- ```ruby
594
- # Claude (Messages API)
595
- Scryer.configure do |c|
596
- c.ai_client = Scryer::AiClient.new(
597
- url: "https://api.anthropic.com/v1/messages",
598
- headers: { "x-api-key" => ENV.fetch("ANTHROPIC_API_KEY"), "anthropic-version" => "2023-06-01" },
599
- build_request: ->(prompt) { { model: "claude-opus-5", max_tokens: 1024, messages: [{ role: "user", content: prompt }] } },
600
- parse_response: ->(json) { json.dig("content", 0, "text") }
601
- )
602
- end
603
- ```
604
-
605
- ```ruby
606
- # Any OpenAI-compatible chat completions endpoint (OpenAI itself, a local
607
- # Ollama/vLLM server, Azure OpenAI, ...) — same adapter, different shape.
608
- Scryer.configure do |c|
609
- c.ai_client = Scryer::AiClient.new(
610
- url: "https://api.openai.com/v1/chat/completions",
611
- headers: { "Authorization" => "Bearer #{ENV.fetch('OPENAI_API_KEY')}" },
612
- build_request: ->(prompt) { { model: "gpt-4o-mini", messages: [{ role: "user", content: prompt }] } },
613
- parse_response: ->(json) { json.dig("choices", 0, "message", "content") }
614
- )
615
- end
616
- ```
617
-
618
- Neither example pins Scryer to that vendor — `build_request`/`parse_response` are just data telling
619
- `AiClient` how to shape one HTTP call; point it at any endpoint that takes a JSON body and returns
620
- a JSON body.
621
-
622
- ### What happens with it configured
623
-
624
- `bin/rails scryer:report` and the `scryer` executable both check `Scryer.configuration.ai_client`
625
- after scanning and, if set, call `Scryer::AiFixSuggester.enhance_result!(result)` before rendering
626
- — one LLM call per security/performance/style finding (plus dependency findings too, if the
627
- dependency audit ran), run across a small thread pool (same pattern as the dependency audit's
628
- OSV.dev lookups) rather than one at a time. A client that raises, times out, or returns nothing
629
- usable just leaves that finding's original `suggested_fix` in place — a failed enrichment never
630
- fails the scan.
631
-
632
- ```ruby
633
- Scryer::AiFixSuggester.enhance!(finding) # one Finding, in place
634
- Scryer::AiFixSuggester.enhance_result!(result) # every finding on a Scanner::Result, in place
635
- ```
636
-
637
- **This sends code snippets to whatever endpoint you configure.** `code_snippet`, `message`, and the
638
- file path are included in the prompt — the same privacy consideration as any third-party service:
639
- don't point this at an endpoint you don't trust with your source, and be mindful this is a second
640
- place (besides the HTML report itself) where finding detail leaves your machine.
641
-
642
- ## Generators
643
-
644
- Scryer ships one generator: `scryer:install`. Run `bin/rails generate scryer:install --help`
645
- in a host app for the full description (also in
646
- [lib/generators/scryer/USAGE](lib/generators/scryer/USAGE)); short version:
647
-
648
- - **What it creates**: a single file, `config/initializers/scryer.rb`, templated from
649
- [lib/generators/scryer/templates/scryer_initializer.rb](lib/generators/scryer/templates/scryer_initializer.rb).
650
- Nothing else — no routes, no migrations, no controllers/views.
651
- - **What it doesn't do**: the rake tasks (`scryer:report`) are registered by
652
- [Scryer::Railtie](lib/scryer/railtie.rb) as soon as the gem is in your `Gemfile`, whether or
653
- not you ever run this generator. The generator exists purely to give you an editable config file.
654
- - **Settings it exposes**: `c.project_name` (report header label), `c.dirs` (which top-level
655
- directories get scanned), `c.branch` (override the git branch label — see
656
- [Branch reporting](#branch-reporting)). All are optional; the commented-out initializer works
657
- as-is with just `bundle install` + the generator.
658
- - **Re-running it**: standard Thor/Rails::Generators behavior — if
659
- `config/initializers/scryer.rb` already exists, you'll be prompted to overwrite, skip, or diff
660
- rather than have it silently clobbered.
661
-
662
- ## Extending it
663
-
664
- Every rule is a small class extending `Scryer::Rule` — see `lib/scryer/rules/*.rb` (security),
665
- `lib/scryer/performance_rules/*.rb` (performance), and `lib/scryer/style_rules/*.rb` (style) for
666
- the pattern. A new rule file dropped into any of the three directories is picked up automatically
667
- (rules self-register via `Rule.inherited` — no manual wiring needed, just `self.category =
668
- "security"|"performance"|"style"`). `Scryer::Ast` has the tree-walking helpers used throughout.
58
+ That's real output from a scan of a live 236-file Rails app (an internal production codebase we
59
+ call "acme-app" here file/controller names above are anonymized, since we don't publish that
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
63
+ severity ranking [`ReportRenderer#top_risks`](lib/scryer/report_renderer.rb) applies across *all*
64
+ categories — security, dependencies, performance, code quality — not just within each one; in the
65
+ HTML report it's at the top of the Findings section, ahead of the 309 individual findings
66
+ underneath it. See
67
+ [Scryer vs RuboCop vs Brakeman vs bundler-audit](docs/architecture.md#scryer-vs-rubocop-vs-brakeman-vs-bundler-audit)
68
+ below for exactly how this differs from what those tools do.
69
+
70
+ ## Frequently asked questions
71
+
72
+ - **What does Scryer do?** Static security auditing and code analysis for Ruby/Rails codebases —
73
+ security vulnerabilities, performance heuristics, dependency risk, and code-quality issues in one
74
+ scan, ranked by severity across all four categories. See
75
+ [What Scryer detects](docs/rules.md#what-scryer-detects).
76
+ - **Is it Rails-specific?** Most of the 31 security rules target Rails conventions specifically
77
+ (controllers, `config/environments/*`, Active Storage, Action Cable, `params`), but the scanning
78
+ engine itself only needs Ruby source — no Rails app or database required to run it. See
79
+ [Designed for Ruby](docs/rules.md#designed-for-ruby).
80
+ - **What vulnerabilities does it detect?** SQL injection, mass assignment, SSRF, path traversal,
81
+ IDOR, missing authorization, insecure JWT/CORS/session/cookie config, hardcoded secrets, XSS,
82
+ weak crypto, unsafe deserialization, open redirects, and more — full list in
83
+ [What Scryer detects](docs/rules.md#what-scryer-detects). Every finding carries a CWE ID and an OWASP Top 10
84
+ (2021) category.
85
+ - **How is it different from Brakeman?** Brakeman does real taint/data-flow analysis for Rails
86
+ security specifically years of maturity Scryer doesn't try to match. Scryer's checks are
87
+ heuristic pattern-matching, and its job is different: combining security, performance,
88
+ dependency, and code-quality findings into one severity-ranked list, which none of those tools do
89
+ on their own. See [Taint analysis vs. heuristic pattern
90
+ matching](https://ramlaxmanyadav.github.io/scryer/taint-analysis-vs-heuristic-pattern-matching.html)
91
+ and the [full comparison table](docs/architecture.md#scryer-vs-rubocop-vs-brakeman-vs-bundler-audit).
92
+ - **Is it actively maintained?** See [CHANGELOG.md](CHANGELOG.md) for release history and
93
+ [lib/scryer/version.rb](lib/scryer/version.rb) for the current version.
94
+ - **Does it work in CI?** Yes — a bundled [GitHub Action](action.yml), SARIF output for GitHub Code
95
+ Scanning, and a documented exit-code contract for gating any CI system. See
96
+ [CI/CD integration](docs/usage.md#cicd-integration) and [Exit codes](docs/usage.md#exit-codes).
97
+ - **What's the license?** MIT. See [License](#license).
98
+ - **How do I install it?** `gem install scryer`, or add `gem "scryer", group: :development` to your
99
+ `Gemfile`. See [Install](docs/usage.md#install).
100
+
101
+
102
+ ## Documentation
103
+
104
+ This README covers the pitch, the FAQ, and licensing. Everything else lives in focused pages —
105
+ each one longer-form than a single section here, cross-linked from wherever it's relevant:
106
+
107
+ - [**Usage & Configuration**](docs/usage.md) — Install, running a scan, exit codes, CI/CD
108
+ integration (GitHub Action + hand-written steps), what gets scanned, branch reporting, skipping
109
+ rules, baseline mode.
110
+ - [**What Scryer Detects**](docs/rules.md) — the full rule list (security/performance/code
111
+ quality/dependencies), example findings, report formats with real JSON/SARIF output, the
112
+ dependency audit.
113
+ - [**Fix Mode & AI-Assisted Fixes**](docs/fix-mode.md) — `scryer verify`, `scryer fix`
114
+ (mechanical, no-AI fixes plus the interactive accept/skip review), and optional AI-rewritten
115
+ suggestions with any LLM you configure.
116
+ - [**Architecture, Performance & Security Model**](docs/architecture.md) how it's built, real
117
+ performance numbers, the security model, false-positive handling, the accuracy benchmark, and
118
+ the full Scryer vs RuboCop vs Brakeman vs bundler-audit comparison.
119
+ - [**Rails Integration**](docs/rails-integration.md) — the runtime query/authorization watchers,
120
+ the `scryer:install` generator, and testing Scryer's own results in your app's test suite.
121
+ - [**Contributing & Releasing**](docs/contributing.md) — how to add a new rule, and how this
122
+ gem's own release process works.
123
+
124
+ Longer-form guides on the [docs site](https://ramlaxmanyadav.github.io/scryer/), each answering
125
+ one question in depth for search/AI-assistant discovery rather than restating the above:
126
+
127
+ - [What is a Rails security scanner?](https://ramlaxmanyadav.github.io/scryer/rails-security-scanner.html)
128
+ - [Taint analysis vs. heuristic pattern matching](https://ramlaxmanyadav.github.io/scryer/taint-analysis-vs-heuristic-pattern-matching.html)
129
+ - [Dependency security for Rails applications](https://ramlaxmanyadav.github.io/scryer/dependency-security.html)
130
+ - [Security code review checklist for Rails](https://ramlaxmanyadav.github.io/scryer/security-code-review-checklist.html)
131
+
132
+ Machine-readable overview for AI assistants/agents: [llms.txt](llms.txt) /
133
+ [llms-full.txt](llms-full.txt) (full concatenated docs, one fetch).
669
134
 
670
135
  ## License
671
136