Class: Scryer::DependencyAudit

Inherits:
Object
  • Object
show all
Defined in:
lib/scryer/dependency_audit.rb

Overview

Dependency vulnerability + supply-chain-hygiene checks for Gemfile.lock — the same broad goal as bundler-audit (github.com/rubysec/bundler-audit), built independently on a different data source and file parser: rather than bundler-audit's local clone of the ruby-advisory-db git repo, this queries OSV.dev's public API (osv.dev — Google's Open Source Vulnerabilities database, covering RubyGems among other ecosystems) live, per gem+version, and parses Gemfile.lock with a small hand-rolled reader (see .parse_lockfile) rather than depending on the bundler library, so the check works the same whether or not this run happens to be under Bundler. No bundler-audit source was read or copied to build this — see the README's "Dependency audit" section for the conceptual write-up this was built from.

Two independent checks, callable separately:

- .insecure_sources(root)   — offline, parses Gemfile.lock's GIT/PATH
blocks for unencrypted (`git://`, `http://`) remotes.
- .vulnerable_gems(root)    — needs network: looks up every
RubyGems-sourced gem (not git/path — see why below) in OSV.dev.

Unlike the static Ripper scan, this needs a live network connection for the vulnerability lookup — so it's not part of the default scryer/ scryer:report run; call it explicitly (scryer:audit_dependencies, or scryer --audit-deps) when you want it, same as bundler-audit is a separate command from your test suite.

Defined Under Namespace

Classes: Finding

Constant Summary collapse

OSV_QUERY_URL =
"https://api.osv.dev/v1/query".freeze
ECOSYSTEM =
"RubyGems".freeze
SEVERITY_BY_OSV_LEVEL =
{
  "CRITICAL" => "critical",
  "HIGH" => "critical",
  "MODERATE" => "warning",
  "MEDIUM" => "warning",
  "LOW" => "info"
}.freeze
RUBY_EOL_DATES =

Ruby's own published maintenance-branch EOL dates (ruby-lang.org/en/downloads/branches/) — these are announced years in advance and essentially never move, unlike a CVE feed, so this is a one-time/occasional-update cost rather than an ongoing sync burden. Update when a new branch's EOL is announced, or an older branch not listed here needs adding.

{
  "2.5" => Date.new(2021, 3, 31),
  "2.6" => Date.new(2022, 3, 31),
  "2.7" => Date.new(2023, 3, 31),
  "3.0" => Date.new(2024, 3, 31),
  "3.1" => Date.new(2025, 3, 31),
  "3.2" => Date.new(2026, 3, 31),
  "3.3" => Date.new(2027, 3, 31),
  "3.4" => Date.new(2028, 3, 31)
}.freeze

Class Method Summary collapse

Class Method Details

.check_gem(name, version = nil) ⇒ Object

Needs network. One-off OSV.dev lookup for a single gem, independent of any Gemfile.lock — backs scryer --check-gem NAME[:VERSION]. With a version, only vulnerabilities affecting that exact version are returned (same filtering as vulnerable_gems); omit it to see every advisory ever filed against the gem, across all versions.



262
263
264
# File 'lib/scryer/dependency_audit.rb', line 262

def check_gem(name, version = nil)
  query_osv(name, version).map { |v| finding_for(name, version, v) }
end

.credentials_exposure_check(root) ⇒ Object

Offline. Flags config/master.key (the key that decrypts Rails encrypted credentials, config/credentials.yml.enc) if it exists on disk and .gitignore doesn't exclude it — Rails generates it gitignored by default (/config/master.key), but that line is easy to lose (a merge, a from-scratch .gitignore, copying the file into a repo that never had the Rails default). If this file is ever committed, anyone with repo access — including git history, even after a later removal — can decrypt every credential in config/credentials.yml.enc.



231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
# File 'lib/scryer/dependency_audit.rb', line 231

def credentials_exposure_check(root)
  master_key_path = File.join(root, "config", "master.key")
  return [] unless File.exist?(master_key_path)
  return [] if master_key_gitignored?(root)

  [
    Finding.new(
      kind: "credentials_exposure",
      gem_name: "Rails",
      severity: "critical",
      title: "config/master.key is present and not gitignored",
      url: "https://guides.rubyonrails.org/security.html",
      patched_versions: [],
      message: "config/master.key exists in this app, but .gitignore doesn't exclude it " \
                "(checked for both `/config/master.key` and `config/master.key`) — if this " \
                "file is ever committed, anyone with repository access, including git " \
                "history even after a later removal, can decrypt every credential in " \
                "config/credentials.yml.enc.",
      suggested_fix: "Add `/config/master.key` to .gitignore immediately. If this key was " \
                      "ever actually committed to git history, treat every credential in " \
                      "config/credentials.yml.enc as compromised: rotate them and regenerate " \
                      "the master key (delete both files, then `bin/rails credentials:edit`)."
    )
  ]
end

.insecure_sources(root) ⇒ Object

Offline. Flags GIT/PATH sources recorded with an unencrypted remote (git:// or plain http://) — the same supply-chain concern bundler-audit's insecure-source check targets, checked here by reading Gemfile.lock's own GIT/PATH blocks instead of the Gemfile.



162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
# File 'lib/scryer/dependency_audit.rb', line 162

def insecure_sources(root)
  lockfile = File.join(root, "Gemfile.lock")
  return [] unless File.exist?(lockfile)

  parsed = parse_lockfile(lockfile)
  parsed[:git_or_path_sources].filter_map do |src|
    next unless src[:remote] =~ %r{\A(git|http)://}

    Finding.new(
      kind: "insecure_source",
      gem_name: nil,
      severity: "warning",
      message: "Gemfile.lock has a #{src[:type]} source over an unencrypted transport: #{src[:remote]}",
      suggested_fix: "Point this source at an https:// URL instead — an unencrypted git:// or " \
                      "http:// remote can be tampered with in transit (classic supply-chain risk)."
    )
  end
end

.parse_lockfile(path) ⇒ Object

Parses a Gemfile.lock into { gems: { name => {version:, source:} }, git_or_path_sources: [...], ruby_version: "3.3.3" | nil }. source is "gem", "git", or "path" — taken from which top-level block (GEM/GIT/PATH) the spec's specs: list appeared under.



81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
# File 'lib/scryer/dependency_audit.rb', line 81

def parse_lockfile(path)
  gems = {}
  git_or_path_sources = []
  ruby_version = nil

  current_block = nil # "gem" | "git" | "path" | nil (other section)
  current_section = nil # PLATFORMS | DEPENDENCIES | BUNDLED WITH | RUBY VERSION | nil
  current_remote = nil
  in_specs = false

  File.foreach(path) do |line|
    case line
    when /\A(GEM|GIT|PATH)\s*\z/
      current_block = Regexp.last_match(1).downcase
      current_section = nil
      current_remote = nil
      in_specs = false
    when /\A(PLATFORMS|DEPENDENCIES|BUNDLED WITH|RUBY VERSION)\s*\z/
      current_block = nil
      current_section = Regexp.last_match(1)
      in_specs = false
    when /\A {2}remote:\s*(\S+)\s*\z/
      current_remote = Regexp.last_match(1)
      git_or_path_sources << { type: current_block, remote: current_remote } if %w[git path].include?(current_block)
    when /\A {2}specs:\s*\z/
      in_specs = true
    when /\A {4}([A-Za-z0-9_.\-]+)\s+\(([^)]+)\)\s*\z/
      next unless in_specs

      name = Regexp.last_match(1)
      version = Regexp.last_match(2)
      # A gem can legitimately appear under more than one block only
      # in pathological Gemfiles; last one wins, consistent with how
      # Bundler itself resolves a single spec per gem name.
      gems[name] = { version: version, source: current_block }
    when /\A\s*ruby\s+(\S+)\s*\z/
      ruby_version = Regexp.last_match(1) if current_section == "RUBY VERSION"
    end
  end

  { gems: gems, git_or_path_sources: git_or_path_sources, ruby_version: ruby_version }
end

.ruby_eol_check(root) ⇒ Object

Offline. Flags the Ruby version pinned in Gemfile.lock's RUBY VERSION section if its minor series is past end-of-life — after that date Ruby publishes no more security patches for it, for any issue, so this is a real gap even with every gem otherwise up to date. Returns [] if no version is pinned, or its series isn't one RUBY_EOL_DATES knows about (never guessed as "fine" by omission).



130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
# File 'lib/scryer/dependency_audit.rb', line 130

def ruby_eol_check(root)
  lockfile = File.join(root, "Gemfile.lock")
  return [] unless File.exist?(lockfile)

  ruby_version = parse_lockfile(lockfile)[:ruby_version]
  return [] unless ruby_version

  series = ruby_version[/\A\d+\.\d+/]
  eol_date = series && RUBY_EOL_DATES[series]
  return [] unless eol_date && Date.today > eol_date

  [
    Finding.new(
      kind: "ruby_eol",
      gem_name: "Ruby",
      installed_version: ruby_version,
      severity: "critical",
      title: "Ruby #{series} is end-of-life",
      url: "https://www.ruby-lang.org/en/downloads/branches/",
      patched_versions: [],
      message: "Ruby #{ruby_version} (#{series} series) reached end-of-life on " \
                "#{eol_date} — no security patches are published for it anymore, for any issue.",
      suggested_fix: "Upgrade to a Ruby version still receiving security maintenance — see " \
                      "https://www.ruby-lang.org/en/downloads/branches/ for current status."
    )
  ]
end

.vulnerable_gems(root, http_client: method(:query_osv), concurrency: 8) ⇒ Object

Needs network. One OSV.dev query per RubyGems-sourced gem in the lockfile (git/path-sourced gems are skipped — their version string doesn't necessarily correspond to the same code as the published gem of that name, so checking them against RubyGems advisories could misattribute or miss vulnerabilities). http_client is injectable for testing; defaults to a real Net::HTTP call. A real lockfile can easily have 200-300+ gems, and this is a network call per gem, so lookups run across a small stdlib-only thread pool (concurrency) rather than one gem at a time.



190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
# File 'lib/scryer/dependency_audit.rb', line 190

def vulnerable_gems(root, http_client: method(:query_osv), concurrency: 8)
  lockfile = File.join(root, "Gemfile.lock")
  return [] unless File.exist?(lockfile)

  parsed = parse_lockfile(lockfile)
  targets = parsed[:gems].select { |_, info| info[:source] == "gem" }.to_a

  queue = Queue.new
  targets.each { |pair| queue << pair }
  results = Queue.new

  workers = Array.new([concurrency, targets.size].min) do
    Thread.new do
      loop do
        name, info = begin
          queue.pop(true)
        rescue ThreadError
          nil
        end
        break unless name

        http_client.call(name, info[:version]).each { |v| results << finding_for(name, info[:version], v) }
      end
    end
  end
  workers.each(&:join)

  findings = []
  findings << results.pop(true) until results.empty?
  findings
end