Skip to content

Commit e9b43f8

Browse files
authored
feat(python): use minimum version for pip packages (#7348)
1 parent 2a6c7ab commit e9b43f8

File tree

6 files changed

+93
-17
lines changed

6 files changed

+93
-17
lines changed

docs/docs/coverage/language/python.md

+12-3
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ The following table provides an outline of the features Trivy offers.
2323

2424
| Package manager | File | Transitive dependencies | Dev dependencies | [Dependency graph][dependency-graph] | Position | [Detection Priority][detection-priority] |
2525
|-----------------|------------------|:-----------------------:|:----------------:|:------------------------------------:|:--------:|:----------------------------------------:|
26-
| pip | requirements.txt | - | Include | - || - |
26+
| pip | requirements.txt | - | Include | - || |
2727
| Pipenv | Pipfile.lock || Include | - || Not needed |
2828
| Poetry | poetry.lock || Exclude || - | Not needed |
2929

@@ -42,8 +42,17 @@ Trivy parses your files generated by package managers in filesystem/repository s
4242
### pip
4343

4444
#### Dependency detection
45-
Trivy only parses [version specifiers](https://packaging.python.org/en/latest/specifications/version-specifiers/#id5) with `==` comparison operator and without `.*`.
46-
To convert unsupported version specifiers - use the `pip freeze` command.
45+
By default, Trivy only parses [version specifiers](https://packaging.python.org/en/latest/specifications/version-specifiers/#id5) with `==` comparison operator and without `.*`.
46+
47+
Using the [--detection-priority comprehensive](#detection-priority) option ensures that the tool establishes a minimum version, which is particularly useful in scenarios where identifying the exact version is challenging.
48+
In such case Trivy parses specifiers `>=`,`~=` and a trailing `.*`.
49+
50+
```
51+
keyring >= 4.1.1 # Minimum version 4.1.1
52+
Mopidy-Dirble ~= 1.1 # Minimum version 1.1
53+
python-gitlab==2.0.* # Minimum version 2.0.0
54+
```
55+
Also, there is a way to convert unsupported version specifiers - use the `pip freeze` command.
4756

4857
```bash
4958
$ cat requirements.txt

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

+23-4
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,29 @@ const (
2525
)
2626

2727
type Parser struct {
28-
logger *log.Logger
28+
logger *log.Logger
29+
useMinVersion bool
2930
}
3031

31-
func NewParser() *Parser {
32+
func NewParser(useMinVersion bool) *Parser {
3233
return &Parser{
33-
logger: log.WithPrefix("pip"),
34+
logger: log.WithPrefix("pip"),
35+
useMinVersion: useMinVersion,
3436
}
3537
}
38+
func (p *Parser) splitLine(line string) []string {
39+
separators := []string{"~=", ">=", "=="}
40+
// Without useMinVersion check only `==`
41+
if !p.useMinVersion {
42+
separators = []string{"=="}
43+
}
44+
for _, sep := range separators {
45+
if result := strings.Split(line, sep); len(result) == 2 {
46+
return result
47+
}
48+
}
49+
return nil
50+
}
3651

3752
func (p *Parser) Parse(r xio.ReadSeekerAt) ([]ftypes.Package, []ftypes.Dependency, error) {
3853
// `requirements.txt` can use byte order marks (BOM)
@@ -53,10 +68,14 @@ func (p *Parser) Parse(r xio.ReadSeekerAt) ([]ftypes.Package, []ftypes.Dependenc
5368
line = rStripByKey(line, commentMarker)
5469
line = rStripByKey(line, endColon)
5570
line = rStripByKey(line, hashMarker)
56-
s := strings.Split(line, "==")
71+
72+
s := p.splitLine(line)
5773
if len(s) != 2 {
5874
continue
5975
}
76+
if p.useMinVersion && strings.HasSuffix(s[1], ".*") {
77+
s[1] = strings.TrimSuffix(s[1], "*") + "0"
78+
}
6079

6180
if !isValidName(s[0]) || !isValidVersion(s[1]) {
6281
p.logger.Debug("Invalid package name/version in requirements.txt.", log.String("line", text))

pkg/dependency/parser/python/pip/parse_test.go

+11-4
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@ import (
1212

1313
func TestParse(t *testing.T) {
1414
tests := []struct {
15-
name string
16-
filePath string
17-
want []ftypes.Package
15+
name string
16+
filePath string
17+
useMinVersion bool
18+
want []ftypes.Package
1819
}{
1920
{
2021
name: "happy path",
@@ -66,14 +67,20 @@ func TestParse(t *testing.T) {
6667
filePath: "testdata/requirements_with_templating_engine.txt",
6768
want: nil,
6869
},
70+
{
71+
name: "compatible versions",
72+
filePath: "testdata/requirements_compatible.txt",
73+
useMinVersion: true,
74+
want: requirementsCompatibleVersions,
75+
},
6976
}
7077

7178
for _, tt := range tests {
7279
t.Run(tt.name, func(t *testing.T) {
7380
f, err := os.Open(tt.filePath)
7481
require.NoError(t, err)
7582

76-
got, _, err := NewParser().Parse(f)
83+
got, _, err := NewParser(tt.useMinVersion).Parse(f)
7784
require.NoError(t, err)
7885

7986
assert.Equal(t, tt.want, got)

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

+32
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,38 @@ package pip
33
import ftypes "github.com/aquasecurity/trivy/pkg/fanal/types"
44

55
var (
6+
requirementsCompatibleVersions = []ftypes.Package{
7+
{
8+
Name: "keyring",
9+
Version: "4.1.1",
10+
Locations: []ftypes.Location{
11+
{
12+
StartLine: 1,
13+
EndLine: 1,
14+
},
15+
},
16+
},
17+
{
18+
Name: "Mopidy-Dirble",
19+
Version: "1.1",
20+
Locations: []ftypes.Location{
21+
{
22+
StartLine: 2,
23+
EndLine: 2,
24+
},
25+
},
26+
},
27+
{
28+
Name: "python-gitlab",
29+
Version: "2.0.0",
30+
Locations: []ftypes.Location{
31+
{
32+
StartLine: 3,
33+
EndLine: 3,
34+
},
35+
},
36+
},
37+
}
638
requirementsFlask = []ftypes.Package{
739
{
840
Name: "click",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
keyring >= 4.1.1 # Minimum version 4.1.1
2+
Mopidy-Dirble ~= 1.1 # Compatible release. Same as >= 1.1, == 1.*
3+
python-gitlab==2.0.*
4+
django==5.*.* # this dep should be skipped
5+
django==4.*.1

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

+10-6
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,16 @@ var pythonExecNames = []string{
3838
}
3939

4040
type pipLibraryAnalyzer struct {
41-
logger *log.Logger
42-
metadataParser packaging.Parser
41+
logger *log.Logger
42+
metadataParser packaging.Parser
43+
detectionPriority types.DetectionPriority
4344
}
4445

45-
func newPipLibraryAnalyzer(_ analyzer.AnalyzerOptions) (analyzer.PostAnalyzer, error) {
46+
func newPipLibraryAnalyzer(opts analyzer.AnalyzerOptions) (analyzer.PostAnalyzer, error) {
4647
return pipLibraryAnalyzer{
47-
logger: log.WithPrefix("pip"),
48-
metadataParser: *packaging.NewParser(),
48+
logger: log.WithPrefix("pip"),
49+
metadataParser: *packaging.NewParser(),
50+
detectionPriority: opts.DetectionPriority,
4951
}, nil
5052
}
5153

@@ -62,8 +64,10 @@ func (a pipLibraryAnalyzer) PostAnalyze(_ context.Context, input analyzer.PostAn
6264
return true
6365
}
6466

67+
useMinVersion := a.detectionPriority == types.PriorityComprehensive
68+
6569
if err = fsutils.WalkDir(input.FS, ".", required, func(pathPath string, d fs.DirEntry, r io.Reader) error {
66-
app, err := language.Parse(types.Pip, pathPath, r, pip.NewParser())
70+
app, err := language.Parse(types.Pip, pathPath, r, pip.NewParser(useMinVersion))
6771
if err != nil {
6872
return xerrors.Errorf("unable to parse requirements.txt: %w", err)
6973
}

0 commit comments

Comments
 (0)