git-pkgs 0.6.2 → 0.7.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 +4 -4
  2. data/.gitattributes +28 -0
  3. data/.ruby-version +1 -0
  4. data/CHANGELOG.md +15 -0
  5. data/Dockerfile +18 -0
  6. data/Formula/git-pkgs.rb +28 -0
  7. data/README.md +36 -4
  8. data/lib/git/pkgs/analyzer.rb +141 -9
  9. data/lib/git/pkgs/cli.rb +16 -6
  10. data/lib/git/pkgs/commands/blame.rb +0 -18
  11. data/lib/git/pkgs/commands/diff.rb +122 -5
  12. data/lib/git/pkgs/commands/diff_driver.rb +24 -4
  13. data/lib/git/pkgs/commands/init.rb +5 -0
  14. data/lib/git/pkgs/commands/list.rb +60 -15
  15. data/lib/git/pkgs/commands/show.rb +126 -3
  16. data/lib/git/pkgs/commands/stale.rb +6 -2
  17. data/lib/git/pkgs/commands/update.rb +3 -0
  18. data/lib/git/pkgs/commands/vulns/base.rb +354 -0
  19. data/lib/git/pkgs/commands/vulns/blame.rb +276 -0
  20. data/lib/git/pkgs/commands/vulns/diff.rb +172 -0
  21. data/lib/git/pkgs/commands/vulns/exposure.rb +418 -0
  22. data/lib/git/pkgs/commands/vulns/history.rb +345 -0
  23. data/lib/git/pkgs/commands/vulns/log.rb +218 -0
  24. data/lib/git/pkgs/commands/vulns/praise.rb +238 -0
  25. data/lib/git/pkgs/commands/vulns/scan.rb +231 -0
  26. data/lib/git/pkgs/commands/vulns/show.rb +216 -0
  27. data/lib/git/pkgs/commands/vulns/sync.rb +108 -0
  28. data/lib/git/pkgs/commands/vulns.rb +50 -0
  29. data/lib/git/pkgs/config.rb +8 -1
  30. data/lib/git/pkgs/database.rb +135 -5
  31. data/lib/git/pkgs/ecosystems.rb +83 -0
  32. data/lib/git/pkgs/models/package.rb +54 -0
  33. data/lib/git/pkgs/models/vulnerability.rb +300 -0
  34. data/lib/git/pkgs/models/vulnerability_package.rb +59 -0
  35. data/lib/git/pkgs/osv_client.rb +151 -0
  36. data/lib/git/pkgs/output.rb +22 -0
  37. data/lib/git/pkgs/version.rb +1 -1
  38. data/lib/git/pkgs.rb +6 -0
  39. metadata +66 -4
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Git
4
+ module Pkgs
5
+ module Commands
6
+ module Vulns
7
+ class Sync
8
+ include Base
9
+
10
+ def initialize(args)
11
+ @args = args.dup
12
+ @options = parse_options
13
+ end
14
+
15
+ def parse_options
16
+ options = {}
17
+
18
+ parser = OptionParser.new do |opts|
19
+ opts.banner = "Usage: git pkgs vulns sync [options]"
20
+ opts.separator ""
21
+ opts.separator "Sync vulnerability data from OSV."
22
+ opts.separator ""
23
+ opts.separator "Options:"
24
+
25
+ opts.on("--refresh", "Force refresh even if cache is recent") do
26
+ options[:refresh] = true
27
+ end
28
+
29
+ opts.on("-h", "--help", "Show this help") do
30
+ puts opts
31
+ exit
32
+ end
33
+ end
34
+
35
+ parser.parse!(@args)
36
+ options
37
+ end
38
+
39
+ def run
40
+ repo = Repository.new
41
+
42
+ unless Database.exists?(repo.git_dir)
43
+ error "No database found. Run 'git pkgs init' first."
44
+ end
45
+
46
+ Database.connect(repo.git_dir)
47
+
48
+ packages = Models::Package.all
49
+ if packages.empty?
50
+ info "No packages to sync. Run 'git pkgs vulns' first to populate packages."
51
+ return
52
+ end
53
+
54
+ stale_packages = packages.select(&:needs_vuln_sync?)
55
+
56
+ if stale_packages.empty? && !@options[:refresh]
57
+ info "All packages up to date. Use --refresh to force update."
58
+ return
59
+ end
60
+
61
+ packages_to_sync = @options[:refresh] ? packages : stale_packages
62
+
63
+ info "Syncing vulnerabilities for #{packages_to_sync.count} packages..."
64
+
65
+ client = OsvClient.new
66
+ synced = 0
67
+ vuln_count = 0
68
+
69
+ packages_to_sync.each_slice(100) do |batch|
70
+ queries = batch.map do |pkg|
71
+ osv_ecosystem = Ecosystems.to_osv(pkg.ecosystem)
72
+ next unless osv_ecosystem
73
+
74
+ { ecosystem: osv_ecosystem, name: pkg.name }
75
+ end.compact
76
+
77
+ results = client.query_batch(queries)
78
+
79
+ # Collect all unique vuln IDs from this batch to fetch full details
80
+ vuln_ids = results.flatten.map { |v| v["id"] }.uniq
81
+
82
+ # Fetch full vulnerability details and create records
83
+ vuln_ids.each do |vuln_id|
84
+ existing = Models::Vulnerability.first(id: vuln_id)
85
+ next if existing&.vulnerability_packages&.any? && !@options[:refresh]
86
+
87
+ begin
88
+ full_vuln = client.get_vulnerability(vuln_id)
89
+ Models::Vulnerability.from_osv(full_vuln)
90
+ vuln_count += 1
91
+ rescue OsvClient::ApiError
92
+ # Skip vulnerabilities we can't fetch
93
+ end
94
+ end
95
+
96
+ batch.each do |pkg|
97
+ pkg.mark_vulns_synced
98
+ synced += 1
99
+ end
100
+ end
101
+
102
+ info "Synced #{synced} packages, found #{vuln_count} vulnerability records."
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "vulns/base"
4
+ require_relative "vulns/scan"
5
+ require_relative "vulns/sync"
6
+ require_relative "vulns/blame"
7
+ require_relative "vulns/praise"
8
+ require_relative "vulns/exposure"
9
+ require_relative "vulns/diff"
10
+ require_relative "vulns/log"
11
+ require_relative "vulns/history"
12
+ require_relative "vulns/show"
13
+
14
+ module Git
15
+ module Pkgs
16
+ module Commands
17
+ class VulnsCommand
18
+ SUBCOMMANDS = %w[sync blame praise exposure diff log history show].freeze
19
+
20
+ def initialize(args)
21
+ @args = args.dup
22
+ @subcommand = detect_subcommand
23
+ end
24
+
25
+ def detect_subcommand
26
+ return nil if @args.empty?
27
+ return nil unless SUBCOMMANDS.include?(@args.first)
28
+
29
+ @args.shift
30
+ end
31
+
32
+ def run
33
+ handler_class = case @subcommand
34
+ when "sync" then Vulns::Sync
35
+ when "blame" then Vulns::Blame
36
+ when "praise" then Vulns::Praise
37
+ when "exposure" then Vulns::Exposure
38
+ when "diff" then Vulns::Diff
39
+ when "log" then Vulns::Log
40
+ when "history" then Vulns::History
41
+ when "show" then Vulns::Show
42
+ else Vulns::Scan
43
+ end
44
+
45
+ handler_class.new(@args).run
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "bibliothecary"
4
+ require "open3"
4
5
 
