Skip to content

Commit 212178e

Browse files
a-orenclaude
andauthored
feat(pip): extract SHA-256 hashes from pip inspect for requirements.txt (guacsec#521)
* feat(pip): extract SHA-256 hashes from pip inspect for requirements.txt dependencies Add pip inspect (pip 22.2+) as a hash source for the requirements.txt flow, threading SHA-256 hashes through the dependency tree into CycloneDX SBOM output. Gracefully degrades when pip inspect is unavailable. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(test): tolerate missing hashes in real pip inspect test pip inspect may not include archive_info.hashes for cached or pre-installed packages. Verify hash format only when present. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ff694a0 commit 212178e

5 files changed

Lines changed: 325 additions & 22 deletions

File tree

src/providers/python_controller.js

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,51 @@ function getPipShowOutput(depNames) {
2222
}
2323
}
2424

25+
/**
26+
* Get pip inspect output from env var override or by invoking pip inspect.
27+
* Returns null if pip inspect is unavailable (pip < 22.2) or if freeze/show
28+
* env vars are set without an inspect override (to avoid inconsistent data).
29+
* @return {string|null} pip inspect JSON output, or null on failure
30+
*/
31+
function getPipInspectOutput() {
32+
if (environmentVariableIsPopulated("TRUSTIFY_DA_PIP_INSPECT")) {
33+
return new Buffer.from(process.env["TRUSTIFY_DA_PIP_INSPECT"], 'base64').toString('ascii');
34+
}
35+
if (environmentVariableIsPopulated("TRUSTIFY_DA_PIP_FREEZE") || environmentVariableIsPopulated("TRUSTIFY_DA_PIP_SHOW")) {
36+
return null;
37+
}
38+
try {
39+
return invokeCommand(this.pathToPipBin, ['inspect']).toString();
40+
} catch (error) {
41+
console.warn('pip inspect is not available (requires pip 22.2+), SBOM will be generated without hashes');
42+
return null;
43+
}
44+
}
45+
46+
/**
47+
* Parse pip inspect JSON output and build a hash lookup map.
48+
* @param {string|null} inspectOutput - raw pip inspect JSON string
49+
* @return {Map<string, Array<{alg: string, content: string}>>} map of lowercase package name to hashes
50+
*/
51+
function parsePipInspectHashes(inspectOutput) {
52+
if (!inspectOutput) { return new Map(); }
53+
try {
54+
let inspectData = JSON.parse(inspectOutput);
55+
let hashMap = new Map();
56+
for (let pkg of (inspectData.installed || [])) {
57+
let name = pkg.metadata?.name;
58+
let sha256 = pkg.download_info?.archive_info?.hashes?.sha256;
59+
if (name && sha256) {
60+
hashMap.set(name.toLowerCase(), [{alg: "SHA-256", content: sha256}]);
61+
}
62+
}
63+
return hashMap;
64+
} catch (error) {
65+
console.warn('Failed to parse pip inspect output, SBOM will be generated without hashes');
66+
return new Map();
67+
}
68+
}
69+
2570
/** @typedef {{name: string, version: string, dependencies: DependencyEntry[], hashes?: Array<{alg: string, content: string}>}} DependencyEntry */
2671

