Skip to content

Commit 6645ba5

Browse files
a-orenclaude
andauthored
feat(sbom): extract SHA-256 hashes from uv.lock for uv provider (#533)
* feat(sbom): extract SHA-256 hashes from uv.lock for uv provider Parse uv.lock TOML to extract SHA-256 artifact hashes (from sdist or wheels entries) and attach them to the dependency graph, so generated CycloneDX SBOMs include per-component hash data. This enables the backend to compare artifact SHAs against the Trusted Libraries registry. Implements TC-4332 Assisted-by: Claude Code * fix(sbom): address review feedback for uv hash extraction - Log errors when uv.lock cannot be read or parsed instead of silently ignoring, so issues are discoverable during troubleshooting - Align JSDoc graph parameter type with GraphEntry typedef - Skip hash assignment when entry already has hashes to avoid overwriting data from other pipeline stages Implements TC-4332 Assisted-by: Claude Code * fix(sbom): address PR review feedback for uv hash extraction Align JSDoc GraphEntry type import, use async I/O instead of readFileSync, and remove console.error calls in _attachHashesFromLockFile. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d8ec262 commit 6645ba5

15 files changed

Lines changed: 822 additions & 316 deletions

src/providers/python_uv.js

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,50 @@ export default class Python_uv extends Base_pyproject {
2828
* @param {string} workspaceDir - workspace root (for resolving editable install paths)
2929
* @param {object} parsed - parsed pyproject.toml
3030
* @param {Object} opts
31-
* @returns {Promise<{directDeps: string[], graph: Map<string, {name: string, version: string, children: string[]}>}>}
31+
* @returns {Promise<{directDeps: string[], graph: Map<string, import('./base_pyproject.js').GraphEntry>}>}
3232
*/
3333
async _getDependencyData(manifestDir, workspaceDir, parsed, opts) {
3434
let projectName = this._getProjectName(parsed)
3535
let uvOutput = this._getUvExportOutput(manifestDir, opts)
36-
return this._parseUvExport(uvOutput, projectName, workspaceDir)
36+
let { directDeps, graph } = await this._parseUvExport(uvOutput, projectName, workspaceDir)
37+
await this._attachHashesFromLockFile(path.join(workspaceDir, 'uv.lock'), graph)
38+
return { directDeps, graph }
39+
}
40+
41+
/**
42+
* Parse uv.lock and attach SHA-256 hashes to graph entries.
43+
* @param {string} lockFilePath - path to uv.lock
44+
* @param {Map<string, import('./base_pyproject.js').GraphEntry>} graph
45+
*/
46+
async _attachHashesFromLockFile(lockFilePath, graph) {
47+
let lockContent
48+
try {
49+
lockContent = await fs.promises.readFile(lockFilePath, 'utf-8')
50+
} catch {
51+
return
52+
}
53+
54+
let parsed
55+
try {
56+
parsed = parseToml(lockContent)
57+
} catch {
58+
return
59+
}
60+
61+
let packages = parsed.package
62+
if (!Array.isArray(packages)) { return }
63+
64+
for (let pkg of packages) {
65+
if (!pkg.name) { continue }
66+
let hashStr = pkg.sdist?.hash || pkg.wheels?.[0]?.hash
67+
if (!hashStr || !hashStr.startsWith('sha256:')) { continue }
68+
69+
let key = this._canonicalize(pkg.name)
70+
let entry = graph.get(key)
71+
if (!entry || entry.hashes) { continue }
72+
73+
entry.hashes = [{alg: "SHA-256", content: hashStr.slice(7)}]
74+
}
3775
}
3876

3977
/**

test/providers/tst_manifests/pyproject/pep621_ignore_and_extras/expected_component_sbom.json

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,21 +18,39 @@
1818
"version": "2.0.3",
1919
"purl": "pkg:pypi/flask@2.0.3",
2020
"type": "library",
21-
"bom-ref": "pkg:pypi/flask@2.0.3"
21+
"bom-ref": "pkg:pypi/flask@2.0.3",
22+
"hashes": [
23+
{
24+
"alg": "SHA-256",
25+
"content": "e1120c228ca2f553b470df4a5fa927ab66258467526069981b3eb0a91902687d"
26+
}
27+
]
2228
},
2329
{
2430
"name": "requests",
2531
"version": "2.25.1",
2632
"purl": "pkg:pypi/requests@2.25.1",
2733
"type": "library",
28-
"bom-ref": "pkg:pypi/requests@2.25.1"
34+
"bom-ref": "pkg:pypi/requests@2.25.1",
35+
"hashes": [
36+
{
37+
"alg": "SHA-256",
38+
"content": "27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804"
39+
}
40+
]
2941
},
3042
{
3143
"name": "typing-extensions",
3244
"version": "4.1.1",
3345
"purl": "pkg:pypi/typing-extensions@4.1.1",
3446
"type": "library",
35-
"bom-ref": "pkg:pypi/typing-extensions@4.1.1"
47+
"bom-ref": "pkg:pypi/typing-extensions@4.1.1",
48+
"hashes": [
49+
{
50+
"alg": "SHA-256",
51+
"content": "1a9462dcc3347a79b1f1c0271fbe79e844580bb598bafa1ed208b94da3cdcd42"
52+
}
53+
]
3654
}
3755
],
3856
"dependencies": [

test/providers/tst_manifests/pyproject/pep621_ignore_and_extras/expected_stack_sbom.json

Lines changed: 77 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,77 +18,143 @@
1818
"version": "2.0.3",
1919
"purl": "pkg:pypi/flask@2.0.3",
2020
"type": "library",
21-
"bom-ref": "pkg:pypi/flask@2.0.3"
21+
"bom-ref": "pkg:pypi/flask@2.0.3",
22+
"hashes": [
23+
{
24+
"alg": "SHA-256",
25+
"content": "e1120c228ca2f553b470df4a5fa927ab66258467526069981b3eb0a91902687d"
26+
}
27+
]
2228
},
2329
{
2430
"name": "requests",
2531
"version": "2.25.1",
2632
"purl": "pkg:pypi/requests@2.25.1",
2733
"type": "library",
28-
"bom-ref": "pkg:pypi/requests@2.25.1"
34+
"bom-ref": "pkg:pypi/requests@2.25.1",
35+
"hashes": [
36+
{
37+
"alg": "SHA-256",
38+
"content": "27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804"
39+
}
40+
]
2941
},
3042
{
3143
"name": "typing-extensions",
3244
"version": "4.1.1",
3345
"purl": "pkg:pypi/typing-extensions@4.1.1",
3446
"type": "library",
35-
"bom-ref": "pkg:pypi/typing-extensions@4.1.1"
47+
"bom-ref": "pkg:pypi/typing-extensions@4.1.1",
48+
"hashes": [
49+
{
50+
"alg": "SHA-256",
51+
"content": "1a9462dcc3347a79b1f1c0271fbe79e844580bb598bafa1ed208b94da3cdcd42"
52+
}
53+
]
3654
},
3755
{
3856
"name": "click",
3957
"version": "8.3.1",
4058
"purl": "pkg:pypi/click@8.3.1",
4159
"type": "library",
42-
"bom-ref": "pkg:pypi/click@8.3.1"
60+
"bom-ref": "pkg:pypi/click@8.3.1",
61+
"hashes": [
62+
{
63+
"alg": "SHA-256",
64+
"content": "12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a"
65+
}
66+
]
4367
},
4468
{
4569
"name": "itsdangerous",
4670
"version": "2.0.1",
4771
"purl": "pkg:pypi/itsdangerous@2.0.1",
4872
"type": "library",
49-
"bom-ref": "pkg:pypi/itsdangerous@2.0.1"
73+
"bom-ref": "pkg:pypi/itsdangerous@2.0.1",
74+
"hashes": [
75+
{
76+
"alg": "SHA-256",
77+
"content": "9e724d68fc22902a1435351f84c3fb8623f303fffcc566a4cb952df8c572cff0"
78+
}
79+
]
5080
},
5181
{
5282
"name": "jinja2",
5383
"version": "3.0.3",
5484
"purl": "pkg:pypi/jinja2@3.0.3",
5585
"type": "library",
56-
"bom-ref": "pkg:pypi/jinja2@3.0.3"
86+
"bom-ref": "pkg:pypi/jinja2@3.0.3",
87+
"hashes": [
88+
{
89+
"alg": "SHA-256",
90+
"content": "611bb273cd68f3b993fabdc4064fc858c5b47a973cb5aa7999ec1ba405c87cd7"
91+
}
92+
]
5793
},
5894
{
5995
"name": "werkzeug",
6096
"version": "2.0.3",
6197
"purl": "pkg:pypi/werkzeug@2.0.3",
6298
"type": "library",
63-
"bom-ref": "pkg:pypi/werkzeug@2.0.3"
99+
"bom-ref": "pkg:pypi/werkzeug@2.0.3",
100+
"hashes": [
101+
{
102+
"alg": "SHA-256",
103+
"content": "b863f8ff057c522164b6067c9e28b041161b4be5ba4d0daceeaa50a163822d3c"
104+
}
105+
]
64106
},
65107
{
66108
"name": "certifi",
67109
"version": "2023.7.22",
68110
"purl": "pkg:pypi/certifi@2023.7.22",
69111
"type": "library",
70-
"bom-ref": "pkg:pypi/certifi@2023.7.22"
112+
"bom-ref": "pkg:pypi/certifi@2023.7.22",
113+
"hashes": [
114+
{
115+
"alg": "SHA-256",
116+
"content": "539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082"
117+
}
118+
]
71119
},
72120
{
73121
"name": "chardet",
74122
"version": "4.0.0",
75123
"purl": "pkg:pypi/chardet@4.0.0",
76124
"type": "library",
77-
"bom-ref": "pkg:pypi/chardet@4.0.0"
125+
"bom-ref": "pkg:pypi/chardet@4.0.0",
126+
"hashes": [
127+
{
128+
"alg": "SHA-256",
129+
"content": "0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"
130+
}
131+
]
78132
},
79133
{
80134
"name": "idna",
81135
"version": "2.10",
82136
"purl": "pkg:pypi/idna@2.10",
83137
"type": "library",
84-
"bom-ref": "pkg:pypi/idna@2.10"
138+
"bom-ref": "pkg:pypi/idna@2.10",
139+
"hashes": [
140+
{
141+
"alg": "SHA-256",
142+
"content": "b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"
143+
}
144+
]
85145
},
86146
{
87147
"name": "urllib3",
88148
"version": "1.26.16",
89149
"purl": "pkg:pypi/urllib3@1.26.16",
90150
"type": "library",
91-
"bom-ref": "pkg:pypi/urllib3@1.26.16"
151+
"bom-ref": "pkg:pypi/urllib3@1.26.16",
152+
"hashes": [
153+
{
154+
"alg": "SHA-256",
155+
"content": "8f135f6502756bde6b2a9b28989df5fbe87c9970cecaa69041edcce7f0589b14"
156+
}
157+
]
92158
}
93159
],
94160
"dependencies": [

test/providers/tst_manifests/pyproject/uv_dev_deps/expected_component_sbom.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,13 @@
1818
"version": "2.33.1",
1919
"purl": "pkg:pypi/requests@2.33.1",
2020
"type": "library",
21-
"bom-ref": "pkg:pypi/requests@2.33.1"
21+
"bom-ref": "pkg:pypi/requests@2.33.1",
22+
"hashes": [
23+
{
24+
"alg": "SHA-256",
25+
"content": "18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517"
26+
}
27+
]
2228
}
2329
],
2430
"dependencies": [

test/providers/tst_manifests/pyproject/uv_dev_deps/expected_stack_sbom.json

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,35 +18,65 @@
1818
"version": "2.33.1",
1919
"purl": "pkg:pypi/requests@2.33.1",
2020
"type": "library",
21-
"bom-ref": "pkg:pypi/requests@2.33.1"
21+
"bom-ref": "pkg:pypi/requests@2.33.1",
22+
"hashes": [
23+
{
24+
"alg": "SHA-256",
25+
"content": "18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517"
26+
}
27+
]
2228
},
2329
{
2430
"name": "certifi",
2531
"version": "2026.2.25",
2632
"purl": "pkg:pypi/certifi@2026.2.25",
2733
"type": "library",
28-
"bom-ref": "pkg:pypi/certifi@2026.2.25"
34+
"bom-ref": "pkg:pypi/certifi@2026.2.25",
35+
"hashes": [
36+
{
37+
"alg": "SHA-256",
38+
"content": "e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7"
39+
}
40+
]
2941
},
3042
{
3143
"name": "charset-normalizer",
3244
"version": "3.4.7",
3345
"purl": "pkg:pypi/charset-normalizer@3.4.7",
3446
"type": "library",
35-
"bom-ref": "pkg:pypi/charset-normalizer@3.4.7"
47+
"bom-ref": "pkg:pypi/charset-normalizer@3.4.7",
48+
"hashes": [
49+
{
50+
"alg": "SHA-256",
51+
"content": "ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5"
52+
}
53+
]
3654
},
3755
{
3856
"name": "idna",
3957
"version": "3.11",
4058
"purl": "pkg:pypi/idna@3.11",
4159
"type": "library",
42-
"bom-ref": "pkg:pypi/idna@3.11"
60+
"bom-ref": "pkg:pypi/idna@3.11",
61+
"hashes": [
62+
{
63+
"alg": "SHA-256",
64+
"content": "795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"
65+
}
66+
]
4367
},
4468
{
4569
"name": "urllib3",
4670
"version": "2.6.3",
4771
"purl": "pkg:pypi/urllib3@2.6.3",
4872
"type": "library",
49-
"bom-ref": "pkg:pypi/urllib3@2.6.3"
73+
"bom-ref": "pkg:pypi/urllib3@2.6.3",
74+
"hashes": [
75+
{
76+
"alg": "SHA-256",
77+
"content": "1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"
78+
}
79+
]
5080
}
5181
],
5282
"dependencies": [

test/providers/tst_manifests/pyproject/uv_lock/expected_component_sbom.json

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,26 @@
1818
"version": "2.0.3",
1919
"purl": "pkg:pypi/flask@2.0.3",
2020
"type": "library",
21-
"bom-ref": "pkg:pypi/flask@2.0.3"
21+
"bom-ref": "pkg:pypi/flask@2.0.3",
22+
"hashes": [
23+
{
24+
"alg": "SHA-256",
25+
"content": "e1120c228ca2f553b470df4a5fa927ab66258467526069981b3eb0a91902687d"
26+
}
27+
]
2228
},
2329
{
2430
"name": "requests",
2531
"version": "2.25.1",
2632
"purl": "pkg:pypi/requests@2.25.1",
2733
"type": "library",
28-
"bom-ref": "pkg:pypi/requests@2.25.1"
34+
"bom-ref": "pkg:pypi/requests@2.25.1",
35+
"hashes": [
36+
{
37+
"alg": "SHA-256",
38+
"content": "27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804"
39+
}
40+
]
2941
}
3042
],
3143
"dependencies": [

0 commit comments

Comments
 (0)