5
6
  module Git
6
7
  module Pkgs
@@ -52,7 +53,13 @@ module Git
52
53
  end
53
54
 
54
55
  def self.read_config_list(key)
55
- `git config --get-all #{key} 2>/dev/null`.split("\n").map(&:strip).reject(&:empty?)
56
+ args = if Git::Pkgs.work_tree
57
+ ["git", "-C", Git::Pkgs.work_tree.to_s, "config", "--get-all", key.to_s]
58
+ else
59
+ ["git", "config", "--get-all", key.to_s]
60
+ end
61
+ stdout, _stderr, _status = Open3.capture3(*args)
62
+ stdout.split("\n").map(&:strip).reject(&:empty?)
56
63
  end
57
64
  end
58
65
  end
@@ -15,7 +15,7 @@ module Git
15
15
  module Pkgs
16
16
  class Database
17
17
  DB_FILE = "pkgs.sqlite3"
18
- SCHEMA_VERSION = 1
18
+ SCHEMA_VERSION = 2
19
19
 
20
20
  class << self
21
21
  attr_accessor :db
@@ -82,7 +82,10 @@ module Git
82
82
  Git::Pkgs::Models::Commit,
83
83
  Git::Pkgs::Models::Manifest,
84
84
  Git::Pkgs::Models::DependencyChange,
