git-pkgs 0.8.0 → 0.9.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 +4 -4
- data/CHANGELOG.md +15 -0
- data/Formula/git-pkgs.rb +2 -2
- data/README.md +27 -0
- data/lib/git/pkgs/analyzer.rb +18 -9
- data/lib/git/pkgs/cli.rb +3 -1
- data/lib/git/pkgs/commands/branch.rb +5 -2
- data/lib/git/pkgs/commands/init.rb +5 -2
- data/lib/git/pkgs/commands/integrity.rb +288 -0
- data/lib/git/pkgs/commands/licenses.rb +55 -17
- data/lib/git/pkgs/commands/sbom.rb +325 -0
- data/lib/git/pkgs/commands/update.rb +3 -1
- data/lib/git/pkgs/commands/vulns/praise.rb +2 -0
- data/lib/git/pkgs/config.rb +1 -2
- data/lib/git/pkgs/database.rb +7 -13
- data/lib/git/pkgs/ecosystems_client.rb +46 -0
- data/lib/git/pkgs/models/package.rb +23 -0
- data/lib/git/pkgs/models/version.rb +29 -0
- data/lib/git/pkgs/version.rb +1 -1
- data/lib/git/pkgs.rb +3 -0
- metadata +39 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3bd1b968e2bc7ed2d3ecbc8a750925afdea362ca1d0a01f6d69a6bf5aef58b02
|
|
4
|
+
data.tar.gz: 1deed6e6742a7977585c97db65fe13e649e4b5e3b6a0f9674f5352d977dc8fcf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5d7ed904f5e7b3fc0b7a7054d596dfe59d1918f30d493ac5613cea125c41f9b38ab22daab2c84a5536b55030ca407334b01e6d0bd79bc828a371abfdccf5a16d
|
|
7
|
+
data.tar.gz: e1170c4c9eeb345730e9ab614536c6431f1c56ee9bafc8bdff111e7df3bf8f577149ba1f94a77787ace083ee401b2980978148f76e85d16c5a3a1101902a2a08
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.9.0] - 2026-01-14
|
|
4
|
+
|
|
5
|
+
- `git pkgs sbom` command to export dependencies as SPDX or CycloneDX
|
|
6
|
+
- `git pkgs integrity` command to show and verify lockfile integrity hashes
|
|
7
|
+
- Parse go.sum for Go module integrity hashes (no longer ignored)
|
|
8
|
+
- Convert Go h1: hashes (base64) to hex for SBOM compatibility
|
|
9
|
+
- `--drift` flag to detect packages with different hashes for the same version
|
|
10
|
+
- Registry integrity comparison via ecosyste.ms API
|
|
11
|
+
- Store integrity hashes from lockfiles in dependency_snapshots table
|
|
12
|
+
- SBOM export includes supplier info from ecosyste.ms (owner/maintainer)
|
|
13
|
+
- License commands use version-level license data when available
|
|
14
|
+
- Store supplier_name and supplier_type on packages (schema v5, run `git pkgs upgrade`)
|
|
15
|
+
- Update ecosystems-bibliothecary to ~> 15.3 (integrity extraction from lockfiles)
|
|
16
|
+
- Update purl to >= 1.7.1 (ecosyste.ms API URL support)
|
|
17
|
+
|
|
3
18
|
## [0.8.0] - 2026-01-14
|
|
4
19
|
|
|
5
20
|
- `git pkgs outdated` command to find dependencies with newer versions available in registries
|
data/Formula/git-pkgs.rb
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
class GitPkgs < Formula
|
|
2
2
|
desc "Track package dependencies across git history"
|
|
3
3
|
homepage "https://github.com/andrew/git-pkgs"
|
|
4
|
-
url "https://github.com/andrew/git-pkgs/archive/refs/tags/v0.
|
|
5
|
-
sha256 "
|
|
4
|
+
url "https://github.com/andrew/git-pkgs/archive/refs/tags/v0.8.0.tar.gz"
|
|
5
|
+
sha256 "b2e8ebfefc86fd137fb76225934c0fe2a1e01b2fcaac88a91f1134eecc4e9e71"
|
|
6
6
|
license "AGPL-3.0"
|
|
7
7
|
|
|
8
8
|
depends_on "cmake" => :build
|
data/README.md
CHANGED
|
@@ -322,6 +322,33 @@ Output formats: `text` (default), `json`, and `sarif`. SARIF integrates with Git
|
|
|
322
322
|
|
|
323
323
|
Vulnerability data is cached locally and refreshed automatically when stale (>24h). Use `git pkgs vulns sync --refresh` to force an update. See [docs/vulns.md](docs/vulns.md) for full documentation.
|
|
324
324
|
|
|
325
|
+
### Integrity verification
|
|
326
|
+
|
|
327
|
+
Show SHA256 hashes from lockfiles. Modern lockfiles include checksums that verify package contents haven't been tampered with.
|
|
328
|
+
|
|
329
|
+
```bash
|
|
330
|
+
git pkgs integrity # show hashes for current dependencies
|
|
331
|
+
git pkgs integrity --drift # detect same version with different hashes
|
|
332
|
+
git pkgs integrity -f json # JSON output
|
|
333
|
+
git pkgs integrity --stateless # no database needed
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
The `--drift` flag scans your history for packages where the same version has different integrity hashes, which could indicate a supply chain issue.
|
|
337
|
+
|
|
338
|
+
### SBOM export
|
|
339
|
+
|
|
340
|
+
Export dependencies as a Software Bill of Materials in CycloneDX or SPDX format:
|
|
341
|
+
|
|
342
|
+
```bash
|
|
343
|
+
git pkgs sbom # CycloneDX JSON (default)
|
|
344
|
+
git pkgs sbom --type spdx # SPDX JSON
|
|
345
|
+
git pkgs sbom -f xml # XML instead of JSON
|
|
346
|
+
git pkgs sbom --name my-project # custom project name
|
|
347
|
+
git pkgs sbom --stateless # no database needed
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
Includes package URLs (purls), versions, and licenses (fetched from registries). Use `--skip-enrichment` to omit license lookups.
|
|
351
|
+
|
|
325
352
|
### Diff between commits
|
|
326
353
|
|
|
327
354
|
```bash
|
data/lib/git/pkgs/analyzer.rb
CHANGED
|
@@ -141,7 +141,8 @@ module Git
|
|
|
141
141
|
purl: generate_purl(result[:platform], dep[:name]),
|
|
142
142
|
change_type: "added",
|
|
143
143
|
requirement: dep[:requirement],
|
|
144
|
-
dependency_type: dep[:type]
|
|
144
|
+
dependency_type: dep[:type],
|
|
145
|
+
integrity: dep[:integrity]
|
|
145
146
|
}
|
|
146
147
|
|
|
147
148
|
key = [manifest_path, dep[:name]]
|
|
@@ -150,7 +151,8 @@ module Git
|
|
|
150
151
|
kind: result[:kind],
|
|
151
152
|
purl: generate_purl(result[:platform], dep[:name]),
|
|
152
153
|
requirement: dep[:requirement],
|
|
153
|
-
dependency_type: dep[:type]
|
|
154
|
+
dependency_type: dep[:type],
|
|
155
|
+
integrity: dep[:integrity]
|
|
154
156
|
}
|
|
155
157
|
end
|
|
156
158
|
end
|
|
@@ -179,7 +181,8 @@ module Git
|
|
|
179
181
|
purl: generate_purl(after_result[:platform], name),
|
|
180
182
|
change_type: "added",
|
|
181
183
|
requirement: dep[:requirement],
|
|
182
|
-
dependency_type: dep[:type]
|
|
184
|
+
dependency_type: dep[:type],
|
|
185
|
+
integrity: dep[:integrity]
|
|
183
186
|
}
|
|
184
187
|
|
|
185
188
|
key = [manifest_path, name]
|
|
@@ -188,7 +191,8 @@ module Git
|
|
|
188
191
|
kind: after_result[:kind],
|
|
189
192
|
purl: generate_purl(after_result[:platform], name),
|
|
190
193
|
requirement: dep[:requirement],
|
|
191
|
-
dependency_type: dep[:type]
|
|
194
|
+
dependency_type: dep[:type],
|
|
195
|
+
integrity: dep[:integrity]
|
|
192
196
|
}
|
|
193
197
|
end
|
|
194
198
|
|
|
@@ -202,7 +206,8 @@ module Git
|
|
|
202
206
|
purl: generate_purl(before_result[:platform], name),
|
|
203
207
|
change_type: "removed",
|
|
204
208
|
requirement: dep[:requirement],
|
|
205
|
-
dependency_type: dep[:type]
|
|
209
|
+
dependency_type: dep[:type],
|
|
210
|
+
integrity: dep[:integrity]
|
|
206
211
|
}
|
|
207
212
|
|
|
208
213
|
key = [manifest_path, name]
|
|
@@ -223,7 +228,8 @@ module Git
|
|
|
223
228
|
change_type: "modified",
|
|
224
229
|
requirement: after_dep[:requirement],
|
|
225
230
|
previous_requirement: before_dep[:requirement],
|
|
226
|
-
dependency_type: after_dep[:type]
|
|
231
|
+
dependency_type: after_dep[:type],
|
|
232
|
+
integrity: after_dep[:integrity]
|
|
227
233
|
}
|
|
228
234
|
|
|
229
235
|
key = [manifest_path, name]
|
|
@@ -232,7 +238,8 @@ module Git
|
|
|
232
238
|
kind: after_result[:kind],
|
|
233
239
|
purl: generate_purl(after_result[:platform], name),
|
|
234
240
|
requirement: after_dep[:requirement],
|
|
235
|
-
dependency_type: after_dep[:type]
|
|
241
|
+
dependency_type: after_dep[:type],
|
|
242
|
+
integrity: after_dep[:integrity]
|
|
236
243
|
}
|
|
237
244
|
end
|
|
238
245
|
end
|
|
@@ -252,7 +259,8 @@ module Git
|
|
|
252
259
|
purl: generate_purl(result[:platform], dep[:name]),
|
|
253
260
|
change_type: "removed",
|
|
254
261
|
requirement: dep[:requirement],
|
|
255
|
-
dependency_type: dep[:type]
|
|
262
|
+
dependency_type: dep[:type],
|
|
263
|
+
integrity: dep[:integrity]
|
|
256
264
|
}
|
|
257
265
|
|
|
258
266
|
key = [manifest_path, dep[:name]]
|
|
@@ -329,7 +337,8 @@ module Git
|
|
|
329
337
|
kind: result[:kind],
|
|
330
338
|
purl: generate_purl(result[:platform], dep[:name]),
|
|
331
339
|
requirement: dep[:requirement],
|
|
332
|
-
dependency_type: dep[:type]
|
|
340
|
+
dependency_type: dep[:type],
|
|
341
|
+
integrity: dep[:integrity]
|
|
333
342
|
}
|
|
334
343
|
end
|
|
335
344
|
end
|
data/lib/git/pkgs/cli.rb
CHANGED
|
@@ -35,7 +35,9 @@ module Git
|
|
|
35
35
|
"stats" => "Show dependency statistics",
|
|
36
36
|
"stale" => "Show dependencies that haven't been updated",
|
|
37
37
|
"outdated" => "Show packages with newer versions available",
|
|
38
|
-
"licenses" => "Show licenses for dependencies"
|
|
38
|
+
"licenses" => "Show licenses for dependencies",
|
|
39
|
+
"integrity" => "Show and verify lockfile integrity hashes",
|
|
40
|
+
"sbom" => "Export dependencies as SBOM (SPDX or CycloneDX)"
|
|
39
41
|
},
|
|
40
42
|
"Security" => {
|
|
41
43
|
"vulns" => "Scan for known vulnerabilities"
|
|
@@ -191,6 +191,7 @@ module Git
|
|
|
191
191
|
ecosystem: s[:ecosystem],
|
|
192
192
|
requirement: s[:requirement],
|
|
193
193
|
dependency_type: s[:dependency_type],
|
|
194
|
+
integrity: s[:integrity],
|
|
194
195
|
created_at: now,
|
|
195
196
|
updated_at: now
|
|
196
197
|
}
|
|
@@ -266,7 +267,8 @@ module Git
|
|
|
266
267
|
name: name,
|
|
267
268
|
ecosystem: dep_info[:ecosystem],
|
|
268
269
|
requirement: dep_info[:requirement],
|
|
269
|
-
dependency_type: dep_info[:dependency_type]
|
|
270
|
+
dependency_type: dep_info[:dependency_type],
|
|
271
|
+
integrity: dep_info[:integrity]
|
|
270
272
|
}
|
|
271
273
|
end
|
|
272
274
|
snapshots_stored += snapshot.size
|
|
@@ -286,7 +288,8 @@ module Git
|
|
|
286
288
|
name: name,
|
|
287
289
|
ecosystem: dep_info[:ecosystem],
|
|
288
290
|
requirement: dep_info[:requirement],
|
|
289
|
-
dependency_type: dep_info[:dependency_type]
|
|
291
|
+
dependency_type: dep_info[:dependency_type],
|
|
292
|
+
integrity: dep_info[:integrity]
|
|
290
293
|
}
|
|
291
294
|
end
|
|
292
295
|
snapshots_stored += snapshot.size
|
|
@@ -125,6 +125,7 @@ module Git
|
|
|
125
125
|
purl: s[:purl],
|
|
126
126
|
requirement: s[:requirement],
|
|
127
127
|
dependency_type: s[:dependency_type],
|
|
128
|
+
integrity: s[:integrity],
|
|
128
129
|
created_at: now,
|
|
129
130
|
updated_at: now
|
|
130
131
|
}
|
|
@@ -207,7 +208,8 @@ module Git
|
|
|
207
208
|
ecosystem: dep_info[:ecosystem],
|
|
208
209
|
purl: dep_info[:purl],
|
|
209
210
|
requirement: dep_info[:requirement],
|
|
210
|
-
dependency_type: dep_info[:dependency_type]
|
|
211
|
+
dependency_type: dep_info[:dependency_type],
|
|
212
|
+
integrity: dep_info[:integrity]
|
|
211
213
|
}
|
|
212
214
|
end
|
|
213
215
|
snapshots_stored += snapshot.size
|
|
@@ -228,7 +230,8 @@ module Git
|
|
|
228
230
|
ecosystem: dep_info[:ecosystem],
|
|
229
231
|
purl: dep_info[:purl],
|
|
230
232
|
requirement: dep_info[:requirement],
|
|
231
|
-
dependency_type: dep_info[:dependency_type]
|
|
233
|
+
dependency_type: dep_info[:dependency_type],
|
|
234
|
+
integrity: dep_info[:integrity]
|
|
232
235
|
}
|
|
233
236
|
end
|
|
234
237
|
snapshots_stored += snapshot.size
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Git
|
|
4
|
+
module Pkgs
|
|
5
|
+
module Commands
|
|
6
|
+
class Integrity
|
|
7
|
+
include Output
|
|
8
|
+
|
|
9
|
+
def self.description
|
|
10
|
+
"Show and verify lockfile integrity hashes"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(args)
|
|
14
|
+
@args = args
|
|
15
|
+
@options = parse_options
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def run
|
|
19
|
+
repo = Repository.new
|
|
20
|
+
use_stateless = @options[:stateless] || !Database.exists?(repo.git_dir)
|
|
21
|
+
|
|
22
|
+
if @options[:drift]
|
|
23
|
+
error "--drift requires database (run 'git pkgs init' first)" if use_stateless
|
|
24
|
+
run_drift_detection(repo)
|
|
25
|
+
else
|
|
26
|
+
run_show(repo, use_stateless)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def run_show(repo, use_stateless)
|
|
31
|
+
if use_stateless
|
|
32
|
+
deps = run_stateless(repo)
|
|
33
|
+
else
|
|
34
|
+
deps = run_with_database(repo)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Filter to only lockfile deps with integrity
|
|
38
|
+
deps = Analyzer.lockfile_dependencies(deps)
|
|
39
|
+
deps = deps.select { |d| d[:integrity] }
|
|
40
|
+
|
|
41
|
+
if @options[:ecosystem]
|
|
42
|
+
deps = deps.select { |d| d[:ecosystem] == @options[:ecosystem] }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
if deps.empty?
|
|
46
|
+
empty_result "No dependencies with integrity hashes found"
|
|
47
|
+
return
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
if @options[:format] == "json"
|
|
51
|
+
require "json"
|
|
52
|
+
puts JSON.pretty_generate(deps.map { |d| format_dep_json(d) })
|
|
53
|
+
else
|
|
54
|
+
paginate { output_text(deps) }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def run_stateless(repo)
|
|
59
|
+
commit_sha = @options[:ref] || repo.head_sha
|
|
60
|
+
rugged_commit = repo.lookup(repo.rev_parse(commit_sha))
|
|
61
|
+
error "Could not resolve '#{commit_sha}'" unless rugged_commit
|
|
62
|
+
|
|
63
|
+
analyzer = Analyzer.new(repo)
|
|
64
|
+
analyzer.dependencies_at_commit(rugged_commit)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def run_with_database(repo)
|
|
68
|
+
Database.connect(repo.git_dir)
|
|
69
|
+
|
|
70
|
+
commit_sha = @options[:ref] || repo.head_sha
|
|
71
|
+
target_commit = Models::Commit.first(sha: commit_sha)
|
|
72
|
+
error "Commit not in database. Run 'git pkgs update' first." unless target_commit
|
|
73
|
+
|
|
74
|
+
compute_dependencies_at_commit(target_commit, repo)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def run_drift_detection(repo)
|
|
78
|
+
Database.connect(repo.git_dir)
|
|
79
|
+
|
|
80
|
+
# Get unique (purl, requirement, integrity) from snapshots
|
|
81
|
+
results = Database.db[:dependency_snapshots]
|
|
82
|
+
.exclude(integrity: nil)
|
|
83
|
+
.select(:purl, :requirement, :integrity)
|
|
84
|
+
.distinct
|
|
85
|
+
.all
|
|
86
|
+
|
|
87
|
+
# Build versioned purls and group
|
|
88
|
+
by_versioned_purl = {}
|
|
89
|
+
results.each do |r|
|
|
90
|
+
versioned_purl = "#{r[:purl]}@#{r[:requirement]}"
|
|
91
|
+
by_versioned_purl[versioned_purl] ||= { purl: r[:purl], version: r[:requirement], lockfile_integrities: [] }
|
|
92
|
+
by_versioned_purl[versioned_purl][:lockfile_integrities] << r[:integrity]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Dedupe lockfile integrities
|
|
96
|
+
by_versioned_purl.each { |_, v| v[:lockfile_integrities].uniq! }
|
|
97
|
+
|
|
98
|
+
# Find internal drift (same version with different lockfile hashes)
|
|
99
|
+
internal_drifts = by_versioned_purl.select { |_, v| v[:lockfile_integrities].size > 1 }
|
|
100
|
+
|
|
101
|
+
# Fetch registry integrity for comparison
|
|
102
|
+
registry_mismatches = []
|
|
103
|
+
purls_to_check = by_versioned_purl.keys
|
|
104
|
+
|
|
105
|
+
if purls_to_check.any?
|
|
106
|
+
Spinner.with_spinner("Fetching registry integrity...") do
|
|
107
|
+
client = EcosystemsClient.new
|
|
108
|
+
purls_to_check.each do |versioned_purl|
|
|
109
|
+
data = by_versioned_purl[versioned_purl]
|
|
110
|
+
version_info = client.lookup_version(versioned_purl)
|
|
111
|
+
next unless version_info && version_info["integrity"]
|
|
112
|
+
|
|
113
|
+
registry_integrity = version_info["integrity"]
|
|
114
|
+
lockfile_integrity = data[:lockfile_integrities].first
|
|
115
|
+
|
|
116
|
+
unless integrity_match?(lockfile_integrity, registry_integrity)
|
|
117
|
+
registry_mismatches << {
|
|
118
|
+
purl: versioned_purl,
|
|
119
|
+
lockfile: lockfile_integrity,
|
|
120
|
+
registry: registry_integrity
|
|
121
|
+
}
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
if internal_drifts.empty? && registry_mismatches.empty?
|
|
128
|
+
info "No integrity drift detected"
|
|
129
|
+
return
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
if @options[:format] == "json"
|
|
133
|
+
require "json"
|
|
134
|
+
output = {
|
|
135
|
+
internal_drift: internal_drifts.map { |purl, v| { purl: purl, integrity_values: v[:lockfile_integrities] } },
|
|
136
|
+
registry_mismatch: registry_mismatches
|
|
137
|
+
}
|
|
138
|
+
puts JSON.pretty_generate(output)
|
|
139
|
+
else
|
|
140
|
+
paginate { output_drift_text(internal_drifts, registry_mismatches) }
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def integrity_match?(lockfile, registry)
|
|
145
|
+
normalize_integrity(lockfile) == normalize_integrity(registry)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def normalize_integrity(integrity)
|
|
149
|
+
return nil unless integrity
|
|
150
|
+
# Normalize sha256= vs sha256- format
|
|
151
|
+
integrity.gsub(/^sha256[-=]/, "sha256:")
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def output_text(deps)
|
|
155
|
+
grouped = deps.group_by { |d| d[:ecosystem] }
|
|
156
|
+
|
|
157
|
+
grouped.each do |ecosystem, ecosystem_deps|
|
|
158
|
+
puts "#{ecosystem}:"
|
|
159
|
+
ecosystem_deps.sort_by { |d| d[:name] }.each do |dep|
|
|
160
|
+
puts " #{dep[:name]} #{dep[:requirement]}"
|
|
161
|
+
puts " #{dep[:integrity]}"
|
|
162
|
+
end
|
|
163
|
+
puts
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def output_drift_text(internal_drifts, registry_mismatches)
|
|
168
|
+
if internal_drifts.any?
|
|
169
|
+
puts Color.red("Internal drift (same version, different lockfile hashes):")
|
|
170
|
+
puts
|
|
171
|
+
internal_drifts.each do |purl, data|
|
|
172
|
+
puts " #{purl}"
|
|
173
|
+
data[:lockfile_integrities].each do |integrity|
|
|
174
|
+
puts " #{integrity}"
|
|
175
|
+
end
|
|
176
|
+
puts
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
if registry_mismatches.any?
|
|
181
|
+
puts Color.red("Registry mismatch (lockfile differs from registry):")
|
|
182
|
+
puts
|
|
183
|
+
registry_mismatches.each do |mismatch|
|
|
184
|
+
puts " #{mismatch[:purl]}"
|
|
185
|
+
puts " lockfile: #{mismatch[:lockfile]}"
|
|
186
|
+
puts " registry: #{mismatch[:registry]}"
|
|
187
|
+
puts
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
total = internal_drifts.size + registry_mismatches.size
|
|
192
|
+
puts "#{total} integrity issue(s) found"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def format_dep_json(dep)
|
|
196
|
+
{
|
|
197
|
+
name: dep[:name],
|
|
198
|
+
version: dep[:requirement],
|
|
199
|
+
ecosystem: dep[:ecosystem],
|
|
200
|
+
purl: dep[:purl],
|
|
201
|
+
integrity: dep[:integrity],
|
|
202
|
+
manifest: dep[:manifest_path]
|
|
203
|
+
}
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def compute_dependencies_at_commit(target_commit, repo)
|
|
207
|
+
branch_name = @options[:branch] || repo.default_branch
|
|
208
|
+
branch = Models::Branch.first(name: branch_name)
|
|
209
|
+
return [] unless branch
|
|
210
|
+
|
|
211
|
+
snapshot_commit = branch.commits_dataset
|
|
212
|
+
.join(:dependency_snapshots, commit_id: :id)
|
|
213
|
+
.where { Sequel[:commits][:committed_at] <= target_commit.committed_at }
|
|
214
|
+
.order(Sequel.desc(Sequel[:commits][:committed_at]))
|
|
215
|
+
.distinct
|
|
216
|
+
.first
|
|
217
|
+
|
|
218
|
+
deps = {}
|
|
219
|
+
if snapshot_commit
|
|
220
|
+
snapshot_commit.dependency_snapshots.each do |s|
|
|
221
|
+
key = [s.manifest.path, s.name]
|
|
222
|
+
deps[key] = {
|
|
223
|
+
manifest_path: s.manifest.path,
|
|
224
|
+
name: s.name,
|
|
225
|
+
ecosystem: s.ecosystem,
|
|
226
|
+
kind: s.manifest.kind,
|
|
227
|
+
manifest_kind: s.manifest.kind,
|
|
228
|
+
purl: s.purl,
|
|
229
|
+
requirement: s.requirement,
|
|
230
|
+
dependency_type: s.dependency_type,
|
|
231
|
+
integrity: s.integrity
|
|
232
|
+
}
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
deps.values
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def parse_options
|
|
240
|
+
options = {}
|
|
241
|
+
|
|
242
|
+
parser = OptionParser.new do |opts|
|
|
243
|
+
opts.banner = "Usage: git pkgs integrity [options]"
|
|
244
|
+
opts.separator ""
|
|
245
|
+
opts.separator "Show integrity hashes from lockfiles. Hashes come from lockfile checksums"
|
|
246
|
+
opts.separator "(Gemfile.lock CHECKSUMS, package-lock.json integrity fields, etc.)"
|
|
247
|
+
|
|
248
|
+
opts.on("-r", "--ref=REF", "Git ref to check (default: HEAD)") do |v|
|
|
249
|
+
options[:ref] = v
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
opts.on("-e", "--ecosystem=NAME", "Filter by ecosystem") do |v|
|
|
253
|
+
options[:ecosystem] = v
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
opts.on("-b", "--branch=NAME", "Branch context for database queries") do |v|
|
|
257
|
+
options[:branch] = v
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
opts.on("-f", "--format=FORMAT", "Output format (text, json)") do |v|
|
|
261
|
+
options[:format] = v
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
opts.on("--drift", "Detect packages with different hashes for same version") do
|
|
265
|
+
options[:drift] = true
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
opts.on("--stateless", "Parse manifests directly without database") do
|
|
269
|
+
options[:stateless] = true
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
opts.on("--no-pager", "Do not pipe output into a pager") do
|
|
273
|
+
options[:no_pager] = true
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
opts.on("-h", "--help", "Show this help") do
|
|
277
|
+
puts opts
|
|
278
|
+
exit
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
parser.parse!(@args)
|
|
283
|
+
options
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
@@ -118,9 +118,11 @@ module Git
|
|
|
118
118
|
end
|
|
119
119
|
|
|
120
120
|
packages = deps.map do |dep|
|
|
121
|
-
|
|
121
|
+
versioned_purl = PurlHelper.build_purl(ecosystem: dep[:ecosystem], name: dep[:name], version: dep[:requirement])
|
|
122
|
+
base_purl = PurlHelper.build_purl(ecosystem: dep[:ecosystem], name: dep[:name])
|
|
122
123
|
{
|
|
123
|
-
purl:
|
|
124
|
+
purl: versioned_purl.to_s,
|
|
125
|
+
base_purl: base_purl.to_s,
|
|
124
126
|
name: dep[:name],
|
|
125
127
|
ecosystem: dep[:ecosystem],
|
|
126
128
|
version: dep[:requirement],
|
|
@@ -128,11 +130,9 @@ module Git
|
|
|
128
130
|
}
|
|
129
131
|
end.uniq { |p| p[:purl] }
|
|
130
132
|
|
|
131
|
-
enrich_packages(packages
|
|
133
|
+
enrich_packages(packages)
|
|
132
134
|
|
|
133
135
|
packages.each do |pkg|
|
|
134
|
-
db_pkg = Models::Package.first(purl: pkg[:purl])
|
|
135
|
-
pkg[:license] = db_pkg&.license
|
|
136
136
|
pkg[:violation] = check_violation(pkg[:license])
|
|
137
137
|
end
|
|
138
138
|
|
|
@@ -183,9 +183,14 @@ module Git
|
|
|
183
183
|
license.downcase.include?(pattern.downcase)
|
|
184
184
|
end
|
|
185
185
|
|
|
186
|
-
def enrich_packages(
|
|
186
|
+
def enrich_packages(packages)
|
|
187
|
+
client = EcosystemsClient.new
|
|
188
|
+
|
|
189
|
+
# Enrich package-level data (license, latest version)
|
|
190
|
+
base_purls = packages.map { |p| p[:base_purl] }.uniq
|
|
191
|
+
|
|
187
192
|
packages_by_purl = {}
|
|
188
|
-
|
|
193
|
+
base_purls.each do |purl|
|
|
189
194
|
parsed = Purl::PackageURL.parse(purl)
|
|
190
195
|
ecosystem = PurlHelper::ECOSYSTEM_TO_PURL_TYPE.invert[parsed.type] || parsed.type
|
|
191
196
|
pkg = Models::Package.find_or_create_by_purl(
|
|
@@ -196,19 +201,52 @@ module Git
|
|
|
196
201
|
packages_by_purl[purl] = pkg
|
|
197
202
|
end
|
|
198
203
|
|
|
199
|
-
|
|
200
|
-
return if stale_purls.empty?
|
|
204
|
+
stale_pkg_purls = packages_by_purl.select { |_, pkg| pkg.needs_enrichment? }.keys
|
|
201
205
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
+
if stale_pkg_purls.any?
|
|
207
|
+
begin
|
|
208
|
+
results = Spinner.with_spinner("Fetching package metadata...") do
|
|
209
|
+
client.bulk_lookup(stale_pkg_purls)
|
|
210
|
+
end
|
|
211
|
+
results.each do |purl, data|
|
|
212
|
+
packages_by_purl[purl]&.enrich_from_api(data)
|
|
213
|
+
end
|
|
214
|
+
rescue EcosystemsClient::ApiError => e
|
|
215
|
+
$stderr.puts "Warning: Could not fetch package data: #{e.message}" unless Git::Pkgs.quiet
|
|
206
216
|
end
|
|
207
|
-
|
|
208
|
-
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Enrich version-level data (license, integrity, published_at)
|
|
220
|
+
versions_by_purl = {}
|
|
221
|
+
packages.each do |pkg|
|
|
222
|
+
version = Models::Version.find_or_create_by_purl(
|
|
223
|
+
purl: pkg[:purl],
|
|
224
|
+
package_purl: pkg[:base_purl]
|
|
225
|
+
)
|
|
226
|
+
versions_by_purl[pkg[:purl]] = version
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
stale_version_purls = versions_by_purl.select { |_, v| v.needs_enrichment? }.keys
|
|
230
|
+
|
|
231
|
+
if stale_version_purls.any?
|
|
232
|
+
begin
|
|
233
|
+
Spinner.with_spinner("Fetching version metadata...") do
|
|
234
|
+
stale_version_purls.each do |purl|
|
|
235
|
+
data = client.lookup_version(purl)
|
|
236
|
+
versions_by_purl[purl]&.enrich_from_api(data) if data
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
rescue EcosystemsClient::ApiError => e
|
|
240
|
+
$stderr.puts "Warning: Could not fetch version data: #{e.message}" unless Git::Pkgs.quiet
|
|
209
241
|
end
|
|
210
|
-
|
|
211
|
-
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Apply enriched data to packages - version license takes priority
|
|
245
|
+
packages.each do |pkg|
|
|
246
|
+
db_pkg = packages_by_purl[pkg[:base_purl]]
|
|
247
|
+
db_version = versions_by_purl[pkg[:purl]]
|
|
248
|
+
|
|
249
|
+
pkg[:license] = db_version&.license || db_pkg&.license
|
|
212
250
|
end
|
|
213
251
|
end
|
|
214
252
|
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "sbom"
|
|
5
|
+
|
|
6
|
+
module Git
|
|
7
|
+
module Pkgs
|
|
8
|
+
module Commands
|
|
9
|
+
class Sbom
|
|
10
|
+
include Output
|
|
11
|
+
|
|
12
|
+
def self.description
|
|
13
|
+
"Export dependencies as SBOM (SPDX or CycloneDX)"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(args)
|
|
17
|
+
@args = args.dup
|
|
18
|
+
@options = parse_options
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def run
|
|
22
|
+
repo = Repository.new
|
|
23
|
+
use_stateless = @options[:stateless] || !Database.exists?(repo.git_dir)
|
|
24
|
+
|
|
25
|
+
if use_stateless
|
|
26
|
+
Database.connect_memory
|
|
27
|
+
deps = get_dependencies_stateless(repo)
|
|
28
|
+
else
|
|
29
|
+
Database.connect(repo.git_dir)
|
|
30
|
+
deps = get_dependencies_with_database(repo)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
if deps.empty?
|
|
34
|
+
empty_result "No dependencies found"
|
|
35
|
+
return
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
if @options[:ecosystem]
|
|
39
|
+
deps = deps.select { |d| d[:ecosystem].downcase == @options[:ecosystem].downcase }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
deps = Analyzer.pair_manifests_with_lockfiles(deps)
|
|
43
|
+
|
|
44
|
+
if deps.empty?
|
|
45
|
+
empty_result "No dependencies found"
|
|
46
|
+
return
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
packages = build_packages(deps)
|
|
50
|
+
enrich_packages(packages) unless @options[:skip_enrichment]
|
|
51
|
+
|
|
52
|
+
output_sbom(repo, packages)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def build_packages(deps)
|
|
56
|
+
deps.map do |dep|
|
|
57
|
+
purl = PurlHelper.build_purl(ecosystem: dep[:ecosystem], name: dep[:name], version: dep[:requirement])
|
|
58
|
+
{
|
|
59
|
+
purl: purl.to_s,
|
|
60
|
+
name: dep[:name],
|
|
61
|
+
ecosystem: dep[:ecosystem],
|
|
62
|
+
version: dep[:requirement],
|
|
63
|
+
integrity: dep[:integrity]
|
|
64
|
+
}
|
|
65
|
+
end.uniq { |p| p[:purl] }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def enrich_packages(packages)
|
|
69
|
+
client = EcosystemsClient.new
|
|
70
|
+
|
|
71
|
+
# Enrich package-level data (license, latest version)
|
|
72
|
+
base_purls = packages.map { |p| PurlHelper.build_purl(ecosystem: p[:ecosystem], name: p[:name]).to_s }
|
|
73
|
+
|
|
74
|
+
packages_by_purl = {}
|
|
75
|
+
base_purls.each do |purl|
|
|
76
|
+
parsed = Purl::PackageURL.parse(purl)
|
|
77
|
+
ecosystem = PurlHelper::ECOSYSTEM_TO_PURL_TYPE.invert[parsed.type] || parsed.type
|
|
78
|
+
pkg = Models::Package.find_or_create_by_purl(
|
|
79
|
+
purl: purl,
|
|
80
|
+
ecosystem: ecosystem,
|
|
81
|
+
name: parsed.name
|
|
82
|
+
)
|
|
83
|
+
packages_by_purl[purl] = pkg
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
stale_pkg_purls = packages_by_purl.select { |_, pkg| pkg.needs_enrichment? }.keys
|
|
87
|
+
|
|
88
|
+
if stale_pkg_purls.any?
|
|
89
|
+
begin
|
|
90
|
+
results = Spinner.with_spinner("Fetching package metadata...") do
|
|
91
|
+
client.bulk_lookup(stale_pkg_purls)
|
|
92
|
+
end
|
|
93
|
+
results.each do |purl, data|
|
|
94
|
+
packages_by_purl[purl]&.enrich_from_api(data)
|
|
95
|
+
end
|
|
96
|
+
rescue EcosystemsClient::ApiError => e
|
|
97
|
+
$stderr.puts "Warning: Could not fetch package data: #{e.message}" unless Git::Pkgs.quiet
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Enrich version-level data (integrity, published_at)
|
|
102
|
+
versions_by_purl = {}
|
|
103
|
+
packages.each do |pkg|
|
|
104
|
+
base_purl = PurlHelper.build_purl(ecosystem: pkg[:ecosystem], name: pkg[:name]).to_s
|
|
105
|
+
version = Models::Version.find_or_create_by_purl(
|
|
106
|
+
purl: pkg[:purl],
|
|
107
|
+
package_purl: base_purl
|
|
108
|
+
)
|
|
109
|
+
versions_by_purl[pkg[:purl]] = version
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
stale_version_purls = versions_by_purl.select { |_, v| v.needs_enrichment? }.keys
|
|
113
|
+
|
|
114
|
+
if stale_version_purls.any?
|
|
115
|
+
begin
|
|
116
|
+
Spinner.with_spinner("Fetching version metadata...") do
|
|
117
|
+
stale_version_purls.each do |purl|
|
|
118
|
+
data = client.lookup_version(purl)
|
|
119
|
+
versions_by_purl[purl]&.enrich_from_api(data) if data
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
rescue EcosystemsClient::ApiError => e
|
|
123
|
+
$stderr.puts "Warning: Could not fetch version data: #{e.message}" unless Git::Pkgs.quiet
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Apply enriched data to packages
|
|
128
|
+
packages.each do |pkg|
|
|
129
|
+
base_purl = PurlHelper.build_purl(ecosystem: pkg[:ecosystem], name: pkg[:name]).to_s
|
|
130
|
+
db_pkg = packages_by_purl[base_purl]
|
|
131
|
+
db_version = versions_by_purl[pkg[:purl]]
|
|
132
|
+
|
|
133
|
+
pkg[:license] ||= db_version&.license || db_pkg&.license
|
|
134
|
+
pkg[:integrity] ||= db_version&.integrity
|
|
135
|
+
pkg[:supplier_name] ||= db_pkg&.supplier_name
|
|
136
|
+
pkg[:supplier_type] ||= db_pkg&.supplier_type
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def output_sbom(repo, packages)
|
|
141
|
+
sbom_type = @options[:type]&.to_sym || :cyclonedx
|
|
142
|
+
format = @options[:format]&.to_sym || :json
|
|
143
|
+
|
|
144
|
+
generator = ::Sbom::Generator.new(sbom_type: sbom_type, format: format)
|
|
145
|
+
|
|
146
|
+
sbom_packages = packages.map do |pkg|
|
|
147
|
+
sbom_pkg = ::Sbom::Data::Package.new
|
|
148
|
+
sbom_pkg.name = pkg[:name]
|
|
149
|
+
sbom_pkg.version = pkg[:version]
|
|
150
|
+
sbom_pkg.purl = pkg[:purl]
|
|
151
|
+
sbom_pkg.license_concluded = pkg[:license] if pkg[:license]
|
|
152
|
+
|
|
153
|
+
if pkg[:supplier_name]
|
|
154
|
+
sbom_pkg.set_supplier(pkg[:supplier_type] || "organization", pkg[:supplier_name])
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
if pkg[:integrity]
|
|
158
|
+
algorithm, hash = parse_integrity(pkg[:integrity])
|
|
159
|
+
sbom_pkg.add_checksum(algorithm, hash) if algorithm && hash
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
sbom_pkg
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
project_name = @options[:name] || File.basename(repo.path)
|
|
166
|
+
generator.generate(project_name, { packages: sbom_packages })
|
|
167
|
+
puts generator.output
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def parse_integrity(integrity)
|
|
171
|
+
return nil unless integrity
|
|
172
|
+
|
|
173
|
+
case integrity
|
|
174
|
+
when /^sha256[-:=](.+)$/i
|
|
175
|
+
["SHA256", $1]
|
|
176
|
+
when /^sha512[-:=](.+)$/i
|
|
177
|
+
["SHA512", $1]
|
|
178
|
+
when /^sha1[-:=](.+)$/i
|
|
179
|
+
["SHA1", $1]
|
|
180
|
+
when /^md5[-:=](.+)$/i
|
|
181
|
+
["MD5", $1]
|
|
182
|
+
when /^h1:(.+)$/
|
|
183
|
+
# Go modules use base64-encoded SHA256 in go.sum
|
|
184
|
+
# SPDX/CycloneDX require hex, so convert
|
|
185
|
+
require "base64"
|
|
186
|
+
hex = Base64.decode64($1).unpack1("H*")
|
|
187
|
+
["SHA256", hex]
|
|
188
|
+
else
|
|
189
|
+
nil
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def parse_options
|
|
194
|
+
options = {}
|
|
195
|
+
|
|
196
|
+
parser = OptionParser.new do |opts|
|
|
197
|
+
opts.banner = "Usage: git pkgs sbom [options]"
|
|
198
|
+
opts.separator ""
|
|
199
|
+
opts.separator "Export dependencies as SBOM (Software Bill of Materials)."
|
|
200
|
+
opts.separator ""
|
|
201
|
+
opts.separator "Options:"
|
|
202
|
+
|
|
203
|
+
opts.on("-t", "--type=TYPE", "SBOM type: cyclonedx (default) or spdx") do |v|
|
|
204
|
+
options[:type] = v.downcase
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
opts.on("-f", "--format=FORMAT", "Output format: json (default) or xml") do |v|
|
|
208
|
+
options[:format] = v.downcase
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
opts.on("-n", "--name=NAME", "Project name (default: repository directory name)") do |v|
|
|
212
|
+
options[:name] = v
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
opts.on("-e", "--ecosystem=NAME", "Filter by ecosystem") do |v|
|
|
216
|
+
options[:ecosystem] = v
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
opts.on("-r", "--ref=REF", "Git ref to export (default: HEAD)") do |v|
|
|
220
|
+
options[:ref] = v
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
opts.on("--skip-enrichment", "Skip fetching license data from registries") do
|
|
224
|
+
options[:skip_enrichment] = true
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
opts.on("--stateless", "Parse manifests directly without database") do
|
|
228
|
+
options[:stateless] = true
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
opts.on("-h", "--help", "Show this help") do
|
|
232
|
+
puts opts
|
|
233
|
+
exit
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
parser.parse!(@args)
|
|
238
|
+
options
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def get_dependencies_stateless(repo)
|
|
242
|
+
ref = @options[:ref] || "HEAD"
|
|
243
|
+
commit_sha = repo.rev_parse(ref)
|
|
244
|
+
rugged_commit = repo.lookup(commit_sha)
|
|
245
|
+
|
|
246
|
+
error "Could not resolve '#{ref}'" unless rugged_commit
|
|
247
|
+
|
|
248
|
+
analyzer = Analyzer.new(repo)
|
|
249
|
+
analyzer.dependencies_at_commit(rugged_commit)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def get_dependencies_with_database(repo)
|
|
253
|
+
ref = @options[:ref] || "HEAD"
|
|
254
|
+
commit_sha = repo.rev_parse(ref)
|
|
255
|
+
target_commit = Models::Commit.first(sha: commit_sha)
|
|
256
|
+
|
|
257
|
+
return get_dependencies_stateless(repo) unless target_commit
|
|
258
|
+
|
|
259
|
+
branch_name = repo.default_branch
|
|
260
|
+
branch = Models::Branch.first(name: branch_name)
|
|
261
|
+
return [] unless branch
|
|
262
|
+
|
|
263
|
+
compute_dependencies_at_commit(target_commit, branch)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def compute_dependencies_at_commit(target_commit, branch)
|
|
267
|
+
snapshot_commit = branch.commits_dataset
|
|
268
|
+
.join(:dependency_snapshots, commit_id: :id)
|
|
269
|
+
.where { Sequel[:commits][:committed_at] <= target_commit.committed_at }
|
|
270
|
+
.order(Sequel.desc(Sequel[:commits][:committed_at]))
|
|
271
|
+
.distinct
|
|
272
|
+
.first
|
|
273
|
+
|
|
274
|
+
deps = {}
|
|
275
|
+
if snapshot_commit
|
|
276
|
+
snapshot_commit.dependency_snapshots.each do |s|
|
|
277
|
+
key = [s.manifest.path, s.name]
|
|
278
|
+
deps[key] = {
|
|
279
|
+
manifest_path: s.manifest.path,
|
|
280
|
+
manifest_kind: s.manifest.kind,
|
|
281
|
+
name: s.name,
|
|
282
|
+
ecosystem: s.ecosystem,
|
|
283
|
+
requirement: s.requirement,
|
|
284
|
+
dependency_type: s.dependency_type,
|
|
285
|
+
integrity: s.integrity
|
|
286
|
+
}
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
if snapshot_commit && snapshot_commit.id != target_commit.id
|
|
291
|
+
commit_ids = branch.commits_dataset.select_map(Sequel[:commits][:id])
|
|
292
|
+
changes = Models::DependencyChange
|
|
293
|
+
.join(:commits, id: :commit_id)
|
|
294
|
+
.where(Sequel[:commits][:id] => commit_ids)
|
|
295
|
+
.where { Sequel[:commits][:committed_at] > snapshot_commit.committed_at }
|
|
296
|
+
.where { Sequel[:commits][:committed_at] <= target_commit.committed_at }
|
|
297
|
+
.order(Sequel[:commits][:committed_at])
|
|
298
|
+
.eager(:manifest)
|
|
299
|
+
.all
|
|
300
|
+
|
|
301
|
+
changes.each do |change|
|
|
302
|
+
key = [change.manifest.path, change.name]
|
|
303
|
+
case change.change_type
|
|
304
|
+
when "added", "modified"
|
|
305
|
+
deps[key] = {
|
|
306
|
+
manifest_path: change.manifest.path,
|
|
307
|
+
manifest_kind: change.manifest.kind,
|
|
308
|
+
name: change.name,
|
|
309
|
+
ecosystem: change.ecosystem,
|
|
310
|
+
requirement: change.requirement,
|
|
311
|
+
dependency_type: change.dependency_type,
|
|
312
|
+
integrity: nil
|
|
313
|
+
}
|
|
314
|
+
when "removed"
|
|
315
|
+
deps.delete(key)
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
deps.values
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
|
@@ -43,7 +43,8 @@ module Git
|
|
|
43
43
|
ecosystem: s.ecosystem,
|
|
44
44
|
purl: s.purl,
|
|
45
45
|
requirement: s.requirement,
|
|
46
|
-
dependency_type: s.dependency_type
|
|
46
|
+
dependency_type: s.dependency_type,
|
|
47
|
+
integrity: s.integrity
|
|
47
48
|
}
|
|
48
49
|
end
|
|
49
50
|
end
|
|
@@ -110,6 +111,7 @@ module Git
|
|
|
110
111
|
s.purl = dep_info[:purl]
|
|
111
112
|
s.requirement = dep_info[:requirement]
|
|
112
113
|
s.dependency_type = dep_info[:dependency_type]
|
|
114
|
+
s.integrity = dep_info[:integrity]
|
|
113
115
|
end
|
|
114
116
|
end
|
|
115
117
|
end
|
data/lib/git/pkgs/config.rb
CHANGED
|
@@ -6,7 +6,7 @@ require "open3"
|
|
|
6
6
|
module Git
|
|
7
7
|
module Pkgs
|
|
8
8
|
module Config
|
|
9
|
-
# File patterns ignored by default (SBOM formats
|
|
9
|
+
# File patterns ignored by default (SBOM formats duplicate info from actual lockfiles)
|
|
10
10
|
DEFAULT_IGNORED_FILES = %w[
|
|
11
11
|
cyclonedx.xml
|
|
12
12
|
cyclonedx.json
|
|
@@ -14,7 +14,6 @@ module Git
|
|
|
14
14
|
*.cdx.json
|
|
15
15
|
*.spdx
|
|
16
16
|
*.spdx.json
|
|
17
|
-
go.sum
|
|
18
17
|
].freeze
|
|
19
18
|
|
|
20
19
|
def self.ignored_dirs
|
data/lib/git/pkgs/database.rb
CHANGED
|
@@ -15,7 +15,7 @@ module Git
|
|
|
15
15
|
module Pkgs
|
|
16
16
|
class Database
|
|
17
17
|
DB_FILE = "pkgs.sqlite3"
|
|
18
|
-
SCHEMA_VERSION =
|
|
18
|
+
SCHEMA_VERSION = 5
|
|
19
19
|
|
|
20
20
|
class << self
|
|
21
21
|
attr_accessor :db
|
|
@@ -179,6 +179,7 @@ module Git
|
|
|
179
179
|
String :purl
|
|
180
180
|
String :requirement
|
|
181
181
|
String :dependency_type
|
|
182
|
+
String :integrity, text: true
|
|
182
183
|
DateTime :created_at
|
|
183
184
|
DateTime :updated_at
|
|
184
185
|
end
|
|
@@ -193,6 +194,8 @@ module Git
|
|
|
193
194
|
String :description, text: true
|
|
194
195
|
String :homepage
|
|
195
196
|
String :repository_url
|
|
197
|
+
String :supplier_name
|
|
198
|
+
String :supplier_type
|
|
196
199
|
String :source
|
|
197
200
|
DateTime :enriched_at
|
|
198
201
|
DateTime :vulns_synced_at
|
|
@@ -291,21 +294,12 @@ module Git
|
|
|
291
294
|
def self.check_version!
|
|
292
295
|
return unless needs_upgrade?
|
|
293
296
|
|
|
294
|
-
migrate!
|
|
295
|
-
end
|
|
296
|
-
|
|
297
|
-
def self.migrate!
|
|
298
297
|
stored = stored_version || 0
|
|
299
|
-
|
|
300
|
-
# Migration from v1 to v2: add vuln tables
|
|
301
|
-
if stored < 2
|
|
302
|
-
migrate_to_v2!
|
|
303
|
-
end
|
|
304
|
-
|
|
305
|
-
set_version
|
|
306
|
-
refresh_models
|
|
298
|
+
raise SchemaVersionError, "Database schema is v#{stored}, expected v#{SCHEMA_VERSION}. Run 'git pkgs upgrade' to rebuild."
|
|
307
299
|
end
|
|
308
300
|
|
|
301
|
+
# Legacy migration kept for reference, no longer used.
|
|
302
|
+
# All schema changes now require full rebuild via 'git pkgs upgrade'.
|
|
309
303
|
def self.migrate_to_v2!
|
|
310
304
|
@db.create_table?(:packages) do
|
|
311
305
|
primary_key :id
|
|
@@ -47,8 +47,54 @@ module Git
|
|
|
47
47
|
results[purl]
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
+
# Lookup a specific version by purl with version.
|
|
51
|
+
# Returns version-level data including integrity hash.
|
|
52
|
+
#
|
|
53
|
+
# @param purl [String] package URL with version (e.g., "pkg:gem/rake@13.3.1")
|
|
54
|
+
# @return [Hash, nil] version data or nil if not found
|
|
55
|
+
def lookup_version(purl)
|
|
56
|
+
parsed = Purl.parse(purl)
|
|
57
|
+
return nil unless parsed.version
|
|
58
|
+
|
|
59
|
+
url = parsed.ecosystems_version_api_url
|
|
60
|
+
return nil unless url
|
|
61
|
+
|
|
62
|
+
fetch_url(url)
|
|
63
|
+
rescue Purl::Error
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Batch lookup versions by purl.
|
|
68
|
+
# Fetches each version individually (no batch API for versions).
|
|
69
|
+
#
|
|
70
|
+
# @param purls [Array<String>] array of versioned package URLs
|
|
71
|
+
# @return [Hash<String, Hash>] hash keyed by purl with version data
|
|
72
|
+
def bulk_lookup_versions(purls)
|
|
73
|
+
results = {}
|
|
74
|
+
purls.each do |purl|
|
|
75
|
+
data = lookup_version(purl)
|
|
76
|
+
results[purl] = data if data
|
|
77
|
+
end
|
|
78
|
+
results
|
|
79
|
+
end
|
|
80
|
+
|
|
50
81
|
private
|
|
51
82
|
|
|
83
|
+
def fetch_url(url)
|
|
84
|
+
uri = URI(url)
|
|
85
|
+
request = Net::HTTP::Get.new(uri)
|
|
86
|
+
request["Accept"] = "application/json"
|
|
87
|
+
execute_request(uri, request)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def get(path)
|
|
91
|
+
uri = URI("#{API_BASE}#{path}")
|
|
92
|
+
request = Net::HTTP::Get.new(uri)
|
|
93
|
+
request["Accept"] = "application/json"
|
|
94
|
+
|
|
95
|
+
execute_request(uri, request)
|
|
96
|
+
end
|
|
97
|
+
|
|
52
98
|
def post(path, payload)
|
|
53
99
|
uri = URI("#{API_BASE}#{path}")
|
|
54
100
|
request = Net::HTTP::Post.new(uri)
|
|
@@ -56,16 +56,39 @@ module Git
|
|
|
56
56
|
|
|
57
57
|
# Update package with data from ecosyste.ms API
|
|
58
58
|
def enrich_from_api(data)
|
|
59
|
+
supplier_name, supplier_type = extract_supplier(data)
|
|
60
|
+
|
|
59
61
|
update(
|
|
60
62
|
latest_version: data["latest_release_number"],
|
|
61
63
|
license: (data["normalized_licenses"] || []).first,
|
|
62
64
|
description: data["description"],
|
|
63
65
|
homepage: data["homepage"],
|
|
64
66
|
repository_url: data["repository_url"],
|
|
67
|
+
supplier_name: supplier_name,
|
|
68
|
+
supplier_type: supplier_type,
|
|
65
69
|
enriched_at: Time.now
|
|
66
70
|
)
|
|
67
71
|
end
|
|
68
72
|
|
|
73
|
+
# Extract supplier info from API response
|
|
74
|
+
# Prefers owner_record (org), falls back to first maintainer
|
|
75
|
+
def extract_supplier(data)
|
|
76
|
+
owner = data["owner_record"]
|
|
77
|
+
if owner && owner["name"]
|
|
78
|
+
type = owner["kind"] == "organization" ? "organization" : "person"
|
|
79
|
+
return [owner["name"], type]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
maintainers = data["maintainers"]
|
|
83
|
+
if maintainers&.any?
|
|
84
|
+
first = maintainers.first
|
|
85
|
+
name = first["name"] || first["login"]
|
|
86
|
+
return [name, "person"] if name
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
[nil, nil]
|
|
90
|
+
end
|
|
91
|
+
|
|
69
92
|
def vulnerabilities
|
|
70
93
|
osv_ecosystem = Ecosystems.to_osv(ecosystem)
|
|
71
94
|
return [] unless osv_ecosystem
|
|
@@ -4,6 +4,8 @@ module Git
|
|
|
4
4
|
module Pkgs
|
|
5
5
|
module Models
|
|
6
6
|
class Version < Sequel::Model
|
|
7
|
+
STALE_THRESHOLD = 86400 # 24 hours
|
|
8
|
+
|
|
7
9
|
many_to_one :package, key: :package_purl, primary_key: :purl
|
|
8
10
|
|
|
9
11
|
def parsed_purl
|
|
@@ -21,6 +23,33 @@ module Git
|
|
|
21
23
|
def enriched?
|
|
22
24
|
!enriched_at.nil?
|
|
23
25
|
end
|
|
26
|
+
|
|
27
|
+
def needs_enrichment?
|
|
28
|
+
enriched_at.nil? || enriched_at < Time.now - STALE_THRESHOLD
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def enrich_from_api(data)
|
|
32
|
+
licenses = data["licenses"]
|
|
33
|
+
license = case licenses
|
|
34
|
+
when Array then licenses.first
|
|
35
|
+
when String then licenses
|
|
36
|
+
end
|
|
37
|
+
license ||= data["spdx_expression"]
|
|
38
|
+
|
|
39
|
+
update(
|
|
40
|
+
license: license,
|
|
41
|
+
integrity: data["integrity"],
|
|
42
|
+
published_at: data["published_at"] ? Time.parse(data["published_at"]) : nil,
|
|
43
|
+
enriched_at: Time.now
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.find_or_create_by_purl(purl:, package_purl:)
|
|
48
|
+
existing = first(purl: purl)
|
|
49
|
+
return existing if existing
|
|
50
|
+
|
|
51
|
+
create(purl: purl, package_purl: package_purl)
|
|
52
|
+
end
|
|
24
53
|
end
|
|
25
54
|
end
|
|
26
55
|
end
|
data/lib/git/pkgs/version.rb
CHANGED
data/lib/git/pkgs.rb
CHANGED
|
@@ -49,12 +49,15 @@ require_relative "pkgs/commands/completions"
|
|
|
49
49
|
require_relative "pkgs/commands/vulns"
|
|
50
50
|
require_relative "pkgs/commands/outdated"
|
|
51
51
|
require_relative "pkgs/commands/licenses"
|
|
52
|
+
require_relative "pkgs/commands/integrity"
|
|
53
|
+
require_relative "pkgs/commands/sbom"
|
|
52
54
|
|
|
53
55
|
module Git
|
|
54
56
|
module Pkgs
|
|
55
57
|
class Error < StandardError; end
|
|
56
58
|
class NotInitializedError < Error; end
|
|
57
59
|
class NotInGitRepoError < Error; end
|
|
60
|
+
class SchemaVersionError < Error; end
|
|
58
61
|
|
|
59
62
|
class << self
|
|
60
63
|
attr_accessor :quiet, :git_dir, :work_tree, :db_path, :batch_size, :snapshot_interval, :threads
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: git-pkgs
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrew Nesbitt
|
|
@@ -57,14 +57,14 @@ dependencies:
|
|
|
57
57
|
requirements:
|
|
58
58
|
- - "~>"
|
|
59
59
|
- !ruby/object:Gem::Version
|
|
60
|
-
version: '15.
|
|
60
|
+
version: '15.3'
|
|
61
61
|
type: :runtime
|
|
62
62
|
prerelease: false
|
|
63
63
|
version_requirements: !ruby/object:Gem::Requirement
|
|
64
64
|
requirements:
|
|
65
65
|
- - "~>"
|
|
66
66
|
- !ruby/object:Gem::Version
|
|
67
|
-
version: '15.
|
|
67
|
+
version: '15.3'
|
|
68
68
|
- !ruby/object:Gem::Dependency
|
|
69
69
|
name: vers
|
|
70
70
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -86,6 +86,9 @@ dependencies:
|
|
|
86
86
|
- - "~>"
|
|
87
87
|
- !ruby/object:Gem::Version
|
|
88
88
|
version: '1.7'
|
|
89
|
+
- - ">="
|
|
90
|
+
- !ruby/object:Gem::Version
|
|
91
|
+
version: 1.7.1
|
|
89
92
|
type: :runtime
|
|
90
93
|
prerelease: false
|
|
91
94
|
version_requirements: !ruby/object:Gem::Requirement
|
|
@@ -93,6 +96,9 @@ dependencies:
|
|
|
93
96
|
- - "~>"
|
|
94
97
|
- !ruby/object:Gem::Version
|
|
95
98
|
version: '1.7'
|
|
99
|
+
- - ">="
|
|
100
|
+
- !ruby/object:Gem::Version
|
|
101
|
+
version: 1.7.1
|
|
96
102
|
- !ruby/object:Gem::Dependency
|
|
97
103
|
name: sarif-ruby
|
|
98
104
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -107,6 +113,34 @@ dependencies:
|
|
|
107
113
|
- - ">="
|
|
108
114
|
- !ruby/object:Gem::Version
|
|
109
115
|
version: '0'
|
|
116
|
+
- !ruby/object:Gem::Dependency
|
|
117
|
+
name: sbom
|
|
118
|
+
requirement: !ruby/object:Gem::Requirement
|
|
119
|
+
requirements:
|
|
120
|
+
- - "~>"
|
|
121
|
+
- !ruby/object:Gem::Version
|
|
122
|
+
version: '0.4'
|
|
123
|
+
type: :runtime
|
|
124
|
+
prerelease: false
|
|
125
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
126
|
+
requirements:
|
|
127
|
+
- - "~>"
|
|
128
|
+
- !ruby/object:Gem::Version
|
|
129
|
+
version: '0.4'
|
|
130
|
+
- !ruby/object:Gem::Dependency
|
|
131
|
+
name: base64
|
|
132
|
+
requirement: !ruby/object:Gem::Requirement
|
|
133
|
+
requirements:
|
|
134
|
+
- - ">="
|
|
135
|
+
- !ruby/object:Gem::Version
|
|
136
|
+
version: '0'
|
|
137
|
+
type: :runtime
|
|
138
|
+
prerelease: false
|
|
139
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
140
|
+
requirements:
|
|
141
|
+
- - ">="
|
|
142
|
+
- !ruby/object:Gem::Version
|
|
143
|
+
version: '0'
|
|
110
144
|
description: A git subcommand for analyzing package/dependency usage in git repositories
|
|
111
145
|
over time
|
|
112
146
|
email:
|
|
@@ -137,10 +171,12 @@ files:
|
|
|
137
171
|
- lib/git/pkgs/commands/hooks.rb
|
|
138
172
|
- lib/git/pkgs/commands/info.rb
|
|
139
173
|
- lib/git/pkgs/commands/init.rb
|
|
174
|
+
- lib/git/pkgs/commands/integrity.rb
|
|
140
175
|
- lib/git/pkgs/commands/licenses.rb
|
|
141
176
|
- lib/git/pkgs/commands/list.rb
|
|
142
177
|
- lib/git/pkgs/commands/log.rb
|
|
143
178
|
- lib/git/pkgs/commands/outdated.rb
|
|
179
|
+
- lib/git/pkgs/commands/sbom.rb
|
|
144
180
|
- lib/git/pkgs/commands/schema.rb
|
|
145
181
|
- lib/git/pkgs/commands/search.rb
|
|
146
182
|
- lib/git/pkgs/commands/show.rb
|