Skip to content

Commit 8b93081

Browse files
aqua-botnikpivkin
andauthored
fix(python): skip dev group's deps for poetry [backport: release/v0.58] (#8158)
Signed-off-by: nikpivkin <nikita.pivkin@smartforce.io> Co-authored-by: Nikita Pivkin <nikita.pivkin@smartforce.io>
1 parent 18cd1a5 commit 8b93081

File tree

11 files changed

+650
-50
lines changed

11 files changed

+650
-50
lines changed

pkg/dependency/parser/python/poetry/parse.go

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@ package poetry
22

33
import (
44
"sort"
5-
"strings"
65

76
"github.com/BurntSushi/toml"
87
"golang.org/x/xerrors"
98

109
version "github.com/aquasecurity/go-pep440-version"
1110
"github.com/aquasecurity/trivy/pkg/dependency"
11+
"github.com/aquasecurity/trivy/pkg/dependency/parser/python"
1212
ftypes "github.com/aquasecurity/trivy/pkg/fanal/types"
1313
"github.com/aquasecurity/trivy/pkg/log"
1414
xio "github.com/aquasecurity/trivy/pkg/x/io"
@@ -105,7 +105,7 @@ func (p *Parser) parseDependencies(deps map[string]any, pkgVersions map[string][
105105
}
106106

107107
func (p *Parser) parseDependency(name string, versRange any, pkgVersions map[string][]string) (string, error) {
108-
name = NormalizePkgName(name)
108+
name = python.NormalizePkgName(name)
109109
vers, ok := pkgVersions[name]
110110
if !ok {
111111
return "", xerrors.Errorf("no version found for %q", name)
@@ -149,17 +149,6 @@ func matchVersion(currentVersion, constraint string) (bool, error) {
149149
return c.Check(v), nil
150150
}
151151

152-
// NormalizePkgName normalizes the package name based on pep-0426
153-
func NormalizePkgName(name string) string {
154-
// The package names don't use `_`, `.` or upper case, but dependency names can contain them.
155-
// We need to normalize those names.
156-
// cf. https://peps.python.org/pep-0426/#name
157-
name = strings.ToLower(name) // e.g. https://github.com/python-poetry/poetry/blob/c8945eb110aeda611cc6721565d7ad0c657d453a/poetry.lock#L819
158-
name = strings.ReplaceAll(name, "_", "-") // e.g. https://github.com/python-poetry/poetry/blob/c8945eb110aeda611cc6721565d7ad0c657d453a/poetry.lock#L50
159-
name = strings.ReplaceAll(name, ".", "-") // e.g. https://github.com/python-poetry/poetry/blob/c8945eb110aeda611cc6721565d7ad0c657d453a/poetry.lock#L816
160-
return name
161-
}
162-
163152
func packageID(name, ver string) string {
164153
return dependency.ID(ftypes.Poetry, name, ver)
165154
}

pkg/dependency/parser/python/poetry/parse_testcase.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ package poetry
33
import ftypes "github.com/aquasecurity/trivy/pkg/fanal/types"
44

55
var (
6-
// docker run --name pipenv --rm -it python@sha256:e1141f10176d74d1a0e87a7c0a0a5a98dd98ec5ac12ce867768f40c6feae2fd9 sh
6+
// docker run --name poetry --rm -it python@sha256:e1141f10176d74d1a0e87a7c0a0a5a98dd98ec5ac12ce867768f40c6feae2fd9 sh
77
// apk add curl
8-
// curl -sSL https://install.python-poetry.org | python3 -
8+
// curl -sSL https://install.python-poetry.org | POETRY_VERSION=1.1.7 python3 -
99
// export PATH=/root/.local/bin:$PATH
1010
// poetry new normal && cd normal
1111
// poetry add pypi@2.1
@@ -14,9 +14,9 @@ var (
1414
{ID: "pypi@2.1", Name: "pypi", Version: "2.1"},
1515
}
1616

17-
// docker run --name pipenv --rm -it python@sha256:e1141f10176d74d1a0e87a7c0a0a5a98dd98ec5ac12ce867768f40c6feae2fd9 sh
17+
// docker run --name poetry --rm -it python@sha256:e1141f10176d74d1a0e87a7c0a0a5a98dd98ec5ac12ce867768f40c6feae2fd9 sh
1818
// apk add curl
19-
// curl -sSL https://install.python-poetry.org | python3 -
19+
// curl -sSL https://install.python-poetry.org | POETRY_VERSION=1.1.7 python3 -
2020
// export PATH=/root/.local/bin:$PATH
2121
// poetry new many && cd many
2222
// curl -o poetry.lock https://raw.githubusercontent.com/python-poetry/poetry/c8945eb110aeda611cc6721565d7ad0c657d453a/poetry.lock
@@ -108,9 +108,9 @@ var (
108108
{ID: "xattr@0.10.1", DependsOn: []string{"cffi@1.15.1"}},
109109
}
110110

111-
// docker run --name pipenv --rm -it python@sha256:e1141f10176d74d1a0e87a7c0a0a5a98dd98ec5ac12ce867768f40c6feae2fd9 sh
111+
// docker run --name poetry --rm -it python@sha256:e1141f10176d74d1a0e87a7c0a0a5a98dd98ec5ac12ce867768f40c6feae2fd9 sh
112112
// apk add curl
113-
// curl -sSL https://install.python-poetry.org | python3 -
113+
// curl -sSL https://install.python-poetry.org | POETRY_VERSION=1.1.7 python3 -
114114
// export PATH=/root/.local/bin:$PATH
115115
// poetry new web && cd web
116116
// poetry add flask@1.0.3

pkg/dependency/parser/python/pyproject/pyproject.go

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import (
44
"io"
55

66
"github.com/BurntSushi/toml"
7+
"github.com/samber/lo"
78
"golang.org/x/xerrors"
9+
10+
"github.com/aquasecurity/trivy/pkg/dependency/parser/python"
811
)
912

1013
type PyProject struct {
@@ -16,7 +19,26 @@ type Tool struct {
1619
}
1720

1821
type Poetry struct {
19-
Dependencies map[string]any `toml:"dependencies"`
22+
Dependencies dependencies `toml:"dependencies"`
23+
Groups map[string]Group `toml:"group"`
24+
}
25+
26+
type Group struct {
27+
Dependencies dependencies `toml:"dependencies"`
28+
}
29+
30+
type dependencies map[string]struct{}
31+
32+
func (d *dependencies) UnmarshalTOML(data any) error {
33+
m, ok := data.(map[string]any)
34+
if !ok {
35+
return xerrors.Errorf("dependencies must be map, but got: %T", data)
36+
}
37+
38+
*d = lo.MapEntries(m, func(pkgName string, _ any) (string, struct{}) {
39+
return python.NormalizePkgName(pkgName), struct{}{}
40+
})
41+
return nil
2042
}
2143

2244
// Parser parses pyproject.toml defined in PEP518.
@@ -28,10 +50,10 @@ func NewParser() *Parser {
2850
return &Parser{}
2951
}
3052

31-
func (p *Parser) Parse(r io.Reader) (map[string]any, error) {
53+
func (p *Parser) Parse(r io.Reader) (PyProject, error) {
3254
var conf PyProject
3355
if _, err := toml.NewDecoder(r).Decode(&conf); err != nil {
34-
return nil, xerrors.Errorf("toml decode error: %w", err)
56+
return PyProject{}, xerrors.Errorf("toml decode error: %w", err)
3557
}
36-
return conf.Tool.Poetry.Dependencies, nil
58+
return conf, nil
3759
}

pkg/dependency/parser/python/pyproject/pyproject_test.go

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,26 +15,33 @@ func TestParser_Parse(t *testing.T) {
1515
tests := []struct {
1616
name string
1717
file string
18-
want map[string]any
18+
want pyproject.PyProject
1919
wantErr assert.ErrorAssertionFunc
2020
}{
2121
{
2222
name: "happy path",
2323
file: "testdata/happy.toml",
24-
want: map[string]any{
25-
"flask": "^1.0",
26-
"python": "^3.9",
27-
"requests": map[string]any{
28-
"version": "2.28.1",
29-
"optional": true,
30-
},
31-
"virtualenv": []any{
32-
map[string]any{
33-
"version": "^20.4.3,!=20.4.5,!=20.4.6",
34-
},
35-
map[string]any{
36-
"version": "<20.16.6",
37-
"markers": "sys_platform == 'win32' and python_version == '3.9'",
24+
want: pyproject.PyProject{
25+
Tool: pyproject.Tool{
26+
Poetry: pyproject.Poetry{
27+
Dependencies: map[string]struct{}{
28+
"flask": {},
29+
"python": {},
30+
"requests": {},
31+
"virtualenv": {},
32+
},
33+
Groups: map[string]pyproject.Group{
34+
"dev": {
35+
Dependencies: map[string]struct{}{
36+
"pytest": {},
37+
},
38+
},
39+
"lint": {
40+
Dependencies: map[string]struct{}{
41+
"ruff": {},
42+
},
43+
},
44+
},
3845
},
3946
},
4047
},

pkg/dependency/parser/python/pyproject/testdata/happy.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ virtualenv = [
1414

1515
[tool.poetry.dev-dependencies]
1616

17+
[tool.poetry.group.dev.dependencies]
18+
pytest = "8.3.4"
19+
20+
21+
[tool.poetry.group.lint.dependencies]
22+
ruff = "0.8.3"
23+
1724
[build-system]
1825
requires = ["poetry-core>=1.0.0"]
1926
build-backend = "poetry.core.masonry.api"
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package python
2+
3+
import "strings"
4+
5+
// NormalizePkgName normalizes the package name based on pep-0426
6+
func NormalizePkgName(name string) string {
7+
// The package names don't use `_`, `.` or upper case, but dependency names can contain them.
8+
// We need to normalize those names.
9+
// cf. https://peps.python.org/pep-0426/#name
10+
name = strings.ToLower(name) // e.g. https://github.com/python-poetry/poetry/blob/c8945eb110aeda611cc6721565d7ad0c657d453a/poetry.lock#L819
11+
name = strings.ReplaceAll(name, "_", "-") // e.g. https://github.com/python-poetry/poetry/blob/c8945eb110aeda611cc6721565d7ad0c657d453a/poetry.lock#L50
12+
name = strings.ReplaceAll(name, ".", "-") // e.g. https://github.com/python-poetry/poetry/blob/c8945eb110aeda611cc6721565d7ad0c657d453a/poetry.lock#L816
13+
return name
14+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package python_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
8+
"github.com/aquasecurity/trivy/pkg/dependency/parser/python"
9+
)
10+
11+
func Test_NormalizePkgName(t *testing.T) {
12+
tests := []struct {
13+
pkgName string
14+
expected string
15+
}{
16+
{
17+
pkgName: "SecretStorage",
18+
expected: "secretstorage",
19+
},
20+
{
21+
pkgName: "pywin32-ctypes",
22+
expected: "pywin32-ctypes",
23+
},
24+
{
25+
pkgName: "jaraco.classes",
26+
expected: "jaraco-classes",
27+
},
28+
{
29+
pkgName: "green_gdk",
30+
expected: "green-gdk",
31+
},
32+
}
33+
34+
for _, tt := range tests {
35+
t.Run(tt.pkgName, func(t *testing.T) {
36+
assert.Equal(t, tt.expected, python.NormalizePkgName(tt.pkgName))
37+
})
38+
}
39+
}

pkg/fanal/analyzer/language/python/poetry/poetry.go

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ func (a poetryAnalyzer) parsePoetryLock(path string, r io.Reader) (*types.Applic
9494
func (a poetryAnalyzer) mergePyProject(fsys fs.FS, dir string, app *types.Application) error {
9595
// Parse pyproject.toml to identify the direct dependencies
9696
path := filepath.Join(dir, types.PyProject)
97-
p, err := a.parsePyProject(fsys, path)
97+
project, err := a.parsePyProject(fsys, path)
9898
if errors.Is(err, fs.ErrNotExist) {
9999
// Assume all the packages are direct dependencies as it cannot identify them from poetry.lock
100100
a.logger.Debug("pyproject.toml not found", log.FilePath(path))
@@ -105,34 +105,68 @@ func (a poetryAnalyzer) mergePyProject(fsys fs.FS, dir string, app *types.Applic
105105

106106
// Identify the direct/transitive dependencies
107107
for i, pkg := range app.Packages {
108-
if _, ok := p[pkg.Name]; ok {
108+
if _, ok := project.Tool.Poetry.Dependencies[pkg.Name]; ok {
109109
app.Packages[i].Relationship = types.RelationshipDirect
110110
} else {
111111
app.Packages[i].Indirect = true
112112
app.Packages[i].Relationship = types.RelationshipIndirect
113113
}
114114
}
115115

116+
filterProdPackages(project, app)
116117
return nil
117118
}
118119

119-
func (a poetryAnalyzer) parsePyProject(fsys fs.FS, path string) (map[string]any, error) {
120+
func filterProdPackages(project pyproject.PyProject, app *types.Application) {
121+
packages := lo.SliceToMap(app.Packages, func(pkg types.Package) (string, types.Package) {
122+
return pkg.ID, pkg
123+
})
124+
125+
visited := make(map[string]struct{})
126+
deps := project.Tool.Poetry.Dependencies
127+
128+
for group, groupDeps := range project.Tool.Poetry.Groups {
129+
if group == "dev" {
130+
continue
131+
}
132+
deps = lo.Assign(deps, groupDeps.Dependencies)
133+
}
134+
135+
for _, pkg := range packages {
136+
if _, prodDep := deps[pkg.Name]; !prodDep {
137+
continue
138+
}
139+
walkPackageDeps(pkg.ID, packages, visited)
140+
}
141+
142+
app.Packages = lo.Filter(app.Packages, func(pkg types.Package, _ int) bool {
143+
_, ok := visited[pkg.ID]
144+
return ok
145+
})
146+
}
147+
148+
func walkPackageDeps(pkgID string, packages map[string]types.Package, visited map[string]struct{}) {
149+
if _, ok := visited[pkgID]; ok {
150+
return
151+
}
152+
visited[pkgID] = struct{}{}
153+
for _, dep := range packages[pkgID].DependsOn {
154+
walkPackageDeps(dep, packages, visited)
155+
}
156+
}
157+
158+
func (a poetryAnalyzer) parsePyProject(fsys fs.FS, path string) (pyproject.PyProject, error) {
120159
// Parse pyproject.toml
121160
f, err := fsys.Open(path)
122161
if err != nil {
123-
return nil, xerrors.Errorf("file open error: %w", err)
162+
return pyproject.PyProject{}, xerrors.Errorf("file open error: %w", err)
124163
}
125164
defer f.Close()
126165

127-
parsed, err := a.pyprojectParser.Parse(f)
166+
project, err := a.pyprojectParser.Parse(f)
128167
if err != nil {
129-
return nil, err
168+
return pyproject.PyProject{}, err
130169
}
131170

132-
// Packages from `pyproject.toml` can use uppercase characters, `.` and `_`.
133-
parsed = lo.MapKeys(parsed, func(_ any, pkgName string) string {
134-
return poetry.NormalizePkgName(pkgName)
135-
})
136-
137-
return parsed, nil
171+
return project, nil
138172
}

0 commit comments

Comments
 (0)