85
- Git::Pkgs::Models::DependencySnapshot
85
+ Git::Pkgs::Models::DependencySnapshot,
86
+ Git::Pkgs::Models::Package,
87
+ Git::Pkgs::Models::Vulnerability,
88
+ Git::Pkgs::Models::VulnerabilityPackage
86
89
  ].each do |model|
87
90
  model.dataset = @db[model.table_name]
88
91
  # Clear all cached association data that may reference old db
@@ -157,6 +160,7 @@ module Git
157
160
  foreign_key :manifest_id, :manifests
158
161
  String :name, null: false
159
162
  String :ecosystem
163
+ String :purl
160
164
  String :change_type, null: false
161
165
  String :requirement
162
166
  String :previous_requirement
@@ -171,12 +175,63 @@ module Git
171
175
  foreign_key :manifest_id, :manifests
172
176
  String :name, null: false
173
177
  String :ecosystem
178
+ String :purl
174
179
  String :requirement
175
180
  String :dependency_type
176
181
  DateTime :created_at
177
182
  DateTime :updated_at
178
183
  end
179
184
 
185
+ @db.create_table?(:packages) do
186
+ primary_key :id
187
+ String :purl, null: false
188
+ String :ecosystem, null: false
189
+ String :name, null: false
190
+ String :latest_version
191
+ String :license
192
+ String :description, text: true
193
+ String :homepage
194
+ String :repository_url
195
+ String :source
196
+ DateTime :enriched_at
197
+ DateTime :vulns_synced_at
198
+ DateTime :created_at
199
+ DateTime :updated_at
200
+ index :purl, unique: true
201
+ index [:ecosystem, :name]
202
+ end
203
+
204
+ # Core vulnerability data (one row per CVE/GHSA)
205
+ @db.create_table?(:vulnerabilities) do
206
+ String :id, primary_key: true # CVE-2024-1234, GHSA-xxxx, etc.
207
+ String :aliases, text: true # comma-separated other IDs for same vuln
208
+ String :severity # critical, high, medium, low
209
+ Float :cvss_score
210
+ String :cvss_vector
211
+ String :references, text: true # JSON array of {type, url} objects
212
+ String :summary, text: true
213
+ String :details, text: true
214
+ DateTime :published_at # when vuln was disclosed
215
+ DateTime :withdrawn_at # when vuln was retracted (if ever)
216
+ DateTime :modified_at # when OSV record was last modified
217
+ DateTime :fetched_at, null: false # when we last fetched from OSV
218
+ end
219
+
220
+ # Which packages are affected by each vulnerability
221
+ # One vuln can affect multiple packages, each with different version ranges
222
+ @db.create_table?(:vulnerability_packages) do
223
+ primary_key :id
224
+ String :vulnerability_id, null: false
225
+ String :ecosystem, null: false # OSV ecosystem name
226
+ String :package_name, null: false
227
+ String :affected_versions, text: true # version range expression
228
+ String :fixed_versions, text: true # comma-separated list
229
+ foreign_key [:vulnerability_id], :vulnerabilities
230
+ index [:ecosystem, :package_name]
231
+ index [:vulnerability_id]
232
+ unique [:vulnerability_id, :ecosystem, :package_name]
233
+ end
234
+
180
235
  set_version
181
236
  create_bulk_indexes if with_indexes
182
237
  refresh_models
@@ -186,6 +241,7 @@ module Git
186
241
  @db.alter_table(:dependency_changes) do
187
242
  add_index :name, if_not_exists: true
188
243
  add_index :ecosystem, if_not_exists: true
244
+ add_index :purl, if_not_exists: true
189
245
  add_index [:commit_id, :name], if_not_exists: true
190
246
  end
191
247
 
@@ -193,6 +249,7 @@ module Git
193
249
  add_index [:commit_id, :manifest_id, :name], unique: true, name: "idx_snapshots_unique", if_not_exists: true
194
250
  add_index :name, if_not_exists: true
195
251
  add_index :ecosystem, if_not_exists: true
252
+ add_index :purl, if_not_exists: true
196
253
  end
197
254
  end
198
255
 
@@ -218,10 +275,83 @@ module Git
218
275
  def self.check_version!
219
276
  return unless needs_upgrade?
220
277
 
