Skip to content

Commit a8c3942

Browse files
a-orenclaude
andauthored
feat(sbom): extract SHA-256 hashes from pip report for pyproject provider (#506)
feat(sbom): extract SHA-256 hashes from pip report for pyproject provider (#TC-4331) Extract SHA-256 artifact hashes from pip install report and include them in the generated CycloneDX SBOM for the pip-pyproject provider. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e645720 commit a8c3942

2 files changed

Lines changed: 56 additions & 8 deletions

File tree

src/providers/python_pip_pyproject.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,9 @@ export default class Python_pip_pyproject extends Base_pyproject {
8383
let name = pkg.metadata.name
8484
let version = pkg.metadata.version
8585
let key = this._canonicalize(name)
86-
graph.set(key, { name, version, children: [] })
86+
let sha256 = pkg.download_info?.archive_info?.hashes?.sha256
87+
let hashes = sha256 ? [{alg: "SHA-256", content: sha256}] : undefined
88+
graph.set(key, { name, version, children: [], hashes })
8789
}
8890

8991
for (let pkg of nonRootPackages) {

test/providers/python_pyproject.test.js

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -275,14 +275,17 @@ suite('testing the python-pyproject data provider', () => {
275275
/** Verifies stack and component SBOM output matches expected pip fixtures. */
276276
SBOM_CASES.forEach(({type, method, fixture}) => {
277277
test(`verify pyproject.toml sbom provided for ${type} analysis with pip`, async () => {
278-
let expectedSbom = fs.readFileSync(path.join(pipFixtureDir, fixture)).toString().trim()
279-
expectedSbom = JSON.stringify(JSON.parse(expectedSbom))
278+
let expectedSbom = JSON.parse(fs.readFileSync(path.join(pipFixtureDir, fixture)).toString().trim())
280279
let result = await pipProvider[method](path.join(pipFixtureDir, 'pyproject.toml'))
281-
expect(result).to.deep.equal({
282-
ecosystem: 'pip',
283-
contentType: 'application/vnd.cyclonedx+json',
284-
content: expectedSbom
285-
})
280+
let actualSbom = JSON.parse(result.content)
281+
// Strip hashes before comparison — they are platform-dependent
282+
// (e.g. charset-normalizer resolves to different wheels per platform).
283+
// Hash correctness is verified by the dedicated SHA-256 tests below.
284+
for (let c of expectedSbom.components) { delete c.hashes }
285+
for (let c of actualSbom.components) { delete c.hashes }
286+
expect(result.ecosystem).to.equal('pip')
287+
expect(result.contentType).to.equal('application/vnd.cyclonedx+json')
288+
expect(actualSbom).to.deep.equal(expectedSbom)
286289
}).timeout(TIMEOUT)
287290
})
288291

@@ -328,6 +331,49 @@ suite('testing the python-pyproject data provider', () => {
328331
expect(pkg).to.exist
329332
expect(pkg.version).to.equal('3.4.7')
330333
}).timeout(TIMEOUT)
334+
335+
/** Verifies SHA-256 hashes from pip report appear in SBOM components. */
336+
test('SBOM components include SHA-256 hashes from pip report', async () => {
337+
let result = await pipProvider.provideStack(path.join(pipFixtureDir, 'pyproject.toml'))
338+
let sbom = JSON.parse(result.content)
339+
for (let component of sbom.components) {
340+
expect(component.hashes).to.be.an('array').with.lengthOf(1)
341+
expect(component.hashes[0].alg).to.equal('SHA-256')
342+
expect(component.hashes[0].content).to.be.a('string').with.lengthOf(64)
343+
}
344+
}).timeout(TIMEOUT)
345+
346+
/** Verifies graceful handling when pip report lacks hash data. */
347+
test('_parsePipReport handles missing hash data gracefully', () => {
348+
// Given a pip report with packages that have no download_info or hashes
349+
let report = JSON.stringify({
350+
install: [
351+
{
352+
download_info: { dir_info: {} },
353+
metadata: { name: 'root-project', version: '1.0.0', requires_dist: ['dep-a'] }
354+
},
355+
{
356+
metadata: { name: 'dep-a', version: '2.0.0' }
357+
},
358+
{
359+
download_info: { archive_info: {} },
360+
metadata: { name: 'dep-b', version: '3.0.0' }
361+
},
362+
{
363+
download_info: { archive_info: { hashes: {} } },
364+
metadata: { name: 'dep-c', version: '4.0.0' }
365+
}
366+
]
367+
})
368+
369+
// When parsing the report
370+
let { graph } = pipProvider._parsePipReport(report)
371+
372+
// Then packages without SHA-256 should have no hashes
373+
expect(graph.get('dep-a').hashes).to.be.undefined
374+
expect(graph.get('dep-b').hashes).to.be.undefined
375+
expect(graph.get('dep-c').hashes).to.be.undefined
376+
})
331377
})
332378

333379
/** Verifies uv and poetry validateLockFile returns false when no lock file is present. */

0 commit comments

Comments
 (0)