Skip to content

Commit 03ac93d

Browse files
feat(nodejs): add license parser to pnpm analyser (#7036)
Co-authored-by: DmitriyLewen <[email protected]>
1 parent 266d9b1 commit 03ac93d

File tree

13 files changed

+507
-60
lines changed

13 files changed

+507
-60
lines changed

docs/docs/coverage/language/nodejs.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ The following scanners are supported.
88
|----------|:----:|:-------------:|:-------:|
99
| npm ||||
1010
| Yarn ||||
11-
| pnpm ||| - |
11+
| pnpm ||| |
1212
| Bun ||||
1313

1414
The following table provides an outline of the features Trivy offers.
@@ -54,6 +54,7 @@ By default, Trivy doesn't report development dependencies. Use the `--include-de
5454

5555
### pnpm
5656
Trivy parses `pnpm-lock.yaml`, then finds production dependencies and builds a [tree][dependency-graph] of dependencies with vulnerabilities.
57+
To identify licenses, you need to download dependencies to `node_modules` beforehand. Trivy analyzes `node_modules` for licenses.
5758

5859
#### lock file v9 version
5960
Trivy supports `Dev` field for `pnpm-lock.yaml` v9 or later. Use the `--include-dev-deps` flag to include the developer's dependencies in the result.

integration/repo_test.go

+3-2
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,9 @@ func TestRepository(t *testing.T) {
105105
{
106106
name: "pnpm",
107107
args: args{
108-
scanner: types.VulnerabilityScanner,
109-
input: "testdata/fixtures/repo/pnpm",
108+
scanner: types.VulnerabilityScanner,
109+
input: "testdata/fixtures/repo/pnpm",
110+
listAllPkgs: true,
110111
},
111112
golden: "testdata/pnpm.json.golden",
112113
},

integration/testdata/fixtures/repo/pnpm/node_modules/.pnpm/[email protected]/node_modules/jquery/package.json

+108
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

integration/testdata/fixtures/repo/pnpm/node_modules/.pnpm/[email protected]/node_modules/lodash/package.json

+17
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

integration/testdata/fixtures/repo/pnpm/pnpm-lock.yaml

+12-8
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

integration/testdata/pnpm.json.golden

+32-2
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,44 @@
2020
"Target": "pnpm-lock.yaml",
2121
"Class": "lang-pkgs",
2222
"Type": "pnpm",
23+
"Packages": [
24+
{
25+
26+
"Name": "jquery",
27+
"Identifier": {
28+
"PURL": "pkg:npm/[email protected]",
29+
"UID": "53ca18565a4b6a47"
30+
},
31+
"Version": "3.3.9",
32+
"Licenses": [
33+
"MIT"
34+
],
35+
"Relationship": "direct",
36+
"Layer": {}
37+
},
38+
{
39+
40+
"Name": "lodash",
41+
"Identifier": {
42+
"PURL": "pkg:npm/[email protected]",
43+
"UID": "31eadfcf58a6b128"
44+
},
45+
"Version": "4.17.4",
46+
"Licenses": [
47+
"MIT"
48+
],
49+
"Relationship": "direct",
50+
"Layer": {}
51+
}
52+
],
2353
"Vulnerabilities": [
2454
{
2555
"VulnerabilityID": "CVE-2019-11358",
2656
"PkgID": "[email protected]",
2757
"PkgName": "jquery",
2858
"PkgIdentifier": {
2959
"PURL": "pkg:npm/[email protected]",
30-
"UID": "d002d4ebac4ee286"
60+
"UID": "53ca18565a4b6a47"
3161
},
3262
"InstalledVersion": "3.3.9",
3363
"FixedVersion": "3.4.0",
@@ -160,7 +190,7 @@
160190
"PkgName": "lodash",
161191
"PkgIdentifier": {
162192
"PURL": "pkg:npm/[email protected]",
163-
"UID": "68507e8301071074"
193+
"UID": "31eadfcf58a6b128"
164194
},
165195
"InstalledVersion": "4.17.4",
166196
"FixedVersion": "4.17.12",

pkg/fanal/analyzer/language/nodejs/pnpm/pnpm.go

+109-13
Original file line numberDiff line numberDiff line change
@@ -2,45 +2,141 @@ package pnpm
22

33
import (
44
"context"
5+
"errors"
6+
"io"
7+
"io/fs"
58
"os"
9+
"path"
610
"path/filepath"
711

812
"golang.org/x/xerrors"
913

14+
"github.com/aquasecurity/trivy/pkg/dependency/parser/nodejs/packagejson"
1015
"github.com/aquasecurity/trivy/pkg/dependency/parser/nodejs/pnpm"
1116
"github.com/aquasecurity/trivy/pkg/fanal/analyzer"
1217
"github.com/aquasecurity/trivy/pkg/fanal/analyzer/language"
1318
"github.com/aquasecurity/trivy/pkg/fanal/types"
14-
"github.com/aquasecurity/trivy/pkg/fanal/utils"
19+
"github.com/aquasecurity/trivy/pkg/log"
20+
"github.com/aquasecurity/trivy/pkg/utils/fsutils"
21+
xpath "github.com/aquasecurity/trivy/pkg/x/path"
1522
)
1623

1724
func init() {
18-
analyzer.RegisterAnalyzer(&pnpmLibraryAnalyzer{})
25+
analyzer.RegisterPostAnalyzer(analyzer.TypePnpm, newPnpmAnalyzer)
1926
}
2027

21-
const version = 1
28+
const version = 2
2229

23-
var requiredFiles = []string{types.PnpmLock}
30+
type pnpmAnalyzer struct {
31+
logger *log.Logger
32+
packageJsonParser *packagejson.Parser
33+
lockParser language.Parser
34+
}
35+
36+
func newPnpmAnalyzer(_ analyzer.AnalyzerOptions) (analyzer.PostAnalyzer, error) {
37+
return &pnpmAnalyzer{
38+
logger: log.WithPrefix("pnpm"),
39+
packageJsonParser: packagejson.NewParser(),
40+
lockParser: pnpm.NewParser(),
41+
}, nil
42+
}
43+
44+
func (a pnpmAnalyzer) PostAnalyze(_ context.Context, input analyzer.PostAnalysisInput) (*analyzer.AnalysisResult, error) {
45+
var apps []types.Application
46+
47+
required := func(path string, d fs.DirEntry) bool {
48+
return filepath.Base(path) == types.PnpmLock
49+
}
2450

25-
type pnpmLibraryAnalyzer struct{}
51+
err := fsutils.WalkDir(input.FS, ".", required, func(filePath string, d fs.DirEntry, r io.Reader) error {
52+
// Find licenses
53+
licenses, err := a.findLicenses(input.FS, filePath)
54+
if err != nil {
55+
a.logger.Error("Unable to collect licenses", log.Err(err))
56+
licenses = make(map[string][]string)
57+
}
2658

27-
func (a pnpmLibraryAnalyzer) Analyze(_ context.Context, input analyzer.AnalysisInput) (*analyzer.AnalysisResult, error) {
28-
res, err := language.Analyze(types.Pnpm, input.FilePath, input.Content, pnpm.NewParser())
59+
// Parse pnpm-lock.yaml
60+
app, err := language.Parse(types.Pnpm, filePath, r, a.lockParser)
61+
if err != nil {
62+
return xerrors.Errorf("parse error: %w", err)
63+
} else if app == nil {
64+
return nil
65+
}
66+
67+
// Fill licenses
68+
for i, lib := range app.Packages {
69+
if l, ok := licenses[lib.ID]; ok {
70+
app.Packages[i].Licenses = l
71+
}
72+
}
73+
74+
apps = append(apps, *app)
75+
76+
return nil
77+
})
2978
if err != nil {
30-
return nil, xerrors.Errorf("unable to parse %s: %w", input.FilePath, err)
79+
return nil, xerrors.Errorf("pnpm walk error: %w", err)
3180
}
32-
return res, nil
81+
82+
return &analyzer.AnalysisResult{
83+
Applications: apps,
84+
}, nil
3385
}
3486

35-
func (a pnpmLibraryAnalyzer) Required(filePath string, _ os.FileInfo) bool {
87+
func (a pnpmAnalyzer) Required(filePath string, _ os.FileInfo) bool {
3688
fileName := filepath.Base(filePath)
37-
return utils.StringInSlice(fileName, requiredFiles)
89+
// Don't save pnpm-lock.yaml from the `node_modules` directory to avoid duplication and mistakes.
90+
if fileName == types.PnpmLock && !xpath.Contains(filePath, "node_modules") {
91+
return true
92+
}
93+
94+
// Save package.json files only from the `node_modules` directory.
95+
// Required to search for licenses.
96+
if fileName == types.NpmPkg && xpath.Contains(filePath, "node_modules") {
97+
return true
98+
}
99+
100+
return false
38101
}
39102

40-
func (a pnpmLibraryAnalyzer) Type() analyzer.Type {
103+
func (a pnpmAnalyzer) Type() analyzer.Type {
41104
return analyzer.TypePnpm
42105
}
43106

44-
func (a pnpmLibraryAnalyzer) Version() int {
107+
func (a pnpmAnalyzer) Version() int {
45108
return version
46109
}
110+
111+
func (a pnpmAnalyzer) findLicenses(fsys fs.FS, lockPath string) (map[string][]string, error) {
112+
dir := path.Dir(lockPath)
113+
root := path.Join(dir, "node_modules")
114+
if _, err := fs.Stat(fsys, root); errors.Is(err, fs.ErrNotExist) {
115+
a.logger.Info(`To collect the license information of packages, "pnpm install" needs to be performed beforehand`,
116+
log.String("dir", root))
117+
return nil, nil
118+
}
119+
120+
// Parse package.json
121+
required := func(path string, _ fs.DirEntry) bool {
122+
return filepath.Base(path) == types.NpmPkg
123+
}
124+
125+
// Traverse node_modules dir and find licenses
126+
// Note that fs.FS is always slashed regardless of the platform,
127+
// and path.Join should be used rather than filepath.Join.
128+
licenses := make(map[string][]string)
129+
err := fsutils.WalkDir(fsys, root, required, func(filePath string, d fs.DirEntry, r io.Reader) error {
130+
pkg, err := a.packageJsonParser.Parse(r)
131+
if err != nil {
132+
return xerrors.Errorf("unable to parse %q: %w", filePath, err)
133+
}
134+
135+
licenses[pkg.ID] = pkg.Licenses
136+
return nil
137+
})
138+
if err != nil {
139+
return nil, xerrors.Errorf("walk error: %w", err)
140+
}
141+
return licenses, nil
142+
}

0 commit comments

Comments
 (0)