278
+ migrate!
279
+ end
280
+
281
+ def self.migrate!
221
282
  stored = stored_version || 0
222
- $stderr.puts "Database schema is outdated (version #{stored}, current is #{SCHEMA_VERSION})."
223
- $stderr.puts "Run 'git pkgs upgrade' to update."
224
- exit 1
283
+
284
+ # Migration from v1 to v2: add vuln tables
285
+ if stored < 2
286
+ migrate_to_v2!
287
+ end
288
+
289
+ set_version
290
+ refresh_models
291
+ end
292
+
293
+ def self.migrate_to_v2!
294
+ @db.create_table?(:packages) do
295
+ primary_key :id
296
+ String :purl, null: false
297
+ String :ecosystem, null: false
298
+ String :name, null: false
299
+ String :latest_version
300
+ String :license
301
+ String :description, text: true
302
+ String :homepage
303
+ String :repository_url
304
+ String :source
305
+ DateTime :enriched_at
306
+ DateTime :vulns_synced_at
307
+ DateTime :created_at
308
+ DateTime :updated_at
309
+ index :purl, unique: true
310
+ index [:ecosystem, :name]
311
+ end
312
+
313
+ @db.create_table?(:vulnerabilities) do
314
+ String :id, primary_key: true
315
+ String :aliases, text: true
316
+ String :severity
317
+ Float :cvss_score
318
+ String :cvss_vector
319
+ String :references, text: true
320
+ String :summary, text: true
321
+ String :details, text: true
322
+ DateTime :published_at
323
+ DateTime :withdrawn_at
324
+ DateTime :modified_at
325
+ DateTime :fetched_at, null: false
326
+ end
327
+
328
+ @db.create_table?(:vulnerability_packages) do
329
+ primary_key :id
330
+ String :vulnerability_id, null: false
331
+ String :ecosystem, null: false
332
+ String :package_name, null: false
333
+ String :affected_versions, text: true
334
+ String :fixed_versions, text: true
335
+ foreign_key [:vulnerability_id], :vulnerabilities
336
+ index [:ecosystem, :package_name]
337
+ index [:vulnerability_id]
338
+ unique [:vulnerability_id, :ecosystem, :package_name]
339
+ end
340
+
341
+ # Add purl column to existing tables if missing
342
+ unless @db.schema(:dependency_changes).any? { |col, _| col == :purl }
343
+ @db.alter_table(:dependency_changes) do
344
+ add_column :purl, String
345
+ add_index :purl, if_not_exists: true
346
+ end
347
+ end
348
+
349
+ unless @db.schema(:dependency_snapshots).any? { |col, _| col == :purl }
350
+ @db.alter_table(:dependency_snapshots) do
351
+ add_column :purl, String
352
+ add_index :purl, if_not_exists: true
353
+ end
354
+ end
225
355
  end
226
356
 
