scryer 0.1.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 (39) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +367 -0
  3. data/exe/scryer +7 -0
  4. data/lib/generators/scryer/USAGE +66 -0
  5. data/lib/generators/scryer/install_generator.rb +29 -0
  6. data/lib/generators/scryer/templates/scryer_initializer.rb +28 -0
  7. data/lib/scryer/ai_client.rb +53 -0
  8. data/lib/scryer/ai_fix_suggester.rb +138 -0
  9. data/lib/scryer/ast.rb +277 -0
  10. data/lib/scryer/cache_extractor.rb +124 -0
  11. data/lib/scryer/cli.rb +193 -0
  12. data/lib/scryer/dependency_audit.rb +225 -0
  13. data/lib/scryer/duplicate_detector.rb +103 -0
  14. data/lib/scryer/finding.rb +21 -0
  15. data/lib/scryer/method_extractor.rb +55 -0
  16. data/lib/scryer/performance_rules/inefficient_save_loop_rule.rb +108 -0
  17. data/lib/scryer/performance_rules/missing_pagination_rule.rb +132 -0
  18. data/lib/scryer/performance_rules/n_plus_one_query_rule.rb +221 -0
  19. data/lib/scryer/performance_rules/unbounded_table_scan_rule.rb +78 -0
  20. data/lib/scryer/query_extractor.rb +123 -0
  21. data/lib/scryer/query_watcher.rb +250 -0
  22. data/lib/scryer/railtie.rb +12 -0
  23. data/lib/scryer/report_renderer.rb +546 -0
  24. data/lib/scryer/rule.rb +43 -0
  25. data/lib/scryer/rule_set.rb +19 -0
  26. data/lib/scryer/rules/command_injection_rule.rb +61 -0
  27. data/lib/scryer/rules/csrf_protection_rule.rb +89 -0
  28. data/lib/scryer/rules/hardcoded_secret_rule.rb +96 -0
  29. data/lib/scryer/rules/mass_assignment_rule.rb +103 -0
  30. data/lib/scryer/rules/open_redirect_rule.rb +57 -0
  31. data/lib/scryer/rules/sql_injection_rule.rb +63 -0
  32. data/lib/scryer/rules/unsafe_deserialization_rule.rb +71 -0
  33. data/lib/scryer/rules/weak_crypto_rule.rb +66 -0
  34. data/lib/scryer/rules/xss_unsafe_html_rule.rb +70 -0
  35. data/lib/scryer/scanner.rb +129 -0
  36. data/lib/scryer/version.rb +3 -0
  37. data/lib/scryer.rb +65 -0
  38. data/lib/tasks/scryer.rake +172 -0
  39. metadata +106 -0
