From fb7d0e35f25c03c854af34aaf13efa5f83c771d3 Mon Sep 17 00:00:00 2001 From: Ruben Romero Montes Date: Thu, 9 Apr 2026 10:24:25 +0200 Subject: [PATCH] fix: skip marker-constrained uninstalled packages in Python requirements 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 --- src/providers/python_controller.js | 10 ++-- test/providers/python_pip.test.js | 38 +++++++++++++++ .../expected_component_sbom.json | 48 +++++++++++++++++++ .../requirements.txt | 3 ++ 4 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 test/providers/tst_manifests/pip/pip_requirements_txt_marker_skip/expected_component_sbom.json create mode 100644 test/providers/tst_manifests/pip/pip_requirements_txt_marker_skip/requirements.txt diff --git a/src/providers/python_controller.js b/src/providers/python_controller.js index 1b6cff2d..c8ede79b 100644 --- a/src/providers/python_controller.js +++ b/src/providers/python_controller.js @@ -97,7 +97,7 @@ export default class Python_controller { /** * Parse the requirements.txt file using tree-sitter and return structured requirement data. - * @return {Promise<{name: string, version: string|null}[]>} + * @return {Promise<{name: string, version: string|null, hasMarker: boolean}[]>} */ async #parseRequirements() { const content = fs.readFileSync(this.pathToRequirements).toString(); @@ -109,7 +109,8 @@ export default class Python_controller { const version = versionMatches.length > 0 ? versionMatches[0].captures.find(c => c.name === 'version').node.text : null; - return { name, version }; + const hasMarker = reqNode.children.some(c => c.type === 'marker_spec'); + return { name, version, hasMarker }; })); } @@ -224,7 +225,10 @@ export default class Python_controller { CachedEnvironmentDeps[packageName.replace("_", "-")] = pipDepTreeEntryForCache }) } - parsedRequirements.forEach(({ name: depName, version: manifestVersion }) => { + parsedRequirements.forEach(({ name: depName, version: manifestVersion, hasMarker }) => { + if(hasMarker && CachedEnvironmentDeps[depName.toLowerCase()] === undefined) { + return + } if(matchManifestVersions === "true" && manifestVersion != null) { let installedVersion if(CachedEnvironmentDeps[depName.toLowerCase()] !== undefined) { diff --git a/test/providers/python_pip.test.js b/test/providers/python_pip.test.js index 0d1666d6..d1b338c8 100644 --- a/test/providers/python_pip.test.js +++ b/test/providers/python_pip.test.js @@ -115,3 +115,41 @@ suite('testing the python-pip data provider with virtual environment', () => { }) }).beforeAll(() => {clock = useFakeTimers(new Date('2023-10-01T00:00:00.000Z'))}).afterAll(()=> clock.restore()); + +suite('testing python-pip PEP 508 marker handling', () => { + const markerTestCase = 'pip_requirements_txt_marker_skip' + + /** Verify that packages with environment markers (PEP 508) that are not installed + * in the current environment are silently skipped, while marker-constrained + * packages that ARE installed are still included in the SBOM. */ + test('verify marker-constrained uninstalled packages are skipped in component analysis', async () => { + // given: pip environment where only six and certifi are installed (pywin32 is Windows-only) + const pipFreezeOutput = 'six==1.16.0\ncertifi==2023.7.22\n' + const pipShowOutput = + 'Name: certifi\nVersion: 2023.7.22\nSummary: Python package for providing Mozilla\'s CA Bundle.\nRequires: \nRequired-by: ' + + '\n---\n' + + 'Name: six\nVersion: 1.16.0\nSummary: Python 2 and 3 compatibility utilities\nRequires: \nRequired-by: ' + + process.env['TRUSTIFY_DA_PIP_FREEZE'] = Buffer.from(pipFreezeOutput).toString('base64') + process.env['TRUSTIFY_DA_PIP_SHOW'] = Buffer.from(pipShowOutput).toString('base64') + + try { + // when: component analysis is run against a manifest with a Windows-only marker package + let expectedSbom = fs.readFileSync(`test/providers/tst_manifests/pip/${markerTestCase}/expected_component_sbom.json`).toString().trim() + expectedSbom = JSON.stringify(JSON.parse(expectedSbom)) + + let result = await pythonPip.provideComponent(`test/providers/tst_manifests/pip/${markerTestCase}/requirements.txt`, {}) + + // then: SBOM contains six and certifi but not pywin32 + expect(result).to.deep.equal({ + ecosystem: 'pip', + contentType: 'application/vnd.cyclonedx+json', + content: expectedSbom + }) + } finally { + delete process.env['TRUSTIFY_DA_PIP_FREEZE'] + delete process.env['TRUSTIFY_DA_PIP_SHOW'] + } + }).timeout(10000) + +}).beforeAll(() => clock = useFakeTimers(new Date('2023-10-01T00:00:00.000Z'))).afterAll(() => clock.restore()); diff --git a/test/providers/tst_manifests/pip/pip_requirements_txt_marker_skip/expected_component_sbom.json b/test/providers/tst_manifests/pip/pip_requirements_txt_marker_skip/expected_component_sbom.json new file mode 100644 index 00000000..b82e91af --- /dev/null +++ b/test/providers/tst_manifests/pip/pip_requirements_txt_marker_skip/expected_component_sbom.json @@ -0,0 +1,48 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "version": 1, + "metadata": { + "timestamp": "2023-10-01T00:00:00.000Z", + "component": { + "name": "default-pip-root", + "version": "0.0.0", + "purl": "pkg:pypi/default-pip-root@0.0.0", + "type": "application", + "bom-ref": "pkg:pypi/default-pip-root@0.0.0" + } + }, + "components": [ + { + "name": "certifi", + "version": "2023.7.22", + "purl": "pkg:pypi/certifi@2023.7.22", + "type": "library", + "bom-ref": "pkg:pypi/certifi@2023.7.22" + }, + { + "name": "six", + "version": "1.16.0", + "purl": "pkg:pypi/six@1.16.0", + "type": "library", + "bom-ref": "pkg:pypi/six@1.16.0" + } + ], + "dependencies": [ + { + "ref": "pkg:pypi/default-pip-root@0.0.0", + "dependsOn": [ + "pkg:pypi/certifi@2023.7.22", + "pkg:pypi/six@1.16.0" + ] + }, + { + "ref": "pkg:pypi/certifi@2023.7.22", + "dependsOn": [] + }, + { + "ref": "pkg:pypi/six@1.16.0", + "dependsOn": [] + } + ] +} diff --git a/test/providers/tst_manifests/pip/pip_requirements_txt_marker_skip/requirements.txt b/test/providers/tst_manifests/pip/pip_requirements_txt_marker_skip/requirements.txt new file mode 100644 index 00000000..f8c35ddd --- /dev/null +++ b/test/providers/tst_manifests/pip/pip_requirements_txt_marker_skip/requirements.txt @@ -0,0 +1,3 @@ +six==1.16.0 +certifi==2023.7.22 ; python_version >= "3" +pywin32==306 ; platform_system == "Windows"