From 76ede661db6d246424e8144f12323c3239945a1e Mon Sep 17 00:00:00 2001 From: Keith Zantow Date: Mon, 13 Jul 2026 15:10:12 -0400 Subject: [PATCH] fix: allow more PEP440-compliant characters in python versions (#4964) Signed-off-by: Keith Zantow --- .../cataloger/python/parse_requirements.go | 9 +- .../python/parse_requirements_test.go | 135 ++++++++++++++++++ 2 files changed, 142 insertions(+), 2 deletions(-) diff --git a/syft/pkg/cataloger/python/parse_requirements.go b/syft/pkg/cataloger/python/parse_requirements.go index 08d284c79..a7117bdee 100644 --- a/syft/pkg/cataloger/python/parse_requirements.go +++ b/syft/pkg/cataloger/python/parse_requirements.go @@ -29,8 +29,13 @@ const ( // namePattern matches: requests[security] namePattern = `(?P\w[\w\[\],\s-_\.]+)` - // versionConstraintPattern matches: == 2.8.* (including local version identifiers, e.g. == 1.2.3+gcr.2) - versionConstraintPattern = `(?P([^\S\r\n]*[~=>!<]+\s*[0-9a-zA-Z.*+]+[^\S\r\n]*,?)+)?(@[^\S\r\n]*(?P[^;]*))?` + // versionConstraintPattern matches: == 2.8.* + // the version token accepts every character PEP 440 versions can contain: digits and letters, + // '.' (release/segment separator), '!' (epoch), '+' (local version), '*' (wildcard), and the + // alternate segment separators '-' and '_'. See: + // https://packaging.python.org/en/latest/specifications/version-specifiers/#version-scheme + // 1!2.0 1.0rc1 1.0-alpha-1 1.0_beta_2 1.0.post1 1.0.dev1 1.2.3+gcr.2 2.8.* + versionConstraintPattern = `(?P([^\S\r\n]*[~=>!<]+\s*[0-9a-zA-Z.*+!_-]+[^\S\r\n]*,?)+)?(@[^\S\r\n]*(?P[^;]*))?` // markersPattern matches: python_version < "2.7" and sys_platform == "linux" markersPattern = `(;(?P.*))?` diff --git a/syft/pkg/cataloger/python/parse_requirements_test.go b/syft/pkg/cataloger/python/parse_requirements_test.go index 8c9d4e54c..735804e50 100644 --- a/syft/pkg/cataloger/python/parse_requirements_test.go +++ b/syft/pkg/cataloger/python/parse_requirements_test.go @@ -386,6 +386,111 @@ func Test_newRequirement(t *testing.T) { Markers: "sys_platform == 'linux'", }, }, + { + name: "epoch", + raw: "pkg == 1!2.0.0", + want: &unprocessedRequirement{ + Name: "pkg", + VersionConstraint: "== 1!2.0.0", + }, + }, + { + name: "pre-release", + raw: "pkg == 1.0rc1", + want: &unprocessedRequirement{ + Name: "pkg", + VersionConstraint: "== 1.0rc1", + }, + }, + { + name: "pre-release with dash separators", + raw: "pkg == 1.0-alpha-1", + want: &unprocessedRequirement{ + Name: "pkg", + VersionConstraint: "== 1.0-alpha-1", + }, + }, + { + name: "pre-release with underscore separators", + raw: "pkg == 1.0_beta_2", + want: &unprocessedRequirement{ + Name: "pkg", + VersionConstraint: "== 1.0_beta_2", + }, + }, + { + name: "post-release", + raw: "pkg == 1.0.post1", + want: &unprocessedRequirement{ + Name: "pkg", + VersionConstraint: "== 1.0.post1", + }, + }, + { + name: "implicit post-release", + raw: "pkg == 1.0-1", + want: &unprocessedRequirement{ + Name: "pkg", + VersionConstraint: "== 1.0-1", + }, + }, + { + name: "dev-release", + raw: "pkg == 1.0.dev1", + want: &unprocessedRequirement{ + Name: "pkg", + VersionConstraint: "== 1.0.dev1", + }, + }, + { + name: "local version with dash separator", + raw: "pkg == 1.0+ubuntu-1", + want: &unprocessedRequirement{ + Name: "pkg", + VersionConstraint: "== 1.0+ubuntu-1", + }, + }, + { + name: "local version with underscore separator", + raw: "pkg == 1.0+ubuntu_1", + want: &unprocessedRequirement{ + Name: "pkg", + VersionConstraint: "== 1.0+ubuntu_1", + }, + }, + { + name: "all segments combined", + raw: "pkg == 1!1.0a1.post2.dev3+local.1", + want: &unprocessedRequirement{ + Name: "pkg", + VersionConstraint: "== 1!1.0a1.post2.dev3+local.1", + }, + }, + { + name: "release wildcard", + raw: "pkg == 2.*", + want: &unprocessedRequirement{ + Name: "pkg", + VersionConstraint: "== 2.*", + }, + }, + { + name: "epoch with markers and hashes", + raw: "pkg == 1!2.0 ; python_version < '3.8' --hash=sha256:abc123", + want: &unprocessedRequirement{ + Name: "pkg", + VersionConstraint: "== 1!2.0", + Markers: "python_version < '3.8' --hash=sha256:abc123", + }, + }, + { + name: "compound constraint with exclusion is unaffected", + raw: "pkg >= 1.0.0, != 1.1.0, < 2.0.0", + want: &unprocessedRequirement{ + Name: "pkg", + VersionConstraint: ">= 1.0.0, != 1.1.0, < 2.0.0", + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -427,6 +532,36 @@ func Test_parseVersion(t *testing.T) { version: " === 1.2.3+ubuntu1 ", want: "1.2.3+ubuntu1", }, + { + name: "pre-release", + version: "== 1.0rc1", + want: "1.0rc1", + }, + { + name: "pre-release with dash separators", + version: "== 1.0-alpha-1", + want: "1.0-alpha-1", + }, + { + name: "post-release", + version: "== 1.0.post1", + want: "1.0.post1", + }, + { + name: "dev-release", + version: "== 1.0.dev1", + want: "1.0.dev1", + }, + { + name: "local version with dash separator", + version: "== 1.0+ubuntu-1", + want: "1.0+ubuntu-1", + }, + { + name: "all segments combined", + version: "== 1.0a1.post2.dev3+local.1", + want: "1.0a1.post2.dev3+local.1", + }, { name: "resolve lowest, simple constraint", version: " >= 1.0.0 ",