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
data/lib/scryer/cli.rb ADDED
@@ -0,0 +1,193 @@
1
+ require "optparse"
2
+ require "fileutils"
3
+ require "shellwords"
4
+
5
+ module Scryer
6
+ # Backs the `scryer` executable (see exe/scryer) — a standalone,
7
+ # Rails-free way to run a scan, mirroring `brakeman -o report.json`.
8
+ # Deliberately separate from lib/scryer.rb: OptionParser/Shellwords are
9
+ # only needed for this CLI entry point, not when the gem is required
10
+ # inside a host app.
11
+ class CLI
12
+ EXTENSION_FORMATS = { ".json" => "json", ".html" => "html", ".htm" => "html", ".csv" => "csv" }.freeze
13
+
14
+ def initialize(argv, stdout: $stdout, stderr: $stderr)
15
+ @argv = argv
16
+ @stdout = stdout
17
+ @stderr = stderr
18
+ end
19
+
20
+ # Returns a process exit code: 0 if clean, 1 if security findings were
21
+ # found (so `scryer -o report.json` can gate CI the way `brakeman -o
22
+ # report.json` does), 2 on a usage error.
23
+ def run
24
+ options = parse(@argv)
25
+ return 0 if options[:exit_early]
26
+ return check_gem(options[:check_gem]) if options[:check_gem]
27
+
28
+ root = File.expand_path(options[:path] || Dir.pwd)
29
+ return audit_deps(root) if options[:audit_deps]
30
+
31
+ skip_rules = Scryer.configuration.skip_rules + (options[:skip] || [])
32
+ @stdout.puts "Scryer: skipping #{skip_rules.join(', ')}." if skip_rules.any?
33
+
34
+ result = Scanner.new(root: root, dirs: Scryer.configuration.dirs, skip_rules: skip_rules).call
35
+
36
+ dependency_findings = []
37
+ if options[:include_deps]
38
+ @stdout.puts "Scryer: checking Gemfile.lock sources (offline)..."
39
+ @stdout.puts "Scryer: querying OSV.dev for known vulnerabilities (needs network)..."
40
+ dependency_findings = DependencyAudit.insecure_sources(root) + DependencyAudit.vulnerable_gems(root)
41
+ end
42
+
43
+ if Scryer.configuration.ai_client
44
+ @stdout.puts "Scryer: rewriting suggested fixes via the configured AI client..."
45
+ AiFixSuggester.enhance_result!(result)
46
+ AiFixSuggester.enhance_many!(dependency_findings) unless dependency_findings.empty?
47
+ end
48
+
49
+ renderer = ReportRenderer.new(
50
+ result: result,
51
+ project_name: options[:project_name] || File.basename(root),
52
+ release_label: git(root, "describe --tags --always"),
53
+ git_commit_sha: git(root, "rev-parse HEAD"),
54
+ git_branch: options[:branch] || git(root, "rev-parse --abbrev-ref HEAD"),
55
+ dependency_findings: dependency_findings
56
+ )
57
+
58
+ outputs = options[:outputs].empty? ? default_outputs(root) : options[:outputs]
59
+ outputs.each { |path| write_report(renderer, path) }
60
+
61
+ @stdout.puts "Scryer: #{result.files_scanned} files scanned, " \
62
+ "#{result.security_findings.size} security findings, " \
63
+ "#{result.performance_findings.size} performance findings, " \
64
+ "#{result.duplicate_groups.size} duplicate groups" \
65
+ "#{options[:include_deps] ? ", #{dependency_findings.size} dependency findings" : ""}."
66
+ @stdout.puts "Report written to #{outputs.join(', ')}"
67
+
68
+ result.security_findings.empty? && dependency_findings.empty? ? 0 : 1
69
+ rescue UsageError => e
70
+ @stderr.puts "scryer: #{e.message}"
71
+ 2
72
+ end
73
+
74
+ private
75
+
76
+ UsageError = Class.new(StandardError)
77
+
78
+ # Mirrors the `scryer:audit_dependencies` rake task's output/exit-code
79
+ # behavior, so `scryer --audit-deps` works the same outside a Rails app.
80
+ def audit_deps(root)
81
+ @stdout.puts "Scryer: checking Gemfile.lock sources (offline)..."
82
+ insecure = DependencyAudit.insecure_sources(root)
83
+
84
+ @stdout.puts "Scryer: querying OSV.dev for known vulnerabilities (needs network)..."
85
+ vulnerable = DependencyAudit.vulnerable_gems(root)
86
+
87
+ (insecure + vulnerable).each do |f|
88
+ label = f.kind == "insecure_source" ? "[#{f.severity.upcase}] #{f.message}" : "[#{f.severity.upcase}] #{f.gem_name} #{f.installed_version} - #{f.advisory_id}: #{f.title}"
89
+ @stdout.puts label
90
+ @stdout.puts " fix: #{f.suggested_fix}"
91
+ end
92
+
93
+ total = insecure.size + vulnerable.size
94
+ @stdout.puts "\nScryer: #{total} dependency finding(s) (#{insecure.size} insecure source, #{vulnerable.size} vulnerable gem)."
95
+ total.positive? ? 1 : 0
96
+ end
97
+
98
+ # One-off OSV.dev lookup for a single gem — no Gemfile.lock, no scan,
99
+ # no other network calls. `spec` is "name" or "name:version" (colon
100
+ # rather than a second CLI arg, so this stays a single -o-style flag).
101
+ def check_gem(spec)
102
+ name, version = spec.split(":", 2)
103
+ label = version ? "#{name} #{version}" : "#{name} (all versions)"
104
+
105
+ @stdout.puts "Scryer: querying OSV.dev for #{label}..."
106
+ findings = DependencyAudit.check_gem(name, version)
107
+
108
+ findings.each do |f|
109
+ @stdout.puts "[#{f.severity.upcase}] #{f.advisory_id}: #{f.title}"
110
+ @stdout.puts " fix: #{f.suggested_fix}"
111
+ end
112
+
113
+ @stdout.puts "\nScryer: #{findings.size} advisory(-ies) found for #{label}."
114
+ findings.empty? ? 0 : 1
115
+ end
116
+
117
+ def parse(argv)
118
+ options = { outputs: [] }
119
+
120
+ parser = OptionParser.new do |opts|
121
+ opts.banner = "Usage: scryer [options]"
122
+ opts.on("-o PATH", "--output PATH",
123
+ "Write a report to PATH (repeatable). Format is inferred from the " \
124
+ "extension: .json, .html, or .csv.") { |v| options[:outputs] << v }
125
+ opts.on("-p PATH", "--path PATH", "Root directory to scan (default: current directory).") { |v| options[:path] = v }
126
+ opts.on("--audit-deps",
127
+ "Check Gemfile.lock for known-vulnerable gems (via OSV.dev — needs network) and " \
128
+ "insecure git/http sources (offline), instead of running the normal static scan. " \
129
+ "Exits non-zero if anything is found, so this can gate CI the same way " \
130
+ "`bundle-audit check` does.") { options[:audit_deps] = true }
131
+ opts.on("--include-deps",
132
+ "Fold a dependency audit (same checks as --audit-deps: OSV.dev vulnerable gems + " \
133
+ "insecure git/http sources) into the normal report as one more section, instead " \
134
+ "of running it as a separate command. Combine with -o to get one HTML/JSON " \
135
+ "report covering static findings, duplicate code, and dependency findings " \
136
+ "together — and, with an AI client configured, AI-rewritten suggested fixes for " \
137
+ "all of them.") { options[:include_deps] = true }
138
+ opts.on("--skip RULE_ID",
139
+ "Skip a rule by rule_id (repeatable) — e.g. a known false positive on this " \
140
+ "codebase. Adds to c.skip_rules for this run only; doesn't affect other " \
141
+ "invocations.") { |v| (options[:skip] ||= []) << v }
142
+ opts.on("--check-gem NAME[:VERSION]",
143
+ "Query OSV.dev for known vulnerabilities affecting a single gem, independent of " \
144
+ "any Gemfile.lock or full scan/audit — instead of running the normal static " \
145
+ "scan. Omit :VERSION to see every advisory ever filed against the gem across " \
146
+ "all versions.") { |v| options[:check_gem] = v }
147
+ opts.on("--project-name NAME", "Project name shown in the report header.") { |v| options[:project_name] = v }
148
+ opts.on("--branch BRANCH", "Git branch label recorded in the report (overrides the actual checked-out branch).") { |v| options[:branch] = v }
149
+ opts.on("-v", "--version", "Show the Scryer version.") { options[:exit_early] = true; @stdout.puts Scryer::VERSION }
150
+ opts.on("-h", "--help", "Show this help.") { options[:exit_early] = true; @stdout.puts opts }
151
+ end
152
+
153
+ begin
154
+ parser.parse!(argv)
155
+ rescue OptionParser::ParseError => e
156
+ raise UsageError, "#{e.message}\n#{parser}"
157
+ end
158
+
159
+ options
160
+ end
161
+
162
+ def write_report(renderer, path)
163
+ format = format_for(path)
164
+ content = case format
165
+ when "json" then renderer.as_json
166
+ when "csv" then renderer.as_csv
167
+ else renderer.as_html
168
+ end
169
+
170
+ FileUtils.mkdir_p(File.dirname(File.expand_path(path)))
171
+ File.write(path, content)
172
+ end
173
+
174
+ def format_for(path)
175
+ ext = File.extname(path).downcase
176
+ EXTENSION_FORMATS[ext] ||
177
+ raise(UsageError, "don't know how to write #{path} — recognized extensions are " \
178
+ "#{EXTENSION_FORMATS.keys.join(', ')}.")
179
+ end
180
+
181
+ def default_outputs(root)
182
+ dir = File.join(root, "tmp")
183
+ [File.join(dir, "scryer_report.json"), File.join(dir, "scryer_report.html")]
184
+ end
185
+
186
+ def git(root, cmd)
187
+ output = `git -C #{Shellwords.escape(root)} #{cmd} 2>/dev/null`.strip
188
+ output.empty? ? nil : output
189
+ rescue StandardError
190
+ nil
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,225 @@
1
+ require "json"
2
+
3
+ module Scryer
4
+ # Dependency vulnerability + supply-chain-hygiene checks for Gemfile.lock —
5
+ # the same broad goal as bundler-audit (github.com/rubysec/bundler-audit),
6
+ # built independently on a different data source and file parser: rather
7
+ # than bundler-audit's local clone of the ruby-advisory-db git repo, this
8
+ # queries OSV.dev's public API (osv.dev — Google's Open Source
9
+ # Vulnerabilities database, covering RubyGems among other ecosystems) live,
10
+ # per gem+version, and parses Gemfile.lock with a small hand-rolled reader
11
+ # (see .parse_lockfile) rather than depending on the `bundler` library, so
12
+ # the check works the same whether or not this run happens to be under
13
+ # Bundler. No bundler-audit source was read or copied to build this — see
14
+ # the README's "Dependency audit" section for the conceptual write-up this
15
+ # was built from.
16
+ #
17
+ # Two independent checks, callable separately:
18
+ # - .insecure_sources(root) — offline, parses Gemfile.lock's GIT/PATH
19
+ # blocks for unencrypted (`git://`, `http://`) remotes.
20
+ # - .vulnerable_gems(root) — needs network: looks up every
21
+ # RubyGems-sourced gem (not git/path — see why below) in OSV.dev.
22
+ #
23
+ # Unlike the static Ripper scan, this needs a live network connection for
24
+ # the vulnerability lookup — so it's not part of the default `scryer`/
25
+ # `scryer:report` run; call it explicitly (`scryer:audit_dependencies`,
26
+ # or `scryer --audit-deps`) when you want it, same as bundler-audit is a
27
+ # separate command from your test suite.
28
+ class DependencyAudit
29
+ Finding = Struct.new(
30
+ :kind, # "vulnerable_dependency" | "insecure_source"
31
+ :gem_name,
32
+ :installed_version,
33
+ :severity, # "critical" | "warning" | "info"
34
+ :advisory_id,
35
+ :title,
36
+ :url,
37
+ :patched_versions,
38
+ :message,
39
+ :suggested_fix,
40
+ keyword_init: true
41
+ ) do
42
+ def to_h
43
+ super.transform_keys(&:to_s)
44
+ end
45
+ end
46
+
47
+ OSV_QUERY_URL = "https://api.osv.dev/v1/query".freeze
48
+ ECOSYSTEM = "RubyGems".freeze
49
+
50
+ SEVERITY_BY_OSV_LEVEL = {
51
+ "CRITICAL" => "critical",
52
+ "HIGH" => "critical",
53
+ "MODERATE" => "warning",
54
+ "MEDIUM" => "warning",
55
+ "LOW" => "info"
56
+ }.freeze
57
+
58
+ class << self
59
+ # Parses a Gemfile.lock into `{ gems: { name => {version:, source:} }, git_or_path_sources: [...] }`.
60
+ # `source` is "gem", "git", or "path" — taken from which top-level
61
+ # block (GEM/GIT/PATH) the spec's `specs:` list appeared under.
62
+ def parse_lockfile(path)
63
+ gems = {}
64
+ git_or_path_sources = []
65
+
66
+ current_block = nil # "gem" | "git" | "path" | other section name
67
+ current_remote = nil
68
+ in_specs = false
69
+
70
+ File.foreach(path) do |line|
71
+ case line
72
+ when /\A(GEM|GIT|PATH)\s*\z/
73
+ current_block = Regexp.last_match(1).downcase
74
+ current_remote = nil
75
+ in_specs = false
76
+ when /\A(PLATFORMS|DEPENDENCIES|BUNDLED WITH|RUBY VERSION)\s*\z/
77
+ current_block = nil
78
+ in_specs = false
79
+ when /\A {2}remote:\s*(\S+)\s*\z/
80
+ current_remote = Regexp.last_match(1)
81
+ git_or_path_sources << { type: current_block, remote: current_remote } if %w[git path].include?(current_block)
82
+ when /\A {2}specs:\s*\z/
83
+ in_specs = true
84
+ when /\A {4}([A-Za-z0-9_.\-]+)\s+\(([^)]+)\)\s*\z/
85
+ next unless in_specs
86
+
87
+ name = Regexp.last_match(1)
88
+ version = Regexp.last_match(2)
89
+ # A gem can legitimately appear under more than one block only
90
+ # in pathological Gemfiles; last one wins, consistent with how
91
+ # Bundler itself resolves a single spec per gem name.
92
+ gems[name] = { version: version, source: current_block }
93
+ end
94
+ end
95
+
96
+ { gems: gems, git_or_path_sources: git_or_path_sources }
97
+ end
98
+
99
+ # Offline. Flags GIT/PATH sources recorded with an unencrypted remote
100
+ # (`git://` or plain `http://`) — the same supply-chain concern
101
+ # bundler-audit's insecure-source check targets, checked here by
102
+ # reading Gemfile.lock's own GIT/PATH blocks instead of the Gemfile.
103
+ def insecure_sources(root)
104
+ lockfile = File.join(root, "Gemfile.lock")
105
+ return [] unless File.exist?(lockfile)
106
+
107
+ parsed = parse_lockfile(lockfile)
108
+ parsed[:git_or_path_sources].filter_map do |src|
109
+ next unless src[:remote] =~ %r{\A(git|http)://}
110
+
111
+ Finding.new(
112
+ kind: "insecure_source",
113
+ gem_name: nil,
114
+ severity: "warning",
115
+ message: "Gemfile.lock has a #{src[:type]} source over an unencrypted transport: #{src[:remote]}",
116
+ suggested_fix: "Point this source at an https:// URL instead — an unencrypted git:// or " \
117
+ "http:// remote can be tampered with in transit (classic supply-chain risk)."
118
+ )
119
+ end
120
+ end
121
+
122
+ # Needs network. One OSV.dev query per RubyGems-sourced gem in the
123
+ # lockfile (git/path-sourced gems are skipped — their version string
124
+ # doesn't necessarily correspond to the same code as the published gem
125
+ # of that name, so checking them against RubyGems advisories could
126
+ # misattribute or miss vulnerabilities). `http_client` is injectable
127
+ # for testing; defaults to a real Net::HTTP call. A real lockfile can
128
+ # easily have 200-300+ gems, and this is a network call per gem, so
129
+ # lookups run across a small stdlib-only thread pool (`concurrency`)
130
+ # rather than one gem at a time.
131
+ def vulnerable_gems(root, http_client: method(:query_osv), concurrency: 8)
132
+ lockfile = File.join(root, "Gemfile.lock")
133
+ return [] unless File.exist?(lockfile)
134
+
135
+ parsed = parse_lockfile(lockfile)
136
+ targets = parsed[:gems].select { |_, info| info[:source] == "gem" }.to_a
137
+
138
+ queue = Queue.new
139
+ targets.each { |pair| queue << pair }
140
+ results = Queue.new
141
+
142
+ workers = Array.new([concurrency, targets.size].min) do
143
+ Thread.new do
144
+ loop do
145
+ name, info = begin
146
+ queue.pop(true)
147
+ rescue ThreadError
148
+ nil
149
+ end
150
+ break unless name
151
+
152
+ http_client.call(name, info[:version]).each { |v| results << finding_for(name, info[:version], v) }
153
+ end
154
+ end
155
+ end
156
+ workers.each(&:join)
157
+
158
+ findings = []
159
+ findings << results.pop(true) until results.empty?
160
+ findings
161
+ end
162
+
163
+ # Needs network. One-off OSV.dev lookup for a single gem, independent
164
+ # of any Gemfile.lock — backs `scryer --check-gem NAME[:VERSION]`.
165
+ # With a version, only vulnerabilities affecting that exact version
166
+ # are returned (same filtering as vulnerable_gems); omit it to see
167
+ # every advisory ever filed against the gem, across all versions.
168
+ def check_gem(name, version = nil)
169
+ query_osv(name, version).map { |v| finding_for(name, version, v) }
170
+ end
171
+
172
+ private
173
+
174
+ def query_osv(name, version = nil)
175
+ require "net/http"
176
+ require "uri"
177
+
178
+ uri = URI(OSV_QUERY_URL)
179
+ package_query = { package: { name: name, ecosystem: ECOSYSTEM } }
180
+ package_query[:version] = version if version
181
+ body = JSON.generate(package_query)
182
+
183
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 5, read_timeout: 10) do |http|
184
+ http.post(uri.path, body, "Content-Type" => "application/json")
185
+ end
186
+
187
+ return [] unless response.is_a?(Net::HTTPSuccess)
188
+
189
+ (JSON.parse(response.body)["vulns"] || [])
190
+ rescue StandardError
191
+ # A single gem's lookup failing (network hiccup, rate limit) shouldn't
192
+ # abort the whole audit — it just means that one gem wasn't checked.
193
+ []
194
+ end
195
+
196
+ def finding_for(gem_name, installed_version, vuln)
197
+ level = vuln.dig("database_specific", "severity")
198
+ fixed_versions = fixed_versions_for(vuln, gem_name)
199
+
200
+ Finding.new(
201
+ kind: "vulnerable_dependency",
202
+ gem_name: gem_name,
203
+ installed_version: installed_version,
204
+ severity: SEVERITY_BY_OSV_LEVEL[level.to_s.upcase] || "warning",
205
+ advisory_id: vuln["id"],
206
+ title: vuln["summary"] || vuln["id"],
207
+ url: (vuln["references"] || []).map { |r| r["url"] }.find { |u| u&.start_with?("http") },
208
+ patched_versions: fixed_versions,
209
+ message: "#{gem_name}#{installed_version ? " #{installed_version}" : ""} is affected by " \
210
+ "#{vuln["id"]}#{vuln["summary"] ? ": #{vuln["summary"]}" : ""}",
211
+ suggested_fix: fixed_versions.empty? ? "No fixed version is published yet for #{vuln["id"]} — track the advisory for updates." : "Upgrade #{gem_name} to #{fixed_versions.join(" or ")} (or later)."
212
+ )
213
+ end
214
+
215
+ def fixed_versions_for(vuln, gem_name)
216
+ (vuln["affected"] || [])
217
+ .select { |a| a.dig("package", "name") == gem_name }
218
+ .flat_map { |a| a["ranges"] || [] }
219
+ .flat_map { |r| r["events"] || [] }
220
+ .filter_map { |e| e["fixed"] }
221
+ .uniq
222
+ end
223
+ end
224
+ end
225
+ end
@@ -0,0 +1,103 @@
1
+ require "set"
2
+
3
+ module Scryer
4
+ # Token-normalized near-duplicate detection across methods (see
5
+ # MethodExtractor for how a method's token stream is built and normalized —
6
+ # identifiers/literals become placeholders, keywords/operators stay literal,
7
+ # so a copy-pasted method with renamed variables still looks "the same
8
+ # shape" while structurally different code doesn't).
9
+ #
10
+ # Approach: build a shingle set (sliding-window n-grams of the normalized
11
+ # token stream) per method, then compare methods pairwise via Jaccard
12
+ # similarity of their shingle sets, bucketing by token-count first so we
13
+ # don't bother comparing a 15-token method against a 200-token one. This is
14
+ # simpler than MinHash and fully correct (no approximation error) — fine
15
+ # for the method counts a typical Rails app has; a MinHash-based
16
+ # approximation would only be worth the complexity at codebases far larger
17
+ # than this tool is likely to run against in one pass.
18
+ class DuplicateDetector
19
+ # A smaller shingle size is less disrupted by a single inserted/removed
20
+ # token (common in near-duplicates that were copy-pasted then tweaked) —
21
+ # each inserted token only breaks SHINGLE_SIZE consecutive shingles
22
+ # rather than a larger fraction of the total set, at some cost in
23
+ # precision (shorter shingles are individually less distinctive).
24
+ SHINGLE_SIZE = 3
25
+ SIMILARITY_THRESHOLD = 0.6
26
+ SIZE_BUCKET_RATIO = 0.4 # only compare methods whose token counts are within +/-40% of each other
27
+
28
+ # `kind` just gets stamped onto every group this run produces — lets
29
+ # Scanner run this same algorithm separately over methods, query chains,
30
+ # and cached values, and have each result self-identify in the report
31
+ # (see QueryExtractor/CacheExtractor, whose output feeds this same class).
32
+ DuplicateGroup = Struct.new(:kind, :similarity, :members, keyword_init: true)
33
+
34
+ def self.call(methods, threshold: SIMILARITY_THRESHOLD, kind: "method_duplicate")
35
+ new(methods, threshold: threshold, kind: kind).call
36
+ end
37
+
38
+ def initialize(methods, threshold: SIMILARITY_THRESHOLD, kind: "method_duplicate")
39
+ @threshold = threshold
40
+ @kind = kind
41
+ @methods = methods.map { |m| Entry.new(m, shingles(m.token_stream)) }
42
+ end
43
+
44
+ def call
45
+ groups = []
46
+ seen_pairs = 0
47
+
48
+ @methods.combination(2).each do |a, b|
49
+ next unless size_compatible?(a, b)
50
+
51
+ seen_pairs += 1
52
+ sim = jaccard(a.shingles, b.shingles)
53
+ next if sim < @threshold
54
+
55
+ merge_or_add(groups, a, b, sim)
56
+ end
57
+
58
+ groups
59
+ end
60
+
61
+ private
62
+
63
+ Entry = Struct.new(:info, :shingles)
64
+
65
+ def size_compatible?(a, b)
66
+ la = a.info.token_stream.size
67
+ lb = b.info.token_stream.size
68
+ return true if la == lb
69
+
70
+ smaller, larger = [la, lb].sort
71
+ smaller >= larger * SIZE_BUCKET_RATIO
72
+ end
73
+
74
+ def shingles(tokens)
75
+ return Set.new([tokens.join("|")]) if tokens.size < SHINGLE_SIZE
76
+
77
+ tokens.each_cons(SHINGLE_SIZE).map { |window| window.join("|") }.to_set
78
+ end
79
+
80
+ def jaccard(a, b)
81
+ return 0.0 if a.empty? || b.empty?
82
+
83
+ intersection = (a & b).size.to_f
84
+ union = (a | b).size.to_f
85
+ union.zero? ? 0.0 : intersection / union
86
+ end
87
+
88
+ # Union-find-lite: if either method is already in a group, add the other
89
+ # to that group (keeping the group's similarity as the min pairwise
90
+ # similarity seen); otherwise start a new group.
91
+ def merge_or_add(groups, a, b, sim)
92
+ existing = groups.find { |g| g.members.include?(a.info) || g.members.include?(b.info) }
93
+
94
+ if existing
95
+ existing.members << a.info unless existing.members.include?(a.info)
96
+ existing.members << b.info unless existing.members.include?(b.info)
97
+ existing.similarity = [existing.similarity, sim].min
98
+ else
99
+ groups << DuplicateGroup.new(kind: @kind, similarity: sim, members: [a.info, b.info])
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,21 @@
1
+ module Scryer
2
+ # A single reported issue. `suggested_fix` is always a human-reviewable text
3
+ # explanation + example patch, never something auto-applied — see the
4
+ # gem's README for why (a rewritten line that changes behavior needs a
5
+ # human's judgment, especially for security-sensitive code).
6
+ Finding = Struct.new(
7
+ :rule_id, # e.g. "sql_injection"
8
+ :category, # "security" | "performance" | "duplication"
9
+ :severity, # "critical" | "warning" | "info"
10
+ :file, # relative path
11
+ :line, # integer line number (1-indexed) or nil
12
+ :code_snippet, # the offending source line, stripped
13
+ :message, # human-readable description of the issue
14
+ :suggested_fix, # human-readable explanation + example patch
15
+ keyword_init: true
16
+ ) do
17
+ def to_h
18
+ super.transform_keys(&:to_s)
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,55 @@
1
+ require "ripper"
2
+
3
+ module Scryer
4
+ # Extracts each `def`/`def self.x` method from a parsed file as a
5
+ # MethodInfo (name, file, start/end line, source text) by walking the
6
+ # Ripper sexp tree for :def and :defs nodes. Sexp nodes don't carry an
7
+ # explicit end line, so we find it by locating the matching top-level
8
+ # statement boundary: the next sibling's start line minus one, or the
9
+ # bodystmt's last statement's line if there's no next sibling, with a
10
+ # fallback that scans forward for the line containing a bare "end" if
11
+ # neither is available. In practice, we don't need exact end lines for
12
+ # duplicate detection — the token stream (not the source substring) is
13
+ # what actually gets compared, so we lex from the method's body sexp's own
14
+ # descendants (whose positions we DO have precisely) rather than fragile
15
+ # source-line slicing.
16
+ MethodInfo = Struct.new(:name, :file, :start_line, :end_line, :token_stream, :source_snippet, keyword_init: true)
17
+
18
+ module MethodExtractor
19
+ module_function
20
+
21
+ def extract(file:, source:, sexp:)
22
+ methods = []
23
+
24
+ Ast.each_node(sexp) do |node|
25
+ next unless Ast.tagged?(node, :def, :defs)
26
+
27
+ name_node = node[0] == :defs ? node[3] : node[1]
28
+ name = Ast.ident_text(name_node) || "?"
29
+ body_node = node.last
30
+
31
+ positions = Ast.each_node(body_node).filter_map { |n| Ast.position_of(n) }
32
+ next if positions.empty? # empty method body (e.g. `def foo; end`) — nothing to compare
33
+
34
+ start_line = Ast.line_of(name_node) || positions.map(&:first).min
35
+ end_line = positions.map(&:first).max
36
+
37
+ tokens = Ast.normalized_tokens(body_node)
38
+ next if tokens.size < MIN_TOKENS
39
+
40
+ methods << MethodInfo.new(
41
+ name: name,
42
+ file: file,
43
+ start_line: start_line,
44
+ end_line: end_line,
45
+ token_stream: tokens,
46
+ source_snippet: source.lines[(start_line - 1)...[end_line, source.lines.size].min]&.join
47
+ )
48
+ end
49
+
50
+ methods
51
+ end
52
+
53
+ MIN_TOKENS = 12 # skip trivial one-liners — not worth flagging as "duplicated"
54
+ end
55
+ end