Skip to content

Commit d2fee6b

Browse files
ruromeroclaude
andauthored
fix(python): filter packages by PEP 508 environment markers (guacsec#491)
* feat(python): filter PEP 508 environment markers in uv and poetry providers Add a PEP 508 marker evaluator that maps Node.js platform values to Python environment markers (sys_platform, platform_system, os_name, python_version, etc.) and evaluates marker expressions with support for version comparison, logical operators, parentheses, reversed operands, and in/not in membership tests. The uv provider skips packages whose marker_spec nodes do not match the current platform. The poetry provider cross-references markers from both pyproject.toml PEP 621 dependency strings (direct deps) and poetry.lock package.dependencies entries (transitive deps). Windows-only packages like pywin32, colorama, and pysocks are now correctly excluded from SBOMs generated on Linux/macOS. Tests mock process.platform to ensure deterministic results on any OS. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(python): use PEP 508 string containment for in/not in operators The `in` and `not in` marker operators were incorrectly splitting by comma and checking array membership. Per PEP 508, these operators perform Python string containment (e.g. `'linux' in 'linux2'` is true). TC-4313 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(python): reverse directional operators in PEP 508 reversed marker form When a marker comparison is written as 'value' op variable (e.g., '3.8' >= python_version), parseAtom swaps the operands but must also reverse directional operators: < ↔ >, <= ↔ >=. Symmetric operators (==, !=) remain unchanged. Resolves TC-4314 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(python): differentiate python_version (X.Y) from python_full_version (X.Y.Z) PEP 508 defines python_version as major.minor and python_full_version as major.minor.micro. The Python command now queries both components in a single call, and getEnvironmentMarkers() assigns them separately. Resolves TC-4315 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: use invokeCommand for Python version detection Replace direct execSync call with the project's invokeCommand utility and getCustomPath to respect TRUSTIFY_DA_PYTHON3_PATH environment variable, consistent with other Python providers. Refs: TC-4316 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 848421d commit d2fee6b

19 files changed

Lines changed: 1928 additions & 1334 deletions

src/providers/marker_evaluator.js

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
// PEP 508 environment marker evaluator.
2+
// Filters Python dependencies by platform/version markers so that e.g.
3+
// "pywin32 ; sys_platform == 'win32'" is excluded on Linux/macOS.
4+
// See https://peps.python.org/pep-0508/#environment-markers
5+
6+
import os from 'node:os'
7+
8+
import { getCustomPath, invokeCommand } from '../tools.js'
9+
10+
let cachedPythonVersions = undefined
11+
12+
function getPythonVersions() {
13+
if (cachedPythonVersions !== undefined) { return cachedPythonVersions }
14+
try {
15+
let python = getCustomPath('python3')
16+
let out = invokeCommand(python, ['-c', "import sys; v=sys.version_info; print(f'{v.major}.{v.minor} {v.major}.{v.minor}.{v.micro}')"],
17+
{ timeout: 5000 }).toString().trim()
18+
let [short, full] = out.split(' ')
19+
cachedPythonVersions = { short, full }
20+
} catch {
21+
cachedPythonVersions = null
22+
}
23+
return cachedPythonVersions
24+
}
25+
26+
/**
27+
* Maps Node.js/OS values to PEP 508 marker variables.
28+
* Example: on Linux, sys_platform='linux', platform_system='Linux', os_name='posix'
29+
* @returns {Record<string, string>}
30+
*/
31+
export function getEnvironmentMarkers() {
32+
let platform = process.platform
33+
let systemMap = { win32: 'Windows', linux: 'Linux', darwin: 'Darwin' }
34+
let machine = typeof os.machine === 'function' ? os.machine() : process.arch
35+
let pyVer = getPythonVersions()
36+
return {
37+
sys_platform: platform,
38+
platform_system: systemMap[platform] || platform,
39+
os_name: platform === 'win32' ? 'nt' : 'posix',
40+
platform_machine: machine,
41+
platform_release: os.release(),
42+
platform_version: os.version?.() || '',
43+
python_version: pyVer?.short || '',
44+
python_full_version: pyVer?.full || '',
45+
implementation_name: 'cpython',
46+
}
47+
}
48+
49+
function compareVersions(left, right) {
50+
let lParts = left.split('.').map(Number)
51+
let rParts = right.split('.').map(Number)
52+
let len = Math.max(lParts.length, rParts.length)
53+
for (let i = 0; i < len; i++) {
54+
let l = lParts[i] || 0
55+
let r = rParts[i] || 0
56+
if (l < r) { return -1 }
57+
if (l > r) { return 1 }
58+
}
59+
return 0
60+
}
61+
62+
// Evaluates a single comparison like sys_platform == 'win32' or python_version >= '3.8'.
63+
// Version-bearing variables (python_version, python_full_version) use numeric comparison;
64+
// all others use string equality. Returns false when the env value is missing.
65+
function evaluateComparison(variable, op, value, env) {
66+
let envVal = env[variable]
67+
if (envVal === undefined || envVal === '') {
68+
return false
69+
}
70+
71+
let isVersion = variable.includes('version')
72+
if (isVersion) {
73+
let cmp = compareVersions(envVal, value)
74+
switch (op) {
75+
case '==': return cmp === 0
76+
case '!=': return cmp !== 0
77+
case '>=': return cmp >= 0
78+
case '<=': return cmp <= 0
79+
case '>': return cmp > 0
80+
case '<': return cmp < 0
81+
case '~=': {
82+
let parts = value.split('.')
83+
parts.pop()
84+
let prefix = parts.join('.')
85+
return envVal.startsWith(prefix) && cmp >= 0
86+
}
87+
default: return true
88+
}
89+
}
90+
91+
switch (op) {
92+
case '==': return envVal === value
93+
case '!=': return envVal !== value
94+
case 'in': return value.includes(envVal)
95+
case 'not in': return !value.includes(envVal)
96+
default: return envVal === value
97+
}
98+
}
99+
100+
// Parses a single marker comparison into {variable, op, value}.
101+
// Handles both normal and reversed forms:
102+
// "sys_platform == 'linux'" → { variable: 'sys_platform', op: '==', value: 'linux' }
103+
// "'linux' == sys_platform" → { variable: 'sys_platform', op: '==', value: 'linux' }
104+
function parseAtom(expr) {
105+
// Normal form: variable op 'value'
106+
let m = expr.match(/^\s*([\w.]+)\s*(~=|!=|==|>=|<=|>|<|not\s+in|in)\s*["']([^"']*)["']\s*$/)
107+
if (m) { return { variable: m[1], op: m[2].replace(/\s+/g, ' '), value: m[3] } }
108+
109+
// Reversed form: 'value' op variable — reverse directional operators
110+
let mReverse = expr.match(/^\s*["']([^"']*)['"]\s*(~=|!=|==|>=|<=|>|<|not\s+in|in)\s*([\w.]+)\s*$/)
111+
if (mReverse) {
112+
let reverseOp = { '<': '>', '>': '<', '<=': '>=', '>=': '<=' }
113+
let op = mReverse[2].replace(/\s+/g, ' ')
114+
return { variable: mReverse[3], op: reverseOp[op] || op, value: mReverse[1] }
115+
}
116+
117+
return null
118+
}
119+
120+
/**
121+
* Evaluates a full PEP 508 marker expression against the current platform.
122+
* Example: "sys_platform == 'win32' and python_version >= '3.8'" → false on Linux
123+
* Empty/missing markers return true (unconditional dependency).
124+
* @param {string} markerExpr
125+
* @returns {boolean}
126+
*/
127+
export function evaluateMarker(markerExpr) {
128+
if (!markerExpr || !markerExpr.trim()) { return true }
129+
let env = getEnvironmentMarkers()
130+
return evaluateExpr(markerExpr.trim(), env)
131+
}
132+
133+
function evaluateExpr(expr, env) {
134+
let orParts = splitLogical(expr, ' or ')
135+
if (orParts.length > 1) {
136+
return orParts.some(part => evaluateExpr(part, env))
137+
}
138+
139+
let andParts = splitLogical(expr, ' and ')
140+
if (andParts.length > 1) {
141+
return andParts.every(part => evaluateExpr(part, env))
142+
}
143+
144+
let trimmed = expr.trim()
145+
if (trimmed.startsWith('(') && trimmed.endsWith(')')) {
146+
return evaluateExpr(trimmed.slice(1, -1), env)
147+
}
148+
149+
let atom = parseAtom(trimmed)
150+
if (!atom) { return true }
151+
152+
return evaluateComparison(atom.variable, atom.op, atom.value, env)
153+
}
154+
155+
// Splits an expression by " and " or " or " at the top level, skipping
156+
// separators inside parentheses or quoted strings.
157+
// Example: splitLogical("a == 'x' and (b == 'y' or c == 'z')", " and ")
158+
// → ["a == 'x'", "(b == 'y' or c == 'z')"]
159+
function splitLogical(expr, sep) {
160+
let parts = []
161+
let depth = 0
162+
let current = ''
163+
let i = 0
164+
let quoteChar = null
165+
while (i < expr.length) {
166+
let ch = expr[i]
167+
if (quoteChar) {
168+
if (ch === quoteChar) { quoteChar = null }
169+
current += ch
170+
i++
171+
continue
172+
}
173+
if (ch === '"' || ch === "'") {
174+
quoteChar = ch
175+
current += ch
176+
i++
177+
continue
178+
}
179+
if (ch === '(') { depth++ }
180+
if (ch === ')') { depth-- }
181+
if (depth === 0 && expr.substring(i, i + sep.length) === sep) {
182+
parts.push(current)
183+
current = ''
184+
i += sep.length
185+
continue
186+
}
187+
current += ch
188+
i++
189+
}
190+
parts.push(current)
191+
return parts.filter(p => p.trim())
192+
}

src/providers/python_poetry.js

Lines changed: 82 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import fs from 'node:fs'
22
import path from 'node:path'
33

4+
import { parse as parseToml } from 'smol-toml'
5+
46
import { environmentVariableIsPopulated, getCustom, getCustomPath, invokeCommand } from '../tools.js'
57

68
import Base_pyproject from './base_pyproject.js'
9+
import { evaluateMarker } from './marker_evaluator.js'
710

811
export default class Python_poetry extends Base_pyproject {
912

@@ -50,7 +53,9 @@ export default class Python_poetry extends Base_pyproject {
5053
let treeOutput = this._getPoetryShowTreeOutput(manifestDir, hasDevGroup, opts)
5154
let showAllOutput = this._getPoetryShowAllOutput(manifestDir, opts)
5255
let versionMap = this._parsePoetryShowAll(showAllOutput)
53-
return this._parsePoetryTree(treeOutput, versionMap)
56+
let lockDir = this._findLockFileDir(manifestDir, opts)
57+
let markerData = this._extractMarkerData(lockDir, parsed)
58+
return this._parsePoetryTree(treeOutput, versionMap, markerData)
5459
}
5560

5661
/**
@@ -107,16 +112,64 @@ export default class Python_poetry extends Base_pyproject {
107112
return versions
108113
}
109114

115+
/**
116+
* Collects PEP 508 marker expressions for direct and transitive deps.
117+
* Direct markers come from pyproject.toml dependency strings, e.g.:
118+
* "pywin32>=311 ; sys_platform == 'win32'" → directMarkers['pywin32'] = "sys_platform == 'win32'"
119+
* Transitive markers come from poetry.lock [package.dependencies] entries, e.g.:
120+
* colorama = {version = "*", markers = "sys_platform == 'win32'"}
121+
* → transitiveMarkers['click']['colorama'] = "sys_platform == 'win32'"
122+
* @param {string|null} lockDir
123+
* @param {object} parsed - parsed pyproject.toml
124+
* @returns {{directMarkers: Map<string, string>, transitiveMarkers: Map<string, Map<string, string>>}}
125+
*/
126+
_extractMarkerData(lockDir, parsed) {
127+
let directMarkers = new Map()
128+
let transitiveMarkers = new Map()
129+
130+
// Extract markers from PEP 621 dependency strings: "name[extras]>=ver ; marker"
131+
let deps = parsed.project?.dependencies || []
132+
for (let dep of deps) {
133+
let m = dep.match(/^([A-Za-z0-9][A-Za-z0-9._-]*)\s*[^;]*;\s*(.+)$/)
134+
if (m) {
135+
directMarkers.set(this._canonicalize(m[1]), m[2].trim())
136+
}
137+
}
138+
139+
if (lockDir) {
140+
let lockPath = path.join(lockDir, this._lockFileName())
141+
if (fs.existsSync(lockPath)) {
142+
let lockContent = fs.readFileSync(lockPath, 'utf-8')
143+
let lock = parseToml(lockContent)
144+
let packages = lock.package || []
145+
for (let pkg of packages) {
146+
let pkgKey = this._canonicalize(pkg.name)
147+
let pkgDeps = pkg.dependencies || {}
148+
for (let [depName, depSpec] of Object.entries(pkgDeps)) {
149+
let markers = typeof depSpec === 'object' && depSpec != null ? depSpec.markers : null
150+
if (markers) {
151+
if (!transitiveMarkers.has(pkgKey)) {
152+
transitiveMarkers.set(pkgKey, new Map())
153+
}
154+
transitiveMarkers.get(pkgKey).set(this._canonicalize(depName), markers)
155+
}
156+
}
157+
}
158+
}
159+
}
160+
161+
return { directMarkers, transitiveMarkers }
162+
}
163+
110164
/**
111165
* Parse poetry show --tree output into a dependency graph structure.
112-
* Top-level lines (no indentation/tree chars) are direct deps: "name version description"
113-
* Indented lines are transitive deps with tree chars: "├── name >=constraint"
114166
*
115167
* @param {string} treeOutput
116168
* @param {Map<string, string>} versionMap - canonical name -> resolved version
169+
* @param {{directMarkers: Map<string, string>, transitiveMarkers: Map<string, Map<string, string>>}} markerData
117170
* @returns {{directDeps: string[], graph: Map<string, {name: string, version: string, children: string[]}>}}
118171
*/
119-
_parsePoetryTree(treeOutput, versionMap) {
172+
_parsePoetryTree(treeOutput, versionMap, markerData) {
120173
let lines = treeOutput.split(/\r?\n/)
121174
let graph = new Map()
122175
let directDeps = []
@@ -133,6 +186,14 @@ export default class Python_poetry extends Base_pyproject {
133186
let name = topMatch[1]
134187
let version = topMatch[2]
135188
let key = this._canonicalize(name)
189+
190+
let marker = markerData.directMarkers.get(key)
191+
if (marker && !evaluateMarker(marker)) {
192+
currentDirectDep = null
193+
stack = []
194+
continue
195+
}
196+
136197
directDeps.push(key)
137198
if (!graph.has(key)) {
138199
graph.set(key, { name, version, children: [] })
@@ -159,6 +220,22 @@ export default class Python_poetry extends Base_pyproject {
159220
let prefix = line.substring(0, nameStart)
160221
let depth = (prefix.match(/(?:[ ][\s]{2} ?)/g) || []).length
161222

223+
// pop stack back to find the parent at depth-1
224+
while (stack.length > 0 && stack[stack.length - 1].depth >= depth) {
225+
stack.pop()
226+
}
227+
228+
let parentKey = stack.length > 0 ? stack[stack.length - 1].key : null
229+
if (parentKey) {
230+
let parentMarkers = markerData.transitiveMarkers.get(parentKey)
231+
if (parentMarkers) {
232+
let marker = parentMarkers.get(depKey)
233+
if (marker && !evaluateMarker(marker)) {
234+
continue
235+
}
236+
}
237+
}
238+
162239
// resolve version from the version map
163240
let version = versionMap.get(depKey) || null
164241
if (!version) {
@@ -169,13 +246,7 @@ export default class Python_poetry extends Base_pyproject {
169246
graph.set(depKey, { name: depName, version, children: [] })
170247
}
171248

172-
// pop stack back to find the parent at depth-1
173-
while (stack.length > 0 && stack[stack.length - 1].depth >= depth) {
174-
stack.pop()
175-
}
176-
177-
if (stack.length > 0) {
178-
let parentKey = stack[stack.length - 1].key
249+
if (parentKey) {
179250
let parentEntry = graph.get(parentKey)
180251
if (parentEntry && !parentEntry.children.includes(depKey)) {
181252
parentEntry.children.push(depKey)

src/providers/python_uv.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { parse as parseToml } from 'smol-toml'
66
import { environmentVariableIsPopulated, getCustomPath, invokeCommand } from '../tools.js'
77

88
import Base_pyproject from './base_pyproject.js'
9+
import { evaluateMarker } from './marker_evaluator.js'
910
import { getParser, getPinnedVersionQuery } from './requirements_parser.js'
1011

1112
export default class Python_uv extends Base_pyproject {
@@ -98,6 +99,17 @@ export default class Python_uv extends Base_pyproject {
9899
let nameNode = child.children.find(c => c.type === 'package')
99100
if (!nameNode) { continue }
100101

102+
// Skip packages with non-matching PEP 508 markers, e.g. "pywin32==311 ; sys_platform == 'win32'"
103+
let markerNode = child.children.find(c => c.type === 'marker_spec')
104+
if (markerNode) {
105+
let markerText = markerNode.text.replace(/^\s*;\s*/, '')
106+
if (!evaluateMarker(markerText)) {
107+
currentPkg = null
108+
collectingVia = false
109+
continue
110+
}
111+
}
112+
101113
let name = nameNode.text
102114
let version = null
103115
let versionMatches = pinnedVersionQuery.matches(child)

0 commit comments

Comments
 (0)