Files
syft/syft/pkg/cataloger/python/parse_requirements.go
T
Sueun Cho fd4796b0d8 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 <sueun.dev@gmail.com>
2026-08-20 13:06:17 +00:00

325 lines
9.1 KiB
Go

package python
import (
"bufio"
"context"
"fmt"
"regexp"
"strings"
"unicode"
pep440 "github.com/aquasecurity/go-pep440-version"
"github.com/go-viper/mapstructure/v2"
"github.com/anchore/syft/internal"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/internal/unknown"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/pkg/cataloger/generic"
)
const (
// given the example requirement:
// requests[security] == 2.8.* ; python_version < "2.7" and sys_platform == "linux" \
// --hash=sha256:a9b3aaa1904eeb78e32394cd46c6f37ac0fb4af6dc488daa58971bdc7d7fcaf3 \
// --hash=sha256:e9535b8c84dc9571a48999094fda7f33e63c3f1b74f3e5f3ac0105a58405bb65 # some comment
// namePattern matches: requests[security]
namePattern = `(?P<name>\w[\w\[\],\s-_\.]+)`
// 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<versionConstraint>([^\S\r\n]*[~=>!<]+\s*[0-9a-zA-Z.*+!_-]+[^\S\r\n]*,?)+)?(@[^\S\r\n]*(?P<url>[^;]*))?`
// markersPattern matches: python_version < "2.7" and sys_platform == "linux"
markersPattern = `(;(?P<markers>.*))?`
// hashesPattern matches: --hash=sha256:a9b3aaa1904eeb78e32394cd46c6f37ac0fb4af6dc488daa58971bdc7d7fcaf3 --hash=sha256:e9535b8c84dc9571a48999094fda7f33e63c3f1b74f3e5f3ac0105a58405bb65
hashesPattern = `(?P<hashes>([^\S\r\n]*--hash=[a-zA-Z0-9:]+)+)?`
// whiteSpaceNoNewlinePattern matches: (any whitespace character except for \r and \n)
whiteSpaceNoNewlinePattern = `[^\S\r\n]*`
)
var requirementPattern = regexp.MustCompile(
`^` +
whiteSpaceNoNewlinePattern +
namePattern +
whiteSpaceNoNewlinePattern +
versionConstraintPattern +
markersPattern +
hashesPattern,
)
type unprocessedRequirement struct {
Name string `mapstructure:"name"`
VersionConstraint string `mapstructure:"versionConstraint"`
Markers string `mapstructure:"markers"`
URL string `mapstructure:"url"`
Hashes string `mapstructure:"hashes"`
}
func newRequirement(raw string) *unprocessedRequirement {
var r unprocessedRequirement
values := internal.MatchNamedCaptureGroups(requirementPattern, raw)
if err := mapstructure.Decode(values, &r); err != nil {
return nil
}
r.Name = strings.TrimSpace(r.Name)
r.VersionConstraint = strings.TrimSpace(r.VersionConstraint)
r.Markers = strings.TrimSpace(r.Markers)
r.URL = strings.TrimSpace(r.URL)
r.Hashes = strings.TrimSpace(r.Hashes)
if r.Name == "" {
return nil
}
return &r
}
type requirementsParser struct {
cfg CatalogerConfig
licenseResolver pythonLicenseResolver
}
func newRequirementsParser(cfg CatalogerConfig) requirementsParser {
return requirementsParser{
cfg: cfg,
licenseResolver: newPythonLicenseResolver(cfg),
}
}
// parseRequirementsTxt takes a Python requirements.txt file, returning all Python packages that are locked to a
// specific version.
func (rp requirementsParser) parseRequirementsTxt(ctx context.Context, _ file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
var errs error
var packages []pkg.Package
scanner := bufio.NewScanner(reader)
var lastLine string
for scanner.Scan() {
line := trimRequirementsTxtLine(scanner.Text())
if lastLine != "" {
line = lastLine + line
lastLine = ""
}
// remove line continuations... smashes the file into a single line
if before, ok := strings.CutSuffix(line, "\\"); ok {
// this line is a continuation of the previous line
lastLine += before
continue
}
if line == "" {
// nothing to parse on this line
continue
}
if strings.HasPrefix(line, "-e") {
// editable packages aren't parsed (yet)
continue
}
req := newRequirement(line)
if req == nil {
log.WithFields("path", reader.RealPath, "line", line).Debug("unable to parse requirements.txt line")
errs = unknown.Appendf(errs, reader, "unable to parse requirements.txt line: %q", line)
continue
}
name := removeExtras(req.Name)
version := parseVersion(req.VersionConstraint, rp.cfg.GuessUnpinnedRequirements)
if version == "" {
log.WithFields("path", reader.RealPath, "line", line).Trace("unable to determine package version in requirements.txt line")
errs = unknown.Appendf(errs, reader, "unable to determine package version in requirements.txt line: %q", line)
continue
}
packages = append(
packages,
newPackageForRequirementsWithMetadata(
ctx,
rp.licenseResolver,
name,
version,
pkg.PythonRequirementsEntry{
Name: name,
Extras: parseExtras(req.Name),
VersionConstraint: req.VersionConstraint,
URL: parseURL(req.URL),
Markers: req.Markers,
},
reader.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation),
),
)
}
if err := scanner.Err(); err != nil {
return nil, nil, fmt.Errorf("failed to parse python requirements file: %w", err)
}
return packages, nil, unknown.Join(errs, unknown.IfEmptyf(packages, "unable to determine packages"))
}
func parseVersion(version string, guessFromConstraint bool) string {
if version := parsePinnedVersion(version); version != "" {
return version
}
if guessFromConstraint {
return guessVersion(version)
}
return ""
}
func parsePinnedVersion(version string) string {
version = strings.TrimSpace(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 ""
}
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 version := parsePinnedVersion(constraint); version != "" {
return version
}
constraints := strings.Split(constraint, ",")
filteredVersions := map[string]struct{}{}
for _, part := range constraints {
if strings.Contains(part, "!=") {
parts := strings.Split(part, "!=")
filteredVersions[strings.TrimSpace(parts[1])] = struct{}{}
}
}
var closestVersion *pep440.Version
for _, part := range constraints {
// ignore any parts that do not have '=' in them, >,<,~ are not valid semver
parts := strings.SplitAfter(part, "=")
if len(parts) < 2 {
continue
}
version, err := pep440.Parse(strings.TrimSpace(parts[1]))
if err != nil {
// ignore any parts that are not valid semver
continue
}
if _, ok := filteredVersions[version.String()]; ok {
continue
}
if strings.Contains(part, "==") {
parts := strings.Split(part, "==")
return strings.TrimSpace(parts[1])
}
if closestVersion == nil || version.GreaterThan(*closestVersion) {
closestVersion = &version
}
}
if closestVersion == nil {
return ""
}
return closestVersion.String()
}
// trimRequirementsTxtLine removes content from the given requirements.txt line
// that should not be considered for parsing.
func trimRequirementsTxtLine(line string) string {
line = strings.TrimSpace(line)
line = removeTrailingComment(line)
return line
}
// removeTrailingComment takes a requirements.txt line and strips off comment strings.
func removeTrailingComment(line string) string {
parts := strings.SplitN(line, "#", 2)
if len(parts) < 2 {
// there aren't any comments
return line
}
return parts[0]
}
func removeExtras(packageName string) string {
before, _, ok := strings.Cut(packageName, "[")
if !ok {
return packageName
}
return strings.TrimSpace(before)
}
func parseExtras(packageName string) []string {
var extras []string
start := strings.Index(packageName, "[")
stop := strings.Index(packageName, "]")
if start == -1 || stop == -1 {
return extras
}
extraString := packageName[start+1 : stop]
for _, extra := range strings.Split(extraString, ",") {
extras = append(extras, strings.TrimSpace(extra))
}
return extras
}
func parseURL(line string) string {
parts := strings.Split(line, "@")
if len(parts) > 1 {
desiredIndex := -1
for index, part := range parts {
part := strings.TrimFunc(part, func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsNumber(r)
})
if strings.HasPrefix(part, "git") {
desiredIndex = index
break
}
}
if desiredIndex != -1 {
return strings.TrimSpace(strings.Join(parts[desiredIndex:], "@"))
}
}
return ""
}