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.
- checksums.yaml +7 -0
- data/README.md +367 -0
- data/exe/scryer +7 -0
- data/lib/generators/scryer/USAGE +66 -0
- data/lib/generators/scryer/install_generator.rb +29 -0
- data/lib/generators/scryer/templates/scryer_initializer.rb +28 -0
- data/lib/scryer/ai_client.rb +53 -0
- data/lib/scryer/ai_fix_suggester.rb +138 -0
- data/lib/scryer/ast.rb +277 -0
- data/lib/scryer/cache_extractor.rb +124 -0
- data/lib/scryer/cli.rb +193 -0
- data/lib/scryer/dependency_audit.rb +225 -0
- data/lib/scryer/duplicate_detector.rb +103 -0
- data/lib/scryer/finding.rb +21 -0
- data/lib/scryer/method_extractor.rb +55 -0
- data/lib/scryer/performance_rules/inefficient_save_loop_rule.rb +108 -0
- data/lib/scryer/performance_rules/missing_pagination_rule.rb +132 -0
- data/lib/scryer/performance_rules/n_plus_one_query_rule.rb +221 -0
- data/lib/scryer/performance_rules/unbounded_table_scan_rule.rb +78 -0
- data/lib/scryer/query_extractor.rb +123 -0
- data/lib/scryer/query_watcher.rb +250 -0
- data/lib/scryer/railtie.rb +12 -0
- data/lib/scryer/report_renderer.rb +546 -0
- data/lib/scryer/rule.rb +43 -0
- data/lib/scryer/rule_set.rb +19 -0
- data/lib/scryer/rules/command_injection_rule.rb +61 -0
- data/lib/scryer/rules/csrf_protection_rule.rb +89 -0
- data/lib/scryer/rules/hardcoded_secret_rule.rb +96 -0
- data/lib/scryer/rules/mass_assignment_rule.rb +103 -0
- data/lib/scryer/rules/open_redirect_rule.rb +57 -0
- data/lib/scryer/rules/sql_injection_rule.rb +63 -0
- data/lib/scryer/rules/unsafe_deserialization_rule.rb +71 -0
- data/lib/scryer/rules/weak_crypto_rule.rb +66 -0
- data/lib/scryer/rules/xss_unsafe_html_rule.rb +70 -0
- data/lib/scryer/scanner.rb +129 -0
- data/lib/scryer/version.rb +3 -0
- data/lib/scryer.rb +65 -0
- data/lib/tasks/scryer.rake +172 -0
- 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
|