Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(nodejs): add license parser to pnpm analyser #7036

Merged
merged 19 commits into from
Jul 3, 2024
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/docs/coverage/language/nodejs.md
DmitriyLewen marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ The following scanners are supported.
|----------|:----:|:-------------:|:-------:|
| npm | ✓ | ✓ | ✓ |
| Yarn | ✓ | ✓ | ✓ |
| pnpm | ✓ | ✓ | - |
| pnpm | ✓ | ✓ | |
| Bun | ✓ | ✓ | ✓ |

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

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

#### lock file v9 version
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.
Expand Down
122 changes: 109 additions & 13 deletions pkg/fanal/analyzer/language/nodejs/pnpm/pnpm.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,45 +2,141 @@ package pnpm

import (
"context"
"errors"
"io"
"io/fs"
"os"
"path"
"path/filepath"

"golang.org/x/xerrors"

"github.com/aquasecurity/trivy/pkg/dependency/parser/nodejs/packagejson"
"github.com/aquasecurity/trivy/pkg/dependency/parser/nodejs/pnpm"
"github.com/aquasecurity/trivy/pkg/fanal/analyzer"
"github.com/aquasecurity/trivy/pkg/fanal/analyzer/language"
"github.com/aquasecurity/trivy/pkg/fanal/types"
"github.com/aquasecurity/trivy/pkg/fanal/utils"
"github.com/aquasecurity/trivy/pkg/log"
"github.com/aquasecurity/trivy/pkg/utils/fsutils"
xpath "github.com/aquasecurity/trivy/pkg/x/path"
)

func init() {
analyzer.RegisterAnalyzer(&pnpmLibraryAnalyzer{})
analyzer.RegisterPostAnalyzer(analyzer.TypePnpm, newPnpmAnalyzer)
}

const version = 1
const version = 2

var requiredFiles = []string{types.PnpmLock}
type pnpmAnalyzer struct {
logger *log.Logger
packageJsonParser *packagejson.Parser
lockParser language.Parser
}

func newPnpmAnalyzer(opt analyzer.AnalyzerOptions) (analyzer.PostAnalyzer, error) {
return &pnpmAnalyzer{
logger: log.WithPrefix("pnpm"),
packageJsonParser: packagejson.NewParser(),
lockParser: pnpm.NewParser(),
}, nil
}

func (a pnpmAnalyzer) PostAnalyze(_ context.Context, input analyzer.PostAnalysisInput) (*analyzer.AnalysisResult, error) {
var apps []types.Application

required := func(path string, d fs.DirEntry) bool {
return filepath.Base(path) == types.PnpmLock
}

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

func (a pnpmLibraryAnalyzer) Analyze(_ context.Context, input analyzer.AnalysisInput) (*analyzer.AnalysisResult, error) {
res, err := language.Analyze(types.Pnpm, input.FilePath, input.Content, pnpm.NewParser())
// Parse pnpm-lock.yaml
app, err := language.Parse(types.Pnpm, filePath, r, a.lockParser)
if err != nil {
return xerrors.Errorf("parse error: %w", err)
} else if app == nil {
return nil
}

// Fill licenses
for i, lib := range app.Packages {
if l, ok := licenses[lib.ID]; ok {
app.Packages[i].Licenses = l
}
}

apps = append(apps, *app)

return nil
})
if err != nil {
return nil, xerrors.Errorf("unable to parse %s: %w", input.FilePath, err)
return nil, xerrors.Errorf("pnpm walk error: %w", err)
}
return res, nil

return &analyzer.AnalysisResult{
Applications: apps,
}, nil
}

