From ae711963d17998715712573f2d6d8f5f3253bd32 Mon Sep 17 00:00:00 2001 From: Rayan Salhab Date: Tue, 5 May 2026 16:49:03 +0300 Subject: [PATCH] fix: parse arbitrary equality python requirements (#4835) Signed-off-by: cyphercodes Signed-off-by: Alex Goodman Co-authored-by: cyphercodes --- .../cataloger/python/parse_requirements.go | 23 ++++++++++++----- .../python/parse_requirements_test.go | 25 +++++++++++++++++++ .../python/testdata/requires/requirements.txt | 1 + 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/syft/pkg/cataloger/python/parse_requirements.go b/syft/pkg/cataloger/python/parse_requirements.go index b35940abc..dfaf01cf9 100644 --- a/syft/pkg/cataloger/python/parse_requirements.go +++ b/syft/pkg/cataloger/python/parse_requirements.go @@ -170,8 +170,8 @@ func (rp requirementsParser) parseRequirementsTxt(ctx context.Context, _ file.Re } func parseVersion(version string, guessFromConstraint bool) string { - if isPinnedConstraint(version) { - return strings.TrimSpace(strings.ReplaceAll(version, "==", "")) + if version := parsePinnedVersion(version); version != "" { + return version } if guessFromConstraint { @@ -181,15 +181,26 @@ func parseVersion(version string, guessFromConstraint bool) string { return "" } -func isPinnedConstraint(version string) bool { - return strings.Contains(version, "==") && !strings.ContainsAny(version, "*,<>!") +func parsePinnedVersion(version string) string { + version = strings.TrimSpace(version) + if strings.ContainsAny(version, "*,<>!") { + return "" + } + + for _, operator := range []string{"===", "=="} { + if strings.HasPrefix(version, operator) && !strings.HasPrefix(version, operator+"=") { + return strings.TrimSpace(strings.TrimPrefix(version, operator)) + } + } + + return "" } func guessVersion(constraint string) string { // handle "2.8.*" -> "2.8.0" constraint = strings.ReplaceAll(constraint, "*", "0") - if isPinnedConstraint(constraint) { - return strings.TrimSpace(strings.ReplaceAll(constraint, "==", "")) + if version := parsePinnedVersion(constraint); version != "" { + return version } constraints := strings.Split(constraint, ",") diff --git a/syft/pkg/cataloger/python/parse_requirements_test.go b/syft/pkg/cataloger/python/parse_requirements_test.go index 6f40974b8..2b831ae33 100644 --- a/syft/pkg/cataloger/python/parse_requirements_test.go +++ b/syft/pkg/cataloger/python/parse_requirements_test.go @@ -29,6 +29,18 @@ func TestParseRequirementsTxt(t *testing.T) { VersionConstraint: "== 4.0.0", }, }, + { + Name: "urllib3", + Version: "1.26.20", + PURL: "pkg:pypi/urllib3@1.26.20", + Locations: locations, + Language: pkg.Python, + Type: pkg.PythonPkg, + Metadata: pkg.PythonRequirementsEntry{ + Name: "urllib3", + VersionConstraint: "===1.26.20", + }, + }, { Name: "foo", Version: "1.0.0", @@ -294,6 +306,14 @@ func Test_newRequirement(t *testing.T) { VersionConstraint: "==2.8", }, }, + { + name: "arbitrary equality", + raw: "urllib3===1.26.20", + want: &unprocessedRequirement{ + Name: "urllib3", + VersionConstraint: "===1.26.20", + }, + }, { name: "comment + constraint", raw: "Mopidy-Dirble ~= 1.1 # Compatible release. Same as >= 1.1, == 1.*", @@ -363,6 +383,11 @@ func Test_parseVersion(t *testing.T) { version: " == 1.0.0 ", want: "1.0.0", }, + { + name: "arbitrary equality constraint", + version: " === 1.26.20 ", + want: "1.26.20", + }, { name: "resolve lowest, simple constraint", version: " >= 1.0.0 ", diff --git a/syft/pkg/cataloger/python/testdata/requires/requirements.txt b/syft/pkg/cataloger/python/testdata/requires/requirements.txt index a238ccdcf..656a4e583 100644 --- a/syft/pkg/cataloger/python/testdata/requires/requirements.txt +++ b/syft/pkg/cataloger/python/testdata/requires/requirements.txt @@ -1,4 +1,5 @@ flask == 4.0.0 +urllib3===1.26.20 # a line that is ignored sqlalchemy >= 1.0.0, <= 2.0.0, != 3.0.0, <= 3.0.0 foo == 1.0.0 # a comment that needs to be ignored