227
357
  def self.optimize_for_bulk_writes
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Git
4
+ module Pkgs
5
+ # Maps ecosystem names between bibliothecary, purl, and OSV formats.
6
+ # Bibliothecary uses lowercase names internally.
7
+ # Purl uses its own type names.
8
+ # OSV uses mixed case names that differ from both.
9
+ module Ecosystems
10
+ # Mapping from bibliothecary ecosystem names to OSV and purl equivalents
11
+ MAPPINGS = {
12
+ "npm" => { osv: "npm", purl: "npm" },
13
+ "rubygems" => { osv: "RubyGems", purl: "gem" },
14
+ "pypi" => { osv: "PyPI", purl: "pypi" },
15
+ "cargo" => { osv: "crates.io", purl: "cargo" },
16
+ "maven" => { osv: "Maven", purl: "maven" },
17
+ "nuget" => { osv: "NuGet", purl: "nuget" },
18
+ "packagist" => { osv: "Packagist", purl: "composer" },
19
+ "go" => { osv: "Go", purl: "golang" },
20
+ "hex" => { osv: "Hex", purl: "hex" },
21
+ "pub" => { osv: "Pub", purl: "pub" }
22
+ }.freeze
23
+
24
+ # Reverse mappings for lookups from OSV/purl to bibliothecary
25
+ OSV_TO_BIBLIOTHECARY = MAPPINGS.transform_values { |v| v[:osv] }.invert.freeze
26
+ PURL_TO_BIBLIOTHECARY = MAPPINGS.transform_values { |v| v[:purl] }.invert.freeze
27
+
28
+ class << self
29
+ # Convert bibliothecary ecosystem name to OSV format
30
+ # @param ecosystem [String] bibliothecary ecosystem name (e.g., "rubygems")
31
+ # @return [String, nil] OSV ecosystem name (e.g., "RubyGems") or nil if not mapped
32
+ def to_osv(ecosystem)
33
+ MAPPINGS.dig(ecosystem.to_s.downcase, :osv)
34
+ end
35
+
36
+ # Convert bibliothecary ecosystem name to purl type
37
+ # @param ecosystem [String] bibliothecary ecosystem name (e.g., "rubygems")
38
+ # @return [String, nil] purl type (e.g., "gem") or nil if not mapped
39
+ def to_purl(ecosystem)
40
+ MAPPINGS.dig(ecosystem.to_s.downcase, :purl)
41
+ end
42
+
43
+ # Convert OSV ecosystem name to bibliothecary format
44
+ # @param osv_ecosystem [String] OSV ecosystem name (e.g., "RubyGems")
45
+ # @return [String, nil] bibliothecary ecosystem name (e.g., "rubygems") or nil if not mapped
46
+ def from_osv(osv_ecosystem)
47
+ OSV_TO_BIBLIOTHECARY[osv_ecosystem]
48
+ end
49
+
50
+ # Convert purl type to bibliothecary ecosystem name
51
+ # @param purl_type [String] purl type (e.g., "gem")
52
+ # @return [String, nil] bibliothecary ecosystem name (e.g., "rubygems") or nil if not mapped
53
+ def from_purl(purl_type)
54
+ PURL_TO_BIBLIOTHECARY[purl_type]
55
+ end
56
+
57
+ # Check if an ecosystem is supported for vulnerability scanning
58
+ # @param ecosystem [String] bibliothecary ecosystem name
59
+ # @return [Boolean]
60
+ def supported?(ecosystem)
61
+ MAPPINGS.key?(ecosystem.to_s.downcase)
62
+ end
63
+
64
+ # List all supported bibliothecary ecosystem names
65
+ # @return [Array<String>]
66
+ def supported_ecosystems
67
+ MAPPINGS.keys
68
+ end
69
+
70
+ # Generate a purl (package URL) for a given ecosystem and package name
71
+ # @param ecosystem [String] bibliothecary ecosystem name (e.g., "rubygems")
72
+ # @param name [String] package name
73
+ # @return [String, nil] purl string (e.g., "pkg:gem/rails") or nil if ecosystem not supported
74
+ def generate_purl(ecosystem, name)
75
+ purl_type = to_purl(ecosystem)
76
+ return nil unless purl_type
77
+
78
+ "pkg:#{purl_type}/#{name}"
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Git
4
+ module Pkgs
5
+ module Models
6
+ class Package < Sequel::Model
7
+ STALE_THRESHOLD = 86400 # 24 hours
8
+
9
+ dataset_module do
10
+ def by_ecosystem(ecosystem)
11
+ where(ecosystem: ecosystem)
12
+ end
13
+
14
+ def needs_vuln_sync
15
+ where(vulns_synced_at: nil).or { vulns_synced_at < Time.now - STALE_THRESHOLD }
16
+ end
17
+
18
+ def synced
19
+ where { vulns_synced_at >= Time.now - STALE_THRESHOLD }
20
+ end
21
+ end
22
+
23
+ def needs_vuln_sync?
24
+ vulns_synced_at.nil? || vulns_synced_at < Time.now - STALE_THRESHOLD
25
+ end
26
+
27
+ def mark_vulns_synced
28
+ update(vulns_synced_at: Time.now)
29
+ end
30
+
31
+ def vulnerabilities
32
+ osv_ecosystem = Ecosystems.to_osv(ecosystem)
33
+ return [] unless osv_ecosystem
34
+
35
+ VulnerabilityPackage
36
+ .where(ecosystem: osv_ecosystem, package_name: name)
37
+ .map(&:vulnerability)
38
+ .compact
39
+ end
40
+
41
+ def self.find_or_create_by_purl(purl:, ecosystem: nil, name: nil)
42
+ existing = first(purl: purl)
43
+ return existing if existing
44
+
45
+ create(purl: purl, ecosystem: ecosystem, name: name)
46
+ end
47
+
48
+ def self.generate_purl(ecosystem, name)
49
+ Ecosystems.generate_purl(ecosystem, name)
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end