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 ",