func (a pnpmLibraryAnalyzer) Required(filePath string, _ os.FileInfo) bool {
func (a pnpmAnalyzer) Required(filePath string, _ os.FileInfo) bool {
fileName := filepath.Base(filePath)
return utils.StringInSlice(fileName, requiredFiles)
// Don't save pnpm-lock.yaml from the `node_modules` directory to avoid duplication and mistakes.
if fileName == types.PnpmLock && !xpath.Contains(filePath, "node_modules") {
return true
}

// Save package.json files only from the `node_modules` directory.
// Required to search for licenses.
if fileName == types.NpmPkg && xpath.Contains(filePath, "node_modules") {
return true
}

return false
}

func (a pnpmLibraryAnalyzer) Type() analyzer.Type {
func (a pnpmAnalyzer) Type() analyzer.Type {
return analyzer.TypePnpm
}

func (a pnpmLibraryAnalyzer) Version() int {
func (a pnpmAnalyzer) Version() int {
return version
}

func (a pnpmAnalyzer) findLicenses(fsys fs.FS, lockPath string) (map[string][]string, error) {
dir := path.Dir(lockPath)
root := path.Join(dir, "node_modules")
if _, err := fs.Stat(fsys, root); errors.Is(err, fs.ErrNotExist) {
a.logger.Info(`To collect the license information of packages, "pnpm install" needs to be performed beforehand`,
log.String("dir", root))
return nil, nil
}

// Parse package.json
required := func(path string, _ fs.DirEntry) bool {
return filepath.Base(path) == types.NpmPkg
}

// Traverse node_modules dir and find licenses
// Note that fs.FS is always slashed regardless of the platform,
// and path.Join should be used rather than filepath.Join.
licenses := make(map[string][]string)
err := fsutils.WalkDir(fsys, root, required, func(filePath string, d fs.DirEntry, r io.Reader) error {
pkg, err := a.packageJsonParser.Parse(r)
if err != nil {
return xerrors.Errorf("unable to parse %q: %w", filePath, err)
}

licenses[pkg.ID] = pkg.Licenses
return nil
})
if err != nil {
return nil, xerrors.Errorf("walk error: %w", err)
}
return licenses, nil
}
139 changes: 118 additions & 21 deletions pkg/fanal/analyzer/language/nodejs/pnpm/pnpm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package pnpm
import (
"context"
"os"
"sort"
"testing"

"github.com/stretchr/testify/assert"
Expand All @@ -14,52 +15,148 @@ import (

func Test_pnpmPkgLibraryAnalyzer_Analyze(t *testing.T) {
tests := []struct {
name string
inputFile string
want *analyzer.AnalysisResult
wantErr string
name string
dir string
want *analyzer.AnalysisResult
}{
{
name: "happy path",
inputFile: "testdata/pnpm-lock.yaml",
name: "with node_modules",
dir: "testdata/happy",
want: &analyzer.AnalysisResult{
Applications: []types.Application{
{
Type: types.Pnpm,
FilePath: "testdata/pnpm-lock.yaml",
FilePath: "pnpm-lock.yaml",
Packages: types.Packages{
{
ID: "[email protected]",
Name: "lodash",
Version: "4.17.21",
ID: "[email protected]",
Name: "ms",
Version: "2.1.3",
Licenses: []string{"MIT"},
Relationship: types.RelationshipDirect,
},
},
},
},
},
},
{
name: "without node_modules",
dir: "testdata/no-node_modules",
want: &analyzer.AnalysisResult{
Applications: []types.Application{
{
Type: types.Pnpm,
FilePath: "pnpm-lock.yaml",
Packages: types.Packages{
{
ID: "@babel/[email protected]",
Name: "@babel/parser",
Version: "7.24.7",
Relationship: types.RelationshipDirect,
DependsOn: []string{"@babel/[email protected]"},
},
{
ID: "[email protected]",
Name: "ms",
Version: "2.1.3",
Relationship: types.RelationshipDirect,
},
{
ID: "@babel/[email protected]",
Name: "@babel/helper-string-parser",
Version: "7.24.7",
Relationship: types.RelationshipIndirect,
Indirect: true,
},
{
ID: "@babel/[email protected]",
Name: "@babel/helper-validator-identifier",
Version: "7.24.7",
Relationship: types.RelationshipIndirect,
Indirect: true,
},
{
ID: "@babel/[email protected]",
Name: "@babel/types",
Version: "7.24.7",
Relationship: types.RelationshipIndirect,
Indirect: true,
DependsOn: []string{
"@babel/[email protected]",
"@babel/[email protected]",
"[email protected]",
},
},
{
ID: "[email protected]",
Name: "to-fast-properties",
Version: "2.0.0",
Relationship: types.RelationshipIndirect,
Indirect: true,
},
},
},
},
},
},
{
name: "sad path",
dir: "testdata/sad",
want: &analyzer.AnalysisResult{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, err := os.Open(tt.inputFile)
a, err := newPnpmAnalyzer(analyzer.AnalyzerOptions{})
require.NoError(t, err)
defer f.Close()

a := pnpmLibraryAnalyzer{}
ctx := context.Background()
got, err := a.Analyze(ctx, analyzer.AnalysisInput{
FilePath: tt.inputFile,
Content: f,
got, err := a.PostAnalyze(context.Background(), analyzer.PostAnalysisInput{
FS: os.DirFS(tt.dir),
})

if tt.wantErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
return
require.NoError(t, err)
if len(got.Applications) > 0 {
sort.Sort(got.Applications[0].Packages)
}
assert.Equal(t, tt.want, got)
})
}
}

func Test_pnpmPkgLibraryAnalyzer_Required(t *testing.T) {
tests := []struct {
name string
filePath string
want bool
}{
{
name: "lock file",
filePath: "pnpm/pnpm-lock.yaml",
want: true,
},
{
name: "lock file in node_modules",
filePath: "pnpm/node_modules/html2canvas/pnpm-lock.yaml",
want: false,
},
{
name: "package.json in node_modules",
filePath: "pnpm/node_modules/ms/package.json",
want: true,
},
{
name: "sad path",
filePath: "pnpm/package.json",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
a, err := newPnpmAnalyzer(analyzer.AnalyzerOptions{})
require.NoError(t, err)

got := a.Required(tt.filePath, nil)
assert.Equal(t, tt.want, got)
})
}
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading