From fd4796b0d838b1b31765c28179c8f16a1a02992f Mon Sep 17 00:00:00 2001 From: Sueun Cho Date: Thu, 20 Aug 2026 22:06:17 +0900 Subject: [PATCH] fix(python): keep epoch-pinned requirements in the SBOM (#5161) parsePinnedVersion rejected any version constraint containing "!" to skip the "!=" exclusion operator. PEP 440 epochs use "!" as well (e.g. "1!2.0.0"), so an exact pin like "pkg == 1!2.0.0" was treated as unparseable and returned "". parseRequirementsTxt drops a requirement whose version resolves to "", so the package never made it into the SBOM at all in either the pinned or guessed path. Check for the "!=" operator as a substring instead of the bare "!" character, which leaves the epoch separator alone. Signed-off-by: Sueun Cho --- .../cataloger/python/parse_requirements.go | 5 ++++- .../python/parse_requirements_test.go | 20 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/syft/pkg/cataloger/python/parse_requirements.go b/syft/pkg/cataloger/python/parse_requirements.go index a7117bdee..f79737e6a 100644 --- a/syft/pkg/cataloger/python/parse_requirements.go +++ b/syft/pkg/cataloger/python/parse_requirements.go @@ -188,7 +188,10 @@ func parseVersion(version string, guessFromConstraint bool) string { func parsePinnedVersion(version string) string { version = strings.TrimSpace(version) - if strings.ContainsAny(version, "*,<>!") { + // a wildcard, range, or list of constraints is not a single pinned version. the "!=" exclusion + // operator is checked as a substring rather than by the bare "!" character, since a lone "!" + // is also the PEP 440 epoch separator (e.g. "1!2.0.0") and must not disqualify an exact pin. + if strings.ContainsAny(version, "*,<>") || strings.Contains(version, "!=") { return "" } diff --git a/syft/pkg/cataloger/python/parse_requirements_test.go b/syft/pkg/cataloger/python/parse_requirements_test.go index 735804e50..1175f7e4d 100644 --- a/syft/pkg/cataloger/python/parse_requirements_test.go +++ b/syft/pkg/cataloger/python/parse_requirements_test.go @@ -562,6 +562,26 @@ func Test_parseVersion(t *testing.T) { version: "== 1.0a1.post2.dev3+local.1", want: "1.0a1.post2.dev3+local.1", }, + { + name: "epoch", + version: "== 1!2.0.0", + want: "1!2.0.0", + }, + { + name: "epoch with all segments combined", + version: "== 1!1.0a1.post2.dev3+local.1", + want: "1!1.0a1.post2.dev3+local.1", + }, + { + name: "arbitrary equality with epoch", + version: "=== 1!2.0.0", + want: "1!2.0.0", + }, + { + name: "bare exclusion is not a pin", + version: "!= 1.1.0", + want: "", + }, { name: "resolve lowest, simple constraint", version: " >= 1.0.0 ",