2772
export default class Python_controller {
@@ -225,6 +270,9 @@ export default class Python_controller {
225270
CachedEnvironmentDeps[packageName.replace("_", "-")] = pipDepTreeEntryForCache
226271
})
227272
}
273+
const inspectOutput = getPipInspectOutput.call(this);
274+
const hashMap = parsePipInspectHashes(inspectOutput);
275+
228276
parsedRequirements.forEach(({ name: depName, version: manifestVersion, hasMarker }) => {
229277
if(hasMarker && CachedEnvironmentDeps[depName.toLowerCase()] === undefined) {
230278
return
@@ -246,7 +294,7 @@ export default class Python_controller {
246294
}
247295
let path = []
248296
path.push(depName.toLowerCase())
249-
bringAllDependencies(dependencies, depName, CachedEnvironmentDeps, includeTransitive, path, usePipDepTree)
297+
bringAllDependencies(dependencies, depName, CachedEnvironmentDeps, includeTransitive, path, usePipDepTree, hashMap)
250298
})
251299
dependencies.sort((dep1,dep2) =>{
252300
const DEP1 = dep1.name.toLowerCase()
@@ -330,8 +378,9 @@ function getDepsList(record) {
330378
* @param includeTransitive
331379
* @param usePipDepTree
332380
* @param {[string]}path array representing the path of the current branch in dependency tree, starting with a root dependency - that is - a given dependency in requirements.txt
381+
* @param {Map<string, Array<{alg: string, content: string}>>} hashMap - map of lowercase package name to hashes
333382
*/
334-
function bringAllDependencies(dependencies, dependencyName, cachedEnvironmentDeps, includeTransitive, path, usePipDepTree) {
383+
function bringAllDependencies(dependencies, dependencyName, cachedEnvironmentDeps, includeTransitive, path, usePipDepTree, hashMap) {
335384
if(dependencyName?.trim() === "" ) {
336385
return
337386
}
@@ -353,7 +402,9 @@ function bringAllDependencies(dependencies, dependencyName, cachedEnvironmentDep
353402
}
354403
let targetDeps = []
355404

405+
let hashes = hashMap?.get(depName.toLowerCase())
356406
let entry = { "name": depName, "version": version, "dependencies": [] }
407+
if (hashes) { entry.hashes = hashes }
357408
dependencies.push(entry)
358409
directDeps.forEach( (dep) => {
359410
let depArray = []
@@ -363,7 +414,7 @@ function bringAllDependencies(dependencies, dependencyName, cachedEnvironmentDep
363414
depArray.push(dep.toLowerCase())
364415
if (includeTransitive) {
365416
// send to recurrsion the array of all deps in path + the current dependency name which is not on the path.
366-
bringAllDependencies(targetDeps, dep, cachedEnvironmentDeps, includeTransitive, path.concat(depArray), usePipDepTree)
417+
bringAllDependencies(targetDeps, dep, cachedEnvironmentDeps, includeTransitive, path.concat(depArray), usePipDepTree, hashMap)
367418
}
368419
}
369420
// sort ra

test/providers/python_pip.test.js

Lines changed: 169 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,24 +9,25 @@ import {getCustomPath, invokeCommand } from "../../src/tools.js"
99
let clock
1010

1111
async function sharedComponentAnalysisTestFlow(testCase, usePipDepTreeUtility) {
12-
// load the expected list for tsharedComponentAnalysisTestFlowhe scenario
13-
let expectedSbom = fs.readFileSync(`test/providers/tst_manifests/pip/${testCase}/expected_component_sbom.json`).toString().trim()
14-
expectedSbom = JSON.stringify(JSON.parse(expectedSbom))
15-
// invoke sut stack analysis for scenario manifest
12+
// load the expected list for the scenario
13+
let expectedSbom = JSON.parse(fs.readFileSync(`test/providers/tst_manifests/pip/${testCase}/expected_component_sbom.json`).toString().trim())
14+
// invoke sut component analysis for scenario manifest
1615
let opts = { TRUSTIFY_DA_PIP_USE_DEP_TREE: usePipDepTreeUtility.toString() }
1716
let providedDatForComponent = await pythonPip.provideComponent(`test/providers/tst_manifests/pip/${testCase}/requirements.txt`, opts)
17+
let actualSbom = JSON.parse(providedDatForComponent.content)
18+
// Strip hashes before comparison — they are platform-dependent.
19+
// Hash correctness is verified by the dedicated SHA-256 tests below.
20+
for (let c of expectedSbom.components) { delete c.hashes }
21+
for (let c of actualSbom.components) { delete c.hashes }
1822
// verify returned data matches expectation
19-
expect(providedDatForComponent).to.deep.equal({
20-
ecosystem: 'pip',
21-
contentType: 'application/vnd.cyclonedx+json',
22-
content: expectedSbom
23-
})
23+
expect(providedDatForComponent.ecosystem).to.equal('pip')
24+
expect(providedDatForComponent.contentType).to.equal('application/vnd.cyclonedx+json')
25+
expect(actualSbom).to.deep.equal(expectedSbom)
2426
}
2527

2628
async function sharedStackAnalysisTestFlow(testCase, usePipDepTreeUtility) {
2729
// load the expected graph for the scenario
28-
let expectedSbom = fs.readFileSync(`test/providers/tst_manifests/pip/${testCase}/expected_stack_sbom.json`).toString()
29-
expectedSbom = JSON.stringify(JSON.parse(expectedSbom))
30+
let expectedSbom = JSON.parse(fs.readFileSync(`test/providers/tst_manifests/pip/${testCase}/expected_stack_sbom.json`).toString())
3031
// invoke sut stack analysis for scenario manifest
3132
let pipPath = getCustomPath("pip3");
3233
try {
@@ -36,15 +37,15 @@ async function sharedStackAnalysisTestFlow(testCase, usePipDepTreeUtility) {
3637
}
3738
let opts = { TRUSTIFY_DA_PIP_USE_DEP_TREE: usePipDepTreeUtility.toString() }
3839
let providedDataForStack = await pythonPip.provideStack(`test/providers/tst_manifests/pip/${testCase}/requirements.txt`, opts)
39-
// new(year: number, month: number, date?: number, hours?: number, minutes?: number, seconds?: number, ms?: number): Date
40-
41-
// providedDataForStack.content = providedDataForStack.content.replaceAll("\"timestamp\":\"[a-zA-Z0-9\\-\\:]+\"","")
40+
let actualSbom = JSON.parse(providedDataForStack.content)
41+
// Strip hashes before comparison — they are platform-dependent.
42+
// Hash correctness is verified by the dedicated SHA-256 tests below.
43+
for (let c of expectedSbom.components) { delete c.hashes }
44+
for (let c of actualSbom.components) { delete c.hashes }
4245
// verify returned data matches expectation
43-
expect(providedDataForStack).to.deep.equal({
44-
ecosystem: 'pip',
45-
contentType: 'application/vnd.cyclonedx+json',
46-
content: expectedSbom
47-
})
46+
expect(providedDataForStack.ecosystem).to.equal('pip')
47+
expect(providedDataForStack.contentType).to.equal('application/vnd.cyclonedx+json')
48+
expect(actualSbom).to.deep.equal(expectedSbom)
4849
}
4950

5051
suite('testing the python-pip data provider', () => {
@@ -153,3 +154,152 @@ suite('testing python-pip PEP 508 marker handling', () => {
153154
}).timeout(10000)
154155

155156
}).beforeAll(() => clock = useFakeTimers(new Date('2023-10-01T00:00:00.000Z'))).afterAll(() => clock.restore());
157+
158+
suite('testing python-pip SHA-256 hash extraction via pip inspect', () => {
159+
const hashTestCase = 'pip_requirements_txt_with_hashes'
160+
161+
const pipFreezeOutput = 'certifi==2023.7.22\nsix==1.16.0\n'
162+
const pipShowOutput =
163+
'Name: certifi\nVersion: 2023.7.22\nSummary: Python package\nRequires: \nRequired-by: ' +
164+
'\n---\n' +
165+
'Name: six\nVersion: 1.16.0\nSummary: Python 2 and 3 compatibility\nRequires: \nRequired-by: '
166+
const pipInspectOutput = JSON.stringify({
167+
version: "1",
168+
pip_version: "23.0",
169+
installed: [
170+
{
171+
metadata: { name: "certifi", version: "2023.7.22" },
172+
download_info: { archive_info: { hashes: { sha256: "539cc1d13202e33ca466e88b2807e29f4c13049d6d853261b21e0e8d461bbbf0" } } }
173+
},
174+
{
175+
metadata: { name: "six", version: "1.16.0" },
176+
download_info: { archive_info: { hashes: { sha256: "1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926" } } }
177+
}
178+
]
179+
})
180+
181+
/** Verifies that SHA-256 hashes from pip inspect are threaded into the component analysis SBOM. */
182+
test('verify component analysis SBOM includes SHA-256 hashes from pip inspect', async () => {
183+
// Given: pip environment with freeze, show, and inspect data
184+
process.env['TRUSTIFY_DA_PIP_FREEZE'] = Buffer.from(pipFreezeOutput).toString('base64')
185+
process.env['TRUSTIFY_DA_PIP_SHOW'] = Buffer.from(pipShowOutput).toString('base64')
186+
process.env['TRUSTIFY_DA_PIP_INSPECT'] = Buffer.from(pipInspectOutput).toString('base64')
187+
188+
try {
189+
// When: component analysis is run
190+
let expectedSbom = fs.readFileSync(`test/providers/tst_manifests/pip/${hashTestCase}/expected_component_sbom.json`).toString().trim()
191+
expectedSbom = JSON.stringify(JSON.parse(expectedSbom))
192+
let result = await pythonPip.provideComponent(`test/providers/tst_manifests/pip/${hashTestCase}/requirements.txt`, {})
193+
194+
// Then: SBOM includes hashes matching the golden file
195+
expect(result).to.deep.equal({
196+
ecosystem: 'pip',
197+
contentType: 'application/vnd.cyclonedx+json',
198+
content: expectedSbom
199+
})
200+
} finally {
201+
delete process.env['TRUSTIFY_DA_PIP_FREEZE']
202+
delete process.env['TRUSTIFY_DA_PIP_SHOW']
203+
delete process.env['TRUSTIFY_DA_PIP_INSPECT']
204+
}
205+
}).timeout(10000)
206+
207+
/** Verifies that SHA-256 hashes from pip inspect are threaded into the stack analysis SBOM. */
208+
test('verify stack analysis SBOM includes SHA-256 hashes from pip inspect', async () => {
209+
// Given: pip environment with freeze, show, and inspect data
210+
process.env['TRUSTIFY_DA_PIP_FREEZE'] = Buffer.from(pipFreezeOutput).toString('base64')
211+
process.env['TRUSTIFY_DA_PIP_SHOW'] = Buffer.from(pipShowOutput).toString('base64')
212+
process.env['TRUSTIFY_DA_PIP_INSPECT'] = Buffer.from(pipInspectOutput).toString('base64')
213+
214+
try {
215+
// When: stack analysis is run
216+
let expectedSbom = fs.readFileSync(`test/providers/tst_manifests/pip/${hashTestCase}/expected_stack_sbom.json`).toString().trim()
217+
expectedSbom = JSON.stringify(JSON.parse(expectedSbom))
218+
let result = await pythonPip.provideStack(`test/providers/tst_manifests/pip/${hashTestCase}/requirements.txt`, {})
219+
220+
// Then: SBOM includes hashes matching the golden file
221+
expect(result).to.deep.equal({
222+
ecosystem: 'pip',
223+
contentType: 'application/vnd.cyclonedx+json',
224+
content: expectedSbom
225+
})
226+
} finally {
227+
delete process.env['TRUSTIFY_DA_PIP_FREEZE']
228+
delete process.env['TRUSTIFY_DA_PIP_SHOW']
229+
delete process.env['TRUSTIFY_DA_PIP_INSPECT']
230+
}
231+
}).timeout(10000)
232+
233+
/** Verifies graceful degradation when pip inspect is unavailable (invalid output). */
234+
test('verify SBOM generated without hashes when pip inspect output is invalid', async () => {
235+
// Given: pip environment with freeze and show but invalid inspect data
236+
process.env['TRUSTIFY_DA_PIP_FREEZE'] = Buffer.from(pipFreezeOutput).toString('base64')
237+
process.env['TRUSTIFY_DA_PIP_SHOW'] = Buffer.from(pipShowOutput).toString('base64')
238+
process.env['TRUSTIFY_DA_PIP_INSPECT'] = Buffer.from('INVALID JSON').toString('base64')
239+
240+
try {
241+
// When: component analysis is run
242+
let result = await pythonPip.provideComponent(`test/providers/tst_manifests/pip/${hashTestCase}/requirements.txt`, {})
243+
let sbom = JSON.parse(result.content)
244+
245+
// Then: SBOM is generated without hashes
246+
expect(sbom.components).to.have.lengthOf(2)
247+
for (let component of sbom.components) {
248+
expect(component).to.not.have.property('hashes')
249+
}
250+
} finally {
251+
delete process.env['TRUSTIFY_DA_PIP_FREEZE']
252+
delete process.env['TRUSTIFY_DA_PIP_SHOW']
253+
delete process.env['TRUSTIFY_DA_PIP_INSPECT']
254+
}
255+
}).timeout(10000)
256+
257+
/** Verifies that pip inspect is skipped when freeze/show env vars are set without inspect. */
258+
test('verify SBOM generated without hashes when pip inspect env var is not set', async () => {
259+
// Given: pip environment with freeze and show but NO inspect override
260+
process.env['TRUSTIFY_DA_PIP_FREEZE'] = Buffer.from(pipFreezeOutput).toString('base64')
261+
process.env['TRUSTIFY_DA_PIP_SHOW'] = Buffer.from(pipShowOutput).toString('base64')
262+
263+
try {
264+
// When: component analysis is run
265+
let result = await pythonPip.provideComponent(`test/providers/tst_manifests/pip/${hashTestCase}/requirements.txt`, {})
266+
let sbom = JSON.parse(result.content)
267+
268+
// Then: SBOM is generated without hashes (pip inspect skipped in env var mode)
269+
expect(sbom.components).to.have.lengthOf(2)
270+
for (let component of sbom.components) {
271+
expect(component).to.not.have.property('hashes')
272+
}
273+
} finally {
274+
delete process.env['TRUSTIFY_DA_PIP_FREEZE']
275+
delete process.env['TRUSTIFY_DA_PIP_SHOW']
276+
}
277+
}).timeout(10000)
278+
279+
}).beforeAll(() => clock = useFakeTimers(new Date('2023-10-01T00:00:00.000Z'))).afterAll(() => clock.restore());
280+
281+
suite('testing python-pip SHA-256 hashes with real pip inspect', () => {
282+
const testCase = 'pip_requirements_txt_with_hashes'
283+
284+
/** Verifies SBOM components include SHA-256 hashes from real pip inspect. */
285+
test('SBOM components include SHA-256 hashes from pip inspect', async () => {
286+
// Given: packages installed via real pip
287+
let pipPath = getCustomPath("pip3");
288+
invokeCommand(pipPath, ['install', '-r', `test/providers/tst_manifests/pip/${testCase}/requirements.txt`])
289+
290+
// When: stack analysis is run (uses real pip freeze, pip show, and pip inspect)
291+
let result = await pythonPip.provideStack(`test/providers/tst_manifests/pip/${testCase}/requirements.txt`, {})
292+
let sbom = JSON.parse(result.content)
293+
294+
// Then: components with hashes have well-formed SHA-256 entries
295+
// Note: pip inspect may not include hashes for cached/pre-installed packages
296+
for (let component of sbom.components) {
297+
if (component.hashes) {
298+
expect(component.hashes).to.be.an('array').with.lengthOf(1)
299+
expect(component.hashes[0].alg).to.equal('SHA-256')
300+
expect(component.hashes[0].content).to.be.a('string').with.lengthOf(64)
301+
}
302+
}
303+
}).timeout(process.env.GITHUB_ACTIONS ? 30000 : 10000)
304+
305+
}).beforeAll(() => clock = useFakeTimers(new Date('2023-10-01T00:00:00.000Z'))).afterAll(() => clock.restore());
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
{
2+
"bomFormat": "CycloneDX",
3+
"specVersion": "1.4",
4+
"version": 1,
5+
"metadata": {
6+
"timestamp": "2023-10-01T00:00:00.000Z",
7+
"component": {
8+
"name": "default-pip-root",
9+
"version": "0.0.0",
10+
"purl": "pkg:pypi/default-pip-root@0.0.0",
11+
"type": "application",
12+
"bom-ref": "pkg:pypi/default-pip-root@0.0.0"
13+
}
14+
},
15+
"components": [
16+
{
17+
"name": "certifi",
18+
"version": "2023.7.22",
19+
"purl": "pkg:pypi/certifi@2023.7.22",
20+
"type": "library",
21+
"bom-ref": "pkg:pypi/certifi@2023.7.22",
22+
"hashes": [{"alg": "SHA-256", "content": "539cc1d13202e33ca466e88b2807e29f4c13049d6d853261b21e0e8d461bbbf0"}]
23+
},
24+
{
25+
"name": "six",
26+
"version": "1.16.0",
27+
"purl": "pkg:pypi/six@1.16.0",
28+
"type": "library",
29+
"bom-ref": "pkg:pypi/six@1.16.0",
30+
"hashes": [{"alg": "SHA-256", "content": "1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}]
31+
}
32+
],
33+
"dependencies": [
34+
{
35+
"ref": "pkg:pypi/default-pip-root@0.0.0",
36+
"dependsOn": [
37+
"pkg:pypi/certifi@2023.7.22",
38+
"pkg:pypi/six@1.16.0"
39+
]
40+
},
41+
{
42+
"ref": "pkg:pypi/certifi@2023.7.22",
43+
"dependsOn": []
44+
},
45+
{
46+
"ref": "pkg:pypi/six@1.16.0",
47+
"dependsOn": []
48+
}
49+
]
50+
}

0 commit comments

Comments
 (0)