Skip to content

Commit 1684e79

Browse files
ruromeroclaude
andauthored
fix: skip marker-constrained uninstalled packages in Python requirements (guacsec#459)
When a requirements.txt contains packages with PEP 508 environment markers (e.g., `pywin32==306 ; platform_system == "Windows"`), pip only installs packages whose markers match the current platform. Previously, the JavaScript client scanned ALL packages from the manifest regardless of markers, throwing an error when a marker- constrained package was not installed. This fix uses tree-sitter to detect `marker_spec` nodes on each requirement and skips packages that have a marker but are absent from the installed packages cache (`pip freeze`). Packages with markers that ARE installed are still included in the SBOM. Implements TC-4043 Assisted-by: Claude Code Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6ca858a commit 1684e79

8 files changed

Lines changed: 185 additions & 17 deletions

File tree

src/providers/python_controller.js

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ export default class Python_controller {
9797

9898
/**
9999
* Parse the requirements.txt file using tree-sitter and return structured requirement data.
100-
* @return {Promise<{name: string, version: string|null}[]>}
100+
* @return {Promise<{name: string, version: string|null, hasMarker: boolean}[]>}
101101
*/
102102
async #parseRequirements() {
103103
const content = fs.readFileSync(this.pathToRequirements).toString();
@@ -109,7 +109,8 @@ export default class Python_controller {
109109
const version = versionMatches.length > 0
110110
? versionMatches[0].captures.find(c => c.name === 'version').node.text
111111
: null;
112-
return { name, version };
112+
const hasMarker = reqNode.children.some(c => c.type === 'marker_spec');
113+
return { name, version, hasMarker };
113114
}));
114115
}
115116

