package pnpm import ( "fmt" "strconv" "strings" "github.com/samber/lo" "golang.org/x/xerrors" "gopkg.in/yaml.v3" "github.com/aquasecurity/go-version/pkg/semver" "github.com/aquasecurity/trivy/pkg/dependency" ftypes "github.com/aquasecurity/trivy/pkg/fanal/types" "github.com/aquasecurity/trivy/pkg/log" xio "github.com/aquasecurity/trivy/pkg/x/io" ) type PackageResolution struct { Tarball string `yaml:"tarball,omitempty"` } type PackageInfo struct { Resolution PackageResolution `yaml:"resolution"` Dependencies map[string]string `yaml:"dependencies,omitempty"` DevDependencies map[string]string `yaml:"devDependencies,omitempty"` IsDev bool `yaml:"dev,omitempty"` Name string `yaml:"name,omitempty"` Version string `yaml:"version,omitempty"` } type LockFile struct { LockfileVersion any `yaml:"lockfileVersion"` Dependencies map[string]any `yaml:"dependencies,omitempty"` DevDependencies map[string]any `yaml:"devDependencies,omitempty"` Packages map[string]PackageInfo `yaml:"packages,omitempty"` } type Parser struct { logger *log.Logger } func NewParser() *Parser { return &Parser{ logger: log.WithPrefix("pnpm"), } } func (p *Parser) Parse(r xio.ReadSeekerAt) ([]ftypes.Package, []ftypes.Dependency, error) { var lockFile LockFile if err := yaml.NewDecoder(r).Decode(&lockFile); err != nil { return nil, nil, xerrors.Errorf("decode error: %w", err) } lockVer := p.parseLockfileVersion(lockFile) if lockVer < 0 { return nil, nil, nil } pkgs, deps := p.parse(lockVer, lockFile) return pkgs, deps, nil } func (p *Parser) parse(lockVer float64, lockFile LockFile) ([]ftypes.Package, []ftypes.Dependency) { var pkgs []ftypes.Package var deps []ftypes.Dependency // Dependency path is a path to a dependency with a specific set of resolved subdependencies. // cf. https://github.com/pnpm/spec/blob/ad27a225f81d9215becadfa540ef05fa4ad6dd60/dependency-path.md for depPath, info := range lockFile.Packages { if info.IsDev { continue } // Dependency name may be present in dependencyPath or Name field. Same for Version. // e.g. packages installed from local directory or tarball // cf. https://github.com/pnpm/spec/blob/274ff02de23376ad59773a9f25ecfedd03a41f64/lockfile/6.0.md#packagesdependencypathname name := info.Name version := info.Version if name == "" { name, version = p.parsePackage(depPath, lockVer) } pkgID := packageID(name, version) dependencies := make([]string, 0, len(info.Dependencies)) for depName, depVer := range info.Dependencies { dependencies = append(dependencies, packageID(depName, depVer)) } pkgs = append(pkgs, ftypes.Package{ ID: pkgID, Name: name, Version: version, Relationship: lo.Ternary(isDirectPkg(name, lockFile.Dependencies), ftypes.RelationshipDirect, ftypes.RelationshipIndirect), }) if len(dependencies) > 0 { deps = append(deps, ftypes.Dependency{ ID: pkgID, DependsOn: dependencies, }) } } return pkgs, deps } func (p *Parser) parseLockfileVersion(lockFile LockFile) float64 { switch v := lockFile.LockfileVersion.(type) { // v5 case float64: return v // v6+ case string: if lockVer, err := strconv.ParseFloat(v, 64); err != nil { p.logger.Debug("Unable to convert the lock file version to float", log.Err(err)) return -1 } else { return lockVer } default: p.logger.Debug("Unknown type for the lock file version", log.Any("version", lockFile.LockfileVersion)) return -1 } } // cf. https://github.com/pnpm/pnpm/blob/ce61f8d3c29eee46cee38d56ced45aea8a439a53/packages/dependency-path/src/index.ts#L112-L163 func (p *Parser) parsePackage(depPath string, lockFileVersion float64) (string, string) { // The version separator is different between v5 and v6+. versionSep := "@" if lockFileVersion < 6 { versionSep = "/" } return p.parseDepPath(depPath, versionSep) } func (p *Parser) parseDepPath(depPath, versionSep string) (string, string) { // Skip registry // e.g. // - "registry.npmjs.org/lodash/4.17.10" => "lodash/4.17.10" // - "registry.npmjs.org/@babel/generator/7.21.9" => "@babel/generator/7.21.9" // - "/lodash/4.17.10" => "lodash/4.17.10" _, depPath, _ = strings.Cut(depPath, "/") // Parse scope // e.g. // - v5: "@babel/generator/7.21.9" => {"babel", "generator/7.21.9"} // - v6+: "@babel/helper-annotate-as-pure@7.18.6" => "{"babel", "helper-annotate-as-pure@7.18.6"} var scope string if strings.HasPrefix(depPath, "@") { scope, depPath, _ = strings.Cut(depPath, "/") } // Parse package name // e.g. // - v5: "generator/7.21.9" => {"generator", "7.21.9"} // - v6+: "helper-annotate-as-pure@7.18.6" => {"helper-annotate-as-pure", "7.18.6"} var name, version string name, version, _ = strings.Cut(depPath, versionSep) if scope != "" { name = fmt.Sprintf("%s/%s", scope, name) } // Trim peer deps // e.g. // - v5: "7.21.5_@babel+core@7.21.8" => "7.21.5" // - v6+: "7.21.5(@babel/core@7.20.7)" => "7.21.5" if idx := strings.IndexAny(version, "_("); idx != -1 { version = version[:idx] } if _, err := semver.Parse(version); err != nil { p.logger.Debug("Skip non-semver package", log.String("pkg_path", depPath), log.String("version", version), log.Err(err)) return "", "" } return name, version } func isDirectPkg(name string, directDeps map[string]interface{}) bool { _, ok := directDeps[name] return ok } func packageID(name, version string) string { return dependency.ID(ftypes.Pnpm, name, version) }