@@ -0,0 +1,129 @@
1
+ require "ripper"
2
+ require "set"
3
+
4
+ module Scryer
5
+ # Ties parsing + rules + duplicate detection together across a directory
6
+ # tree. This is the one entry point both the `sentinel:scan` rake task and
7
+ # any generator/CLI should call — everything else in this gem is a building
8
+ # block this class composes.
9
+ class Scanner
10
+ DEFAULT_GLOB_DIRS = %w[app lib config db].freeze
11
+ SKIP_DIR_SEGMENTS = %w[vendor node_modules tmp log .git spec test].freeze
12
+
13
+ # Duplicate-code detection only makes sense within hand-written business
14
+ # logic. Migrations in particular are mostly boilerplate (create_table /
15
+ # t.string / t.integer calls) that normalize to near-identical token
16
+ # streams and drown out real duplicates with false positives — so unlike
17
+ # the security/performance rules (which still scan every file under
18
+ # DEFAULT_GLOB_DIRS), duplicate detection is restricted to models,
19
+ # controllers, helpers, and concerns (wherever they're nested).
20
+ DUPLICATE_DETECTION_APP_SUBDIRS = %w[models controllers helpers].freeze
21
+
22
+ # Query and cache-value duplicates use a slightly higher bar than whole
23
+ # methods: they're much shorter fragments, so a coincidental match at the
24
+ # method threshold (0.6) is more likely — see DuplicateDetector's shingle
25
+ # comment for why short token streams are more sensitive to this.
26
+ QUERY_SIMILARITY_THRESHOLD = 0.7
27
+ CACHE_SIMILARITY_THRESHOLD = 0.7
28
+
29
+ Result = Struct.new(:security_findings, :performance_findings, :duplicate_groups, :files_scanned, :parse_errors, keyword_init: true)
30
+
31
+ # `skip_rules` silences specific checks by rule_id (e.g. a known false
32
+ # positive on this codebase) without editing/removing the rule itself —
33
+ # accepts strings or symbols, matched against Rule.rule_id.
34
+ def initialize(root:, dirs: DEFAULT_GLOB_DIRS, skip_rules: [])
35
+ @root = File.expand_path(root)
36
+ @dirs = dirs
37
+ @skip_rules = Set.new(skip_rules.map(&:to_s))
38
+ end
39
+
40
+ def call
41
+ files = collect_files
42
+ all_methods = []
43
+ all_queries = []
44
+ all_cache_calls = []
45
+ security_findings = []
46
+ performance_findings = []
47
+ parse_errors = []
48
+
49
+ files.each do |abs_path|
50
+ rel_path = abs_path.sub(/\A#{Regexp.escape(@root)}\/?/, "")
51
+ source = File.read(abs_path)
52
+
53
+ sexp = begin
54
+ Ripper.sexp(source)
55
+ rescue StandardError => e
56
+ parse_errors << { file: rel_path, error: e.message }
57
+ nil
58
+ end
59
+
60
+ if sexp.nil?
61
+ parse_errors << { file: rel_path, error: "Ripper could not parse this file (possibly a syntax error, or Ruby syntax newer than this gem's Ruby runtime supports)" } unless parse_errors.any? { |pe| pe[:file] == rel_path }
62
+ next
63
+ end
64
+
65
+ RuleSet.all.each do |rule_class|
66
+ next if @skip_rules.include?(rule_class.rule_id.to_s)
67
+
68
+ bucket =
69
+ case rule_class.category
70
+ when "security" then security_findings
71
+ when "performance" then performance_findings
72
+ end
73
+ next unless bucket
74
+
75
+ bucket.concat(rule_class.new(file: rel_path, source: source, sexp: sexp).scan)
76
+ end
77
+
78
+ if duplicate_detection_target?(rel_path)
79
+ all_methods.concat(MethodExtractor.extract(file: rel_path, source: source, sexp: sexp))
80
+ all_queries.concat(QueryExtractor.extract(file: rel_path, source: source, sexp: sexp))
81
+ all_cache_calls.concat(CacheExtractor.extract(file: rel_path, source: source, sexp: sexp))
82
+ end
83
+ end
84
+
85
+ # Same computed value cached under the same key from multiple call
86
+ # sites is normal (just reusing the cache). Only flag it when the
87
+ # *keys* differ too — that's either a redundant cache entry or a key
88
+ # that drifted out of sync with a copy-pasted sibling.
89
+ cache_groups = DuplicateDetector.call(all_cache_calls, threshold: CACHE_SIMILARITY_THRESHOLD, kind: "cache_duplicate")
90
+ .select { |g| g.members.map(&:cache_key).uniq.size > 1 }
91
+
92
+ duplicate_groups =
93
+ DuplicateDetector.call(all_methods, kind: "method_duplicate") +
94
+ DuplicateDetector.call(all_queries, threshold: QUERY_SIMILARITY_THRESHOLD, kind: "query_duplicate") +
95
+ cache_groups
96
+
97
+ Result.new(
98
+ security_findings: security_findings,
99
+ performance_findings: performance_findings,
100
+ duplicate_groups: duplicate_groups,
101
+ files_scanned: files.size,
102
+ parse_errors: parse_errors
103
+ )
104
+ end
105
+
106
+ private
107
+
108
+ def duplicate_detection_target?(relative_path)
109
+ segments = relative_path.split("/")
110
+ return true if segments.include?("concerns")
111
+
112
+ segments[0] == "app" && DUPLICATE_DETECTION_APP_SUBDIRS.include?(segments[1])
113
+ end
114
+
115
+ def collect_files
116
+ @dirs.flat_map { |dir| Dir.glob(File.join(@root, dir, "**", "*.rb")) }
117
+ .reject do |path|
118
+ # Only check segments of the path *relative to @root* — checking
119
+ # the full absolute path would wrongly exclude a project simply
120
+ # because some ancestor directory outside the project happens to
121
+ # be named e.g. "tmp" or "test".
122
+ relative = path.sub(/\A#{Regexp.escape(@root)}\/?/, "")
123
+ SKIP_DIR_SEGMENTS.any? { |seg| relative.split("/").include?(seg) }
124
+ end
125
+ .sort
126
+ .uniq
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,3 @@
1
+ module Scryer
2
+ VERSION = "0.1.0"
3
+ end
data/lib/scryer.rb ADDED
@@ -0,0 +1,65 @@
1
+ require "scryer/version"
2
+ require "scryer/ast"
3
+ require "scryer/finding"
4
+ require "scryer/rule_set"
5
+ require "scryer/rule"
6
+ require "scryer/method_extractor"
7
+ require "scryer/query_extractor"
8
+ require "scryer/cache_extractor"
9
+ require "scryer/duplicate_detector"
10
+ require "scryer/scanner"
11
+ require "scryer/report_renderer"
12
+ require "scryer/dependency_audit"
13
+ require "scryer/ai_client"
14
+ require "scryer/ai_fix_suggester"
15
+
16
+ Dir[File.join(__dir__, "scryer", "rules", "*.rb")].sort.each { |f| require f }
17
+ Dir[File.join(__dir__, "scryer", "performance_rules", "*.rb")].sort.each { |f| require f }
18
+
19
+ module Scryer
20
+ class Configuration
21
+ # `branch`, when set, overrides the git_branch value recorded in the
22
+ # report (instead of the actual checked-out branch from `git rev-parse
23
+ # --abbrev-ref HEAD`) — set this when the branch you want tracked isn't
24
+ # necessarily the one the scan happens to run on (e.g. a CI runner in
25
+ # detached-HEAD state, or you always want releases attributed to "main").
26
+ #
27
+ # `ai_client`, when set, opts into rewriting every finding's
28
+ # `suggested_fix` via an LLM (see README's "AI-assisted fix
29
+ # suggestions") — any object or Proc responding to #call(prompt) (or
30
+ # #complete(prompt)). nil by default: off, no network calls, no
31
+ # provider assumed.
32
+ #
33
+ # `skip_rules` silences specific checks by rule_id (e.g. a known false
34
+ # positive on this codebase) without editing/removing the rule itself —
35
+ # strings or symbols, matched against each Rule's rule_id. Empty by
36
+ # default: every registered rule runs. The `scryer` executable's
37
+ # `--skip RULE_ID` flag adds to this list for a single run rather than
38
+ # replacing it.
39
+ attr_accessor :project_name, :dirs, :branch, :ai_client, :skip_rules
40
+
41
+ def initialize
42
+ @dirs = Scryer::Scanner::DEFAULT_GLOB_DIRS
43
+ @skip_rules = []
44
+ end
45
+ end
46
+
47
+ class << self
48
+ def configure
49
+ yield configuration
50
+ end
51
+
52
+ def configuration
53
+ @configuration ||= Configuration.new
54
+ end
55
+ end
56
+ end
57
+
58
+ # QueryWatcher instruments a *running* app (ActiveRecord query notifications,
59
+ # association reader hooks) rather than the one-shot static scan the rest of
60
+ # this file wires up, and is opt-in (Scryer::QueryWatcher.enable!) — so it
61
+ # isn't required eagerly here to avoid loading active_support/notifications
62
+ # machinery for the (default) case where nobody asked for it. Require it
63
+ # yourself where you call .enable! (see README's "Runtime query watcher").
64
+
65
+ require "scryer/railtie" if defined?(Rails::Railtie)
@@ -0,0 +1,172 @@
1
+ require "json"
2
+ require "fileutils"
3
+
4
+ # Deliberately does NOT depend on the Rails `:environment` task — the
5
+ # scanning engine itself needs no Rails/bundler to run, so this task works
6
+ # equally via `bin/rails scryer:report` inside a Rails app or plain
7
+ # `bundle exec rake -f <this file> scryer:report` outside one. Avoids
8
+ # ActiveSupport methods (`.presence`/`.blank?`) for the same reason — plain
9
+ # Ruby nil-or-empty checks only.
10
+ namespace :scryer do
11
+ desc "Run Scryer and write a report. Args are any mix of json/html/csv (which formats " \
12
+ "to write — default json,html), the token 'deps' (fold a dependency audit — OSV.dev " \
13
+ "vulnerable gems + insecure git/http sources, same checks as scryer:audit_dependencies " \
14
+ "— into this report instead of running it separately), plus at most one path (a " \
15
+ "directory when writing more than one format, or an exact file for a single format). " \
16
+ "Rake splits bracket args on every comma, so pass each token as its own item rather " \
17
+ "than one comma-joined string. e.g. rails scryer:report, rails 'scryer:report[html]', " \
18
+ "rails 'scryer:report[json,doc/security.json]', rails 'scryer:report[json,html]', " \
19
+ "rails 'scryer:report[html,deps]', rails 'scryer:report[csv]'"
20
+ task :report, [:format] do |_, args|
21
+ root = defined?(Rails) ? Rails.root.to_s : Dir.pwd
22
+ dirs = Scryer.configuration.dirs
23
+
24
+ # Ignore the declared :format name and read every positional value Rake
25
+ # was given (args.to_a) — the whole point is accepting a variable number
26
+ # of format/deps tokens plus one path, which a single named param can't do.
27
+ formats, path_arg, include_deps = parse_report_args(args.to_a)
28
+
29
+ skip_rules = Scryer.configuration.skip_rules
30
+ puts "Scryer: skipping #{skip_rules.join(', ')}." if skip_rules.any?
31
+
32
+ result = Scryer::Scanner.new(root: root, dirs: dirs, skip_rules: skip_rules).call
33
+
34
+ dependency_findings = []
35
+ if include_deps
36
+ puts "Scryer: checking Gemfile.lock sources (offline)..."
37
+ puts "Scryer: querying OSV.dev for known vulnerabilities (needs network)..."
38
+ dependency_findings = Scryer::DependencyAudit.insecure_sources(root) + Scryer::DependencyAudit.vulnerable_gems(root)
39
+ end
40
+
41
+ if Scryer.configuration.ai_client
42
+ puts "Scryer: rewriting suggested fixes via the configured AI client..."
43
+ Scryer::AiFixSuggester.enhance_result!(result)
44
+ Scryer::AiFixSuggester.enhance_many!(dependency_findings) unless dependency_findings.empty?
45
+ end
46
+
47
+ renderer = Scryer::ReportRenderer.new(
48
+ result: result,
49
+ project_name: blank_to_nil(Scryer.configuration.project_name) || File.basename(root),
50
+ release_label: ScryerTasks.git_release_label,
51
+ git_commit_sha: ScryerTasks.git_commit_sha,
52
+ git_branch: blank_to_nil(Scryer.configuration.branch) || ScryerTasks.git_branch,
53
+ dependency_findings: dependency_findings
54
+ )
55
+
56
+ paths = output_paths(root: root, formats: formats, path_arg: path_arg)
57
+
58
+ paths.each do |format, path|
59
+ FileUtils.mkdir_p(File.dirname(path))
60
+ content = case format
61
+ when "json" then renderer.as_json
62
+ when "csv" then renderer.as_csv
63
+ else renderer.as_html
64
+ end
65
+ File.write(path, content)
66
+ end
67
+
68
+ puts "Scryer: #{result.files_scanned} files scanned, " \
69
+ "#{result.security_findings.size} security findings, " \
70
+ "#{result.performance_findings.size} performance findings, " \
71
+ "#{result.duplicate_groups.size} duplicate groups" \
72
+ "#{include_deps ? ", #{dependency_findings.size} dependency findings" : ""}."
73
+ puts "Report written to #{paths.values.join(', ')}"
74
+ end
75
+
76
+ desc "Check Gemfile.lock for known-vulnerable gem versions (via OSV.dev — needs network) " \
77
+ "and insecure git/http gem sources (offline). Exits non-zero if anything is found, so " \
78
+ "this can gate CI the same way `bundle-audit check` does."
79
+ task :audit_dependencies do
80
+ root = defined?(Rails) ? Rails.root.to_s : Dir.pwd
81
+
82
+ puts "Scryer: checking Gemfile.lock sources (offline)..."
83
+ insecure = Scryer::DependencyAudit.insecure_sources(root)
84
+
85
+ puts "Scryer: querying OSV.dev for known vulnerabilities (needs network)..."
86
+ vulnerable = Scryer::DependencyAudit.vulnerable_gems(root)
87
+
88
+ (insecure + vulnerable).each do |f|
89
+ label = f.kind == "insecure_source" ? "[#{f.severity.upcase}] #{f.message}" : "[#{f.severity.upcase}] #{f.gem_name} #{f.installed_version} - #{f.advisory_id}: #{f.title}"
90
+ puts label
91
+ puts " fix: #{f.suggested_fix}"
92
+ end
93
+
94
+ total = insecure.size + vulnerable.size
95
+ puts "\nScryer: #{total} dependency finding(s) (#{insecure.size} insecure source, #{vulnerable.size} vulnerable gem)."
96
+ abort("Scryer: dependency audit failed.") if total.positive?
97
+ end
98
+
99
+ VALID_FORMATS = %w[json html csv].freeze
100
+ EXTENSION_FOR_FORMAT = { "json" => "json", "html" => "html", "csv" => "csv" }.freeze
101
+ DEPS_TOKEN = "deps".freeze
102
+
103
+ # tokens is every bracket arg Rake was given, e.g. %w[json doc/security.json] or
104
+ # %w[json html] or %w[html deps] or []. Returns [formats, path_arg,
105
+ # include_deps] — any token matching a known format is a format, the
106
+ # literal "deps" token opts into folding a dependency audit into the
107
+ # report, and at most one other token is allowed, which is the path.
108
+ def parse_report_args(tokens)
109
+ tokens = tokens.map { |t| blank_to_nil(t) }.compact
110
+ include_deps = tokens.any? { |t| t.downcase == DEPS_TOKEN }
111
+ tokens = tokens.reject { |t| t.downcase == DEPS_TOKEN }
112
+
113
+ format_tokens, other_tokens = tokens.partition { |t| VALID_FORMATS.include?(t.downcase) }
114
+
115
+ if other_tokens.size > 1
116
+ abort "Scryer: only one path is allowed — got #{other_tokens.join(', ')}."
117
+ end
118
+
119
+ formats = format_tokens.map(&:downcase).uniq
120
+ formats = %w[json html] if formats.empty?
121
+
122
+ [formats, other_tokens.first, include_deps]
123
+ end
124
+
125
+ # No path given: default filenames under tmp/. A path given with a single
126
+ # format whose extension matches that format is treated as an exact file
127
+ # target; otherwise the path is treated as a directory (created if needed)
128
+ # and each format gets its default filename inside it.
129
+ def output_paths(root:, formats:, path_arg:)
130
+ default_names = formats.each_with_object({}) { |f, h| h[f] = "scryer_report.#{EXTENSION_FOR_FORMAT[f]}" }
131
+ return default_names.transform_values { |name| File.join(root, "tmp", name) } if path_arg.nil?
132
+
133
+ resolved = File.absolute_path?(path_arg) ? path_arg : File.join(root, path_arg)
134
+ ext = File.extname(resolved).delete_prefix(".").downcase
135
+
136
+ if formats.size == 1 && ext == formats.first
137
+ { formats.first => resolved }
138
+ else
139
+ formats.each_with_object({}) { |f, h| h[f] = File.join(resolved, default_names[f]) }
140
+ end
141
+ end
142
+
143
+ def blank_to_nil(value)
144
+ return nil if value.nil?
145
+
146
+ str = value.to_s.strip
147
+ str.empty? ? nil : str
148
+ end
149
+ end
150
+
151
+ module ScryerTasks
152
+ module_function
153
+
154
+ def git_commit_sha
155
+ shell_out("git rev-parse HEAD")
156
+ end
157
+
158
+ def git_branch
159
+ shell_out("git rev-parse --abbrev-ref HEAD")
160
+ end
161
+
162
+ def git_release_label
163
+ shell_out("git describe --tags --always")
164
+ end
165
+
166
+ def shell_out(cmd)
167
+ output = `#{cmd} 2>/dev/null`.strip
168
+ output.empty? ? nil : output
169
+ rescue StandardError
170
+ nil
171
+ end
172
+ end
metadata ADDED
@@ -0,0 +1,106 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: scryer
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ram Laxman Yadav
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-08-10 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rake
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '13.0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '13.0'
27
+ description: |
28
+ Scans a Rails app's own source with Ruby's stdlib Ripper parser (no Rails/bundler needed to
29
+ run the scan itself) and reports: security findings (SQL injection, mass assignment, command
30
+ injection, hardcoded secrets, unsafe deserialization, XSS-prone unescaped HTML, CSRF gaps,
31
+ weak crypto, open redirects), near-duplicate code (token-normalized similarity across
32
+ methods), and performance heuristics (N+1 queries, missing pagination, inefficient per-record
33
+ save loops, unbounded full-table iteration). Every finding includes a human-reviewable
34
+ suggested fix — nothing is auto-applied. Writes a detailed report as JSON and/or
35
+ self-contained HTML (tmp/scryer_report.{json,html}). Ships a `scryer` executable for
36
+ running outside a Rails app too, e.g. `scryer -o report.json -o report.html`. Also includes a
37
+ runtime query watcher (N+1 / unused-eager-load detection via ActiveRecord instrumentation,
38
+ opt-in) and a dependency vulnerability + insecure-source audit against OSV.dev.
39
+ email:
40
+ executables:
41
+ - scryer
42
+ extensions: []
43
+ extra_rdoc_files: []
44
+ files:
45
+ - README.md
46
+ - exe/scryer
47
+ - lib/generators/scryer/USAGE
48
+ - lib/generators/scryer/install_generator.rb
49
+ - lib/generators/scryer/templates/scryer_initializer.rb
50
+ - lib/scryer.rb
51
+ - lib/scryer/ai_client.rb
52
+ - lib/scryer/ai_fix_suggester.rb
53
+ - lib/scryer/ast.rb
54
+ - lib/scryer/cache_extractor.rb
55
+ - lib/scryer/cli.rb
56
+ - lib/scryer/dependency_audit.rb
57
+ - lib/scryer/duplicate_detector.rb
58
+ - lib/scryer/finding.rb
59
+ - lib/scryer/method_extractor.rb
60
+ - lib/scryer/performance_rules/inefficient_save_loop_rule.rb
61
+ - lib/scryer/performance_rules/missing_pagination_rule.rb
62
+ - lib/scryer/performance_rules/n_plus_one_query_rule.rb
63
+ - lib/scryer/performance_rules/unbounded_table_scan_rule.rb
64
+ - lib/scryer/query_extractor.rb
65
+ - lib/scryer/query_watcher.rb
66
+ - lib/scryer/railtie.rb
67
+ - lib/scryer/report_renderer.rb
68
+ - lib/scryer/rule.rb
69
+ - lib/scryer/rule_set.rb
70
+ - lib/scryer/rules/command_injection_rule.rb
71
+ - lib/scryer/rules/csrf_protection_rule.rb
72
+ - lib/scryer/rules/hardcoded_secret_rule.rb
73
+ - lib/scryer/rules/mass_assignment_rule.rb
74
+ - lib/scryer/rules/open_redirect_rule.rb
75
+ - lib/scryer/rules/sql_injection_rule.rb
76
+ - lib/scryer/rules/unsafe_deserialization_rule.rb
77
+ - lib/scryer/rules/weak_crypto_rule.rb
78
+ - lib/scryer/rules/xss_unsafe_html_rule.rb
79
+ - lib/scryer/scanner.rb
80
+ - lib/scryer/version.rb
81
+ - lib/tasks/scryer.rake
82
+ homepage: https://github.com/ramlaxmanyadav/scryer
83
+ licenses:
84
+ - MIT
85
+ metadata: {}
86
+ post_install_message:
87
+ rdoc_options: []
88
+ require_paths:
89
+ - lib
90
+ required_ruby_version: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - ">="
93
+ - !ruby/object:Gem::Version
94
+ version: 2.7.0
95
+ required_rubygems_version: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - ">="
98
+ - !ruby/object:Gem::Version
99
+ version: '0'
100
+ requirements: []
101
+ rubygems_version: 3.5.11
102
+ signing_key:
103
+ specification_version: 4
104
+ summary: 'Static code analysis for Rails apps: security vulnerabilities, duplicate
105
+ code, and performance heuristics.'
106
+ test_files: []