@@ -224,7 +225,10 @@ export default class Python_controller {
224225
CachedEnvironmentDeps[packageName.replace("_", "-")] = pipDepTreeEntryForCache
225226
})
226227
}
227-
parsedRequirements.forEach(({ name: depName, version: manifestVersion }) => {
228+
parsedRequirements.forEach(({ name: depName, version: manifestVersion, hasMarker }) => {
229+
if(hasMarker && CachedEnvironmentDeps[depName.toLowerCase()] === undefined) {
230+
return
231+
}
228232
if(matchManifestVersions === "true" && manifestVersion != null) {
229233
let installedVersion
230234
if(CachedEnvironmentDeps[depName.toLowerCase()] !== undefined) {

test/providers/python_pip.test.js

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,3 +115,41 @@ suite('testing the python-pip data provider with virtual environment', () => {
115115
})
116116

117117
}).beforeAll(() => {clock = useFakeTimers(new Date('2023-10-01T00:00:00.000Z'))}).afterAll(()=> clock.restore());
118+
119+
suite('testing python-pip PEP 508 marker handling', () => {
120+
const markerTestCase = 'pip_requirements_txt_marker_skip'
121+
122+
/** Verify that packages with environment markers (PEP 508) that are not installed
123+
* in the current environment are silently skipped, while marker-constrained
124+
* packages that ARE installed are still included in the SBOM. */
125+
test('verify marker-constrained uninstalled packages are skipped in component analysis', async () => {
126+
// given: pip environment where only six and certifi are installed (pywin32 is Windows-only)
127+
const pipFreezeOutput = 'six==1.16.0\ncertifi==2023.7.22\n'
128+
const pipShowOutput =
129+
'Name: certifi\nVersion: 2023.7.22\nSummary: Python package for providing Mozilla\'s CA Bundle.\nRequires: \nRequired-by: ' +
130+
'\n---\n' +
131+
'Name: six\nVersion: 1.16.0\nSummary: Python 2 and 3 compatibility utilities\nRequires: \nRequired-by: '
132+
133+
process.env['TRUSTIFY_DA_PIP_FREEZE'] = Buffer.from(pipFreezeOutput).toString('base64')
134+
process.env['TRUSTIFY_DA_PIP_SHOW'] = Buffer.from(pipShowOutput).toString('base64')
135+
136+
try {
137+
// when: component analysis is run against a manifest with a Windows-only marker package
138+
let expectedSbom = fs.readFileSync(`test/providers/tst_manifests/pip/${markerTestCase}/expected_component_sbom.json`).toString().trim()
139+
expectedSbom = JSON.stringify(JSON.parse(expectedSbom))
140+
141+
let result = await pythonPip.provideComponent(`test/providers/tst_manifests/pip/${markerTestCase}/requirements.txt`, {})
142+
143+
// then: SBOM contains six and certifi but not pywin32
144+
expect(result).to.deep.equal({
145+
ecosystem: 'pip',
146+
contentType: 'application/vnd.cyclonedx+json',
147+
content: expectedSbom
148+
})
149+
} finally {
150+
delete process.env['TRUSTIFY_DA_PIP_FREEZE']
151+
delete process.env['TRUSTIFY_DA_PIP_SHOW']
152+
}
153+
}).timeout(10000)
154+
155+
}).beforeAll(() => clock = useFakeTimers(new Date('2023-10-01T00:00:00.000Z'))).afterAll(() => clock.restore());

test/providers/python_pyproject.test.js

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -272,14 +272,19 @@ suite('testing the python-pyproject data provider', () => {
272272
let result = await pipProvider.provideStack(path.join(pipFixtureDir, 'pyproject.toml'))
273273
let sbom = JSON.parse(result.content)
274274
let rootDep = sbom.dependencies.find(d => d.ref.includes('/test-project@'))
275-
expect(rootDep.dependsOn).to.have.lengthOf(1)
276-
expect(rootDep.dependsOn[0]).to.include('/requests@')
275+
let directNames = rootDep.dependsOn.map(d => d.split('/').pop().split('@')[0])
276+
expect(directNames).to.include('requests')
277+
expect(directNames).to.include('charset-normalizer')
278+
expect(directNames).to.include('idna')
279+
expect(directNames).to.include('urllib3')
280+
expect(directNames).to.include('certifi')
281+
expect(directNames).to.include('pysocks')
277282
let requestsDep = sbom.dependencies.find(d => d.ref.includes('/requests@'))
278-
let transNames = requestsDep.dependsOn.map(d => d.split('/').pop().split('@')[0])
279-
expect(transNames).to.include('certifi')
280-
expect(transNames).to.include('charset-normalizer')
281-
expect(transNames).to.include('idna')
282-
expect(transNames).to.include('urllib3')
283+
let reqTransNames = requestsDep.dependsOn.map(d => d.split('/').pop().split('@')[0])
284+
expect(reqTransNames).to.include('certifi')
285+
expect(reqTransNames).to.include('charset-normalizer')
286+
expect(reqTransNames).to.include('idna')
287+
expect(reqTransNames).to.include('urllib3')
283288
}).timeout(TIMEOUT)
284289

285290
/** Verifies exhortignore marker produces expected SBOM for stack and component analysis. */
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
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+
},
23+
{
24+
"name": "six",
25+
"version": "1.16.0",
26+
"purl": "pkg:pypi/six@1.16.0",
27+
"type": "library",
28+
"bom-ref": "pkg:pypi/six@1.16.0"
29+
}
30+
],
31+
"dependencies": [
32+
{
33+
"ref": "pkg:pypi/default-pip-root@0.0.0",
34+
"dependsOn": [
35+
"pkg:pypi/certifi@2023.7.22",
36+
"pkg:pypi/six@1.16.0"
37+
]
38+
},
39+
{
40+
"ref": "pkg:pypi/certifi@2023.7.22",
41+
"dependsOn": []
42+
},
43+
{
44+
"ref": "pkg:pypi/six@1.16.0",
45+
"dependsOn": []
46+
}
47+
]
48+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
six==1.16.0
2+
certifi==2023.7.22 ; python_version >= "3"
3+
pywin32==306 ; platform_system == "Windows"

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

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,78 @@
1919
"purl": "pkg:pypi/requests@2.32.3",
2020
"type": "library",
2121
"bom-ref": "pkg:pypi/requests@2.32.3"
22+
},
23+
{
24+
"name": "charset-normalizer",
25+
"version": "3.4.7",
26+
"purl": "pkg:pypi/charset-normalizer@3.4.7",
27+
"type": "library",
28+
"bom-ref": "pkg:pypi/charset-normalizer@3.4.7"
29+
},
30+
{
31+
"name": "idna",
32+
"version": "3.12",
33+
"purl": "pkg:pypi/idna@3.12",
34+
"type": "library",
35+
"bom-ref": "pkg:pypi/idna@3.12"
36+
},
37+
{
38+
"name": "urllib3",
39+
"version": "2.6.3",
40+
"purl": "pkg:pypi/urllib3@2.6.3",
41+
"type": "library",
42+
"bom-ref": "pkg:pypi/urllib3@2.6.3"
43+
},
44+
{
45+
"name": "certifi",
46+
"version": "2026.2.25",
47+
"purl": "pkg:pypi/certifi@2026.2.25",
48+
"type": "library",
49+
"bom-ref": "pkg:pypi/certifi@2026.2.25"
50+
},
51+
{
52+
"name": "pysocks",
53+
"version": "1.7.1",
54+
"purl": "pkg:pypi/pysocks@1.7.1",
55+
"type": "library",
56+
"bom-ref": "pkg:pypi/pysocks@1.7.1"
2257
}
2358
],
2459
"dependencies": [
2560
{
2661
"ref": "pkg:pypi/test-project@1.0.0",
2762
"dependsOn": [
28-
"pkg:pypi/requests@2.32.3"
63+
"pkg:pypi/requests@2.32.3",
64+
"pkg:pypi/charset-normalizer@3.4.7",
65+
"pkg:pypi/idna@3.12",
66+
"pkg:pypi/urllib3@2.6.3",
67+
"pkg:pypi/certifi@2026.2.25",
68+
"pkg:pypi/pysocks@1.7.1"
2969
]
3070
},
3171
{
3272
"ref": "pkg:pypi/requests@2.32.3",
3373
"dependsOn": []
74+
},
75+
{
76+
"ref": "pkg:pypi/charset-normalizer@3.4.7",
77+
"dependsOn": []
78+
},
79+
{
80+
"ref": "pkg:pypi/idna@3.12",
81+
"dependsOn": []
82+
},
83+
{
84+
"ref": "pkg:pypi/urllib3@2.6.3",
85+
"dependsOn": []
86+
},
87+
{
88+
"ref": "pkg:pypi/certifi@2026.2.25",
89+
"dependsOn": []
90+
},
91+
{
92+
"ref": "pkg:pypi/pysocks@1.7.1",
93+
"dependsOn": []
3494
}
3595
]
3696
}

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

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,10 @@
2929
},
3030
{
3131
"name": "idna",
32-
"version": "3.11",
33-
"purl": "pkg:pypi/idna@3.11",
32+
"version": "3.12",
33+
"purl": "pkg:pypi/idna@3.12",
3434
"type": "library",
35-
"bom-ref": "pkg:pypi/idna@3.11"
35+
"bom-ref": "pkg:pypi/idna@3.12"
3636
},
3737
{
3838
"name": "urllib3",
@@ -60,14 +60,19 @@
6060
{
6161
"ref": "pkg:pypi/test-project@1.0.0",
6262
"dependsOn": [
63-
"pkg:pypi/requests@2.32.3"
63+
"pkg:pypi/requests@2.32.3",
64+
"pkg:pypi/charset-normalizer@3.4.7",
65+
"pkg:pypi/idna@3.12",
66+
"pkg:pypi/urllib3@2.6.3",
67+
"pkg:pypi/certifi@2026.2.25",
68+
"pkg:pypi/pysocks@1.7.1"
6469
]
6570
},
6671
{
6772
"ref": "pkg:pypi/requests@2.32.3",
6873
"dependsOn": [
6974
"pkg:pypi/charset-normalizer@3.4.7",
70-
"pkg:pypi/idna@3.11",
75+
"pkg:pypi/idna@3.12",
7176
"pkg:pypi/urllib3@2.6.3",
7277
"pkg:pypi/certifi@2026.2.25",
7378
"pkg:pypi/pysocks@1.7.1"
@@ -78,7 +83,7 @@
7883
"dependsOn": []
7984
},
8085
{
81-
"ref": "pkg:pypi/idna@3.11",
86+
"ref": "pkg:pypi/idna@3.12",
8287
"dependsOn": []
8388
},
8489
{

test/providers/tst_manifests/pyproject/pip_pep621/pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,9 @@ version = "1.0.0"
44
requires-python = ">=3.9"
55
dependencies = [
66
"requests[socks]==2.32.3",
7+
"charset-normalizer==3.4.7",
8+
"idna==3.12",
9+
"urllib3[socks]==2.6.3",
10+
"certifi==2026.2.25",
11+
"pysocks==1.7.1",
712
]

0 commit comments

Comments
 (0)