From 1a72b326bba9e0959d5f3b63367bb311f064d795 Mon Sep 17 00:00:00 2001 From: Ashwani Kumar Kamal <75236490+sneaky-potato@users.noreply.github.com> Date: Fri, 30 Jan 2026 16:31:47 +0530 Subject: [PATCH] feat(python): add pylock.toml (PEP 751) parser (#9632) Co-authored-by: DmitriyLewen --- pkg/dependency/parser/python/pylock/parse.go | 98 ++++++++++++++++ .../parser/python/pylock/parse_test.go | 102 ++++++++++++++++ .../parser/python/pylock/testdata/pylock.toml | 109 ++++++++++++++++++ .../parser/python/pylock/testdata/sad.toml | 1 + pkg/fanal/types/const.go | 1 + 5 files changed, 311 insertions(+) create mode 100644 pkg/dependency/parser/python/pylock/parse.go create mode 100644 pkg/dependency/parser/python/pylock/parse_test.go create mode 100644 pkg/dependency/parser/python/pylock/testdata/pylock.toml create mode 100644 pkg/dependency/parser/python/pylock/testdata/sad.toml diff --git a/pkg/dependency/parser/python/pylock/parse.go b/pkg/dependency/parser/python/pylock/parse.go new file mode 100644 index 0000000000..a103c67803 --- /dev/null +++ b/pkg/dependency/parser/python/pylock/parse.go @@ -0,0 +1,98 @@ +package pylock + +import ( + "sort" + + "github.com/BurntSushi/toml" + "github.com/samber/lo" + "golang.org/x/xerrors" + + "github.com/aquasecurity/trivy/pkg/dependency" + "github.com/aquasecurity/trivy/pkg/dependency/parser/python" + ftypes "github.com/aquasecurity/trivy/pkg/fanal/types" + xio "github.com/aquasecurity/trivy/pkg/x/io" +) + +type Pylock struct { + Packages []Package `toml:"packages"` +} + +type Package struct { + Name string `toml:"name"` + Version string `toml:"version"` + Dependencies []Dependency `toml:"dependencies"` +} + +type Dependency struct { + Name string `toml:"name"` + Version string `toml:"version"` +} + +// Parser parses pylock.toml defined in PEP 751. +// https://peps.python.org/pep-0751 +type Parser struct{} + +func NewParser() *Parser { + return &Parser{} +} + +func (p *Parser) Parse(r xio.ReadSeekerAt) ([]ftypes.Package, []ftypes.Dependency, error) { + var lock Pylock + if _, err := toml.NewDecoder(r).Decode(&lock); err != nil { + return nil, nil, xerrors.Errorf("failed to decode pylock.toml: %w", err) + } + + pkgs := make(map[string]ftypes.Package) + deps := make(map[string][]string) + + for _, pkg := range lock.Packages { + normalizedPkgName := python.NormalizePkgName(pkg.Name, true) + pkgID := packageID(normalizedPkgName, pkg.Version) + + pkgs[pkgID] = ftypes.Package{ + ID: pkgID, + Name: normalizedPkgName, + Version: pkg.Version, + } + + var dependsOn []string + for _, dep := range pkg.Dependencies { + depName := python.NormalizePkgName(dep.Name, true) + depID := packageID(depName, dep.Version) + dependsOn = append(dependsOn, depID) + } + if len(dependsOn) > 0 { + sort.Strings(dependsOn) + deps[pkgID] = dependsOn + } + } + + depSlice := lo.MapToSlice(deps, func(pkgID string, dependsOn []string) ftypes.Dependency { + if _, ok := pkgs[pkgID]; !ok { + return ftypes.Dependency{} + } + + // Filter out dependencies that are not in the package list + var dependsOnIDs []string + for _, depID := range dependsOn { + if _, ok := pkgs[depID]; ok { + dependsOnIDs = append(dependsOnIDs, depID) + } + } + + return ftypes.Dependency{ + ID: pkgID, + DependsOn: dependsOnIDs, + } + }) + + pkgSlice := lo.Values(pkgs) + sort.Sort(ftypes.Packages(pkgSlice)) + sort.Sort(ftypes.Dependencies(depSlice)) + + return pkgSlice, depSlice, nil +} + +func packageID(name, ver string) string { + return dependency.ID(ftypes.PyLock, name, ver) +} diff --git a/pkg/dependency/parser/python/pylock/parse_test.go b/pkg/dependency/parser/python/pylock/parse_test.go new file mode 100644 index 0000000000..a742a410ea --- /dev/null +++ b/pkg/dependency/parser/python/pylock/parse_test.go @@ -0,0 +1,102 @@ +package pylock_test + +import ( + "fmt" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/aquasecurity/trivy/pkg/dependency/parser/python/pylock" + ftypes "github.com/aquasecurity/trivy/pkg/fanal/types" +) + +func TestParser_Parse(t *testing.T) { + tests := []struct { + name string + file string + wantPkgs []ftypes.Package + wantDeps []ftypes.Dependency + wantErr assert.ErrorAssertionFunc + }{ + { + name: "happy path", + file: "testdata/pylock.toml", + wantPkgs: []ftypes.Package{ + { + ID: "attrs@25.4.0", + Name: "attrs", + Version: "25.4.0", + }, + { + ID: "certifi@2025.10.5", + Name: "certifi", + Version: "2025.10.5", + }, + { + ID: "charset-normalizer@3.4.3", + Name: "charset-normalizer", + Version: "3.4.3", + }, + { + ID: "ham@3.0.0", + Name: "ham", + Version: "3.0.0", + }, + { + ID: "idna@3.10", + Name: "idna", + Version: "3.10", + }, + { + ID: "requests@2.32.5", + Name: "requests", + Version: "2.32.5", + }, + { + ID: "spam@1.0.0", + Name: "spam", + Version: "1.0.0", + }, + { + ID: "spam@1.1.0", + Name: "spam", + Version: "1.1.0", + }, + { + ID: "urllib3@2.5.0", + Name: "urllib3", + Version: "2.5.0", + }, + }, + wantDeps: []ftypes.Dependency{ + { + ID: "ham@3.0.0", + DependsOn: []string{"spam@1.1.0"}, + }, + }, + wantErr: assert.NoError, + }, + { + name: "sad path", + file: "testdata/sad.toml", + wantErr: assert.Error, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, err := os.Open(tt.file) + require.NoError(t, err) + defer f.Close() + + p := pylock.NewParser() + gotPkgs, gotDeps, err := p.Parse(f) + if !tt.wantErr(t, err, fmt.Sprintf("Parse(%v)", tt.file)) { + return + } + assert.Equalf(t, tt.wantPkgs, gotPkgs, "Parse(%v)", tt.file) + assert.Equalf(t, tt.wantDeps, gotDeps, "Parse(%v)", tt.file) + }) + } +} diff --git a/pkg/dependency/parser/python/pylock/testdata/pylock.toml b/pkg/dependency/parser/python/pylock/testdata/pylock.toml new file mode 100644 index 0000000000..49580d6b60 --- /dev/null +++ b/pkg/dependency/parser/python/pylock/testdata/pylock.toml @@ -0,0 +1,109 @@ +lock-version = "1.0" +created-by = "pip" + +[[packages]] +name = "attrs" +version = "25.4.0" + +[[packages.wheels]] +name = "attrs-25.4.0-py3-none-any.whl" +url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl" + +[packages.wheels.hashes] +sha256 = "adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373" + +[[packages]] +name = "certifi" +version = "2025.10.5" + +[[packages.wheels]] +name = "certifi-2025.10.5-py3-none-any.whl" +url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl" + +[packages.wheels.hashes] +sha256 = "0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de" + +[[packages]] +name = "charset-normalizer" +version = "3.4.3" + +[[packages.wheels]] +name = "charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl" +url = "https://files.pythonhosted.org/packages/87/df/b7737ff046c974b183ea9aa111b74185ac8c3a326c6262d413bd5a1b8c69/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl" + +[packages.wheels.hashes] +sha256 = "0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07" + +[[packages]] +name = "idna" +version = "3.10" + +[[packages.wheels]] +name = "idna-3.10-py3-none-any.whl" +url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl" + +[packages.wheels.hashes] +sha256 = "946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3" + +[[packages]] +name = "requests" +version = "2.32.5" + +[[packages.wheels]] +name = "requests-2.32.5-py3-none-any.whl" +url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl" + +[packages.wheels.hashes] +sha256 = "2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6" + +[[packages]] +name = "urllib3" +version = "2.5.0" + +[[packages.wheels]] +name = "urllib3-2.5.0-py3-none-any.whl" +url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl" + +[packages.wheels.hashes] +sha256 = "e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc" + +# Packages with same name but different versions for different environments +[[packages]] +name = "spam" +version = "1.0.0" +marker = "sys_platform == 'linux'" + +[[packages.wheels]] +name = "spam-1.0.0-py3-none-any.whl" +url = "https://example.invalid/files/spam-1.0.0-py3-none-any.whl" + +[packages.wheels.hashes] +sha256 = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + +[[packages]] +name = "spam" +version = "1.1.0" +marker = "sys_platform == 'win32'" + +[[packages.wheels]] +name = "spam-1.1.0-py3-none-any.whl" +url = "https://example.invalid/files/spam-1.1.0-py3-none-any.whl" + +[packages.wheels.hashes] +sha256 = "feebdaedfeebdaedfeebdaedfeebdaedfeebdaedfeebdaedfeebdaedfeebdaed" + +[[packages]] +name = "ham" +version = "3.0.0" +marker = "sys_platform == 'linux'" +dependencies = [ + { name = "spam", version = "1.0.0" } +] + +[[packages]] +name = "ham" +version = "3.0.0" +marker = "sys_platform == 'win32'" +dependencies = [ + { name = "spam", version = "1.1.0" } +] diff --git a/pkg/dependency/parser/python/pylock/testdata/sad.toml b/pkg/dependency/parser/python/pylock/testdata/sad.toml new file mode 100644 index 0000000000..558ed37d93 --- /dev/null +++ b/pkg/dependency/parser/python/pylock/testdata/sad.toml @@ -0,0 +1 @@ +[ diff --git a/pkg/fanal/types/const.go b/pkg/fanal/types/const.go index ac7df78dec..8a12d96d00 100644 --- a/pkg/fanal/types/const.go +++ b/pkg/fanal/types/const.go @@ -214,6 +214,7 @@ const ( PipfileLock = "Pipfile.lock" PoetryLock = "poetry.lock" UvLock = "uv.lock" + PyLock = "pylock.toml" GemfileLock = "Gemfile.lock"