diff --git a/cmd/syft/internal/test/integration/node_packages_test.go b/cmd/syft/internal/test/integration/node_packages_test.go index 8995a913a..305ff7ca0 100644 --- a/cmd/syft/internal/test/integration/node_packages_test.go +++ b/cmd/syft/internal/test/integration/node_packages_test.go @@ -35,7 +35,8 @@ func TestYarnPackageLockDirectory(t *testing.T) { sbom, _ := catalogDirectory(t, "test-fixtures/yarn-lock") foundPackages := strset.New() - expectedPackages := strset.New("async@0.9.2", "async@3.2.3", "merge-objects@1.0.5", "should-type@1.3.0", "@4lolo/resize-observer-polyfill@1.5.2") + // merge-objects and should-type are devDependencies in package.json and are excluded by default + expectedPackages := strset.New("async@0.9.2", "async@3.2.3", "@4lolo/resize-observer-polyfill@1.5.2") for actualPkg := range sbom.Artifacts.Packages.Enumerate(pkg.NpmPkg) { for _, actualLocation := range actualPkg.Locations.ToSlice() { diff --git a/syft/pkg/cataloger/javascript/parse_package_json.go b/syft/pkg/cataloger/javascript/parse_package_json.go index cfa7a83a1..926f765c5 100644 --- a/syft/pkg/cataloger/javascript/parse_package_json.go +++ b/syft/pkg/cataloger/javascript/parse_package_json.go @@ -23,20 +23,21 @@ var _ generic.Parser = parsePackageJSON // packageJSON represents a JavaScript package.json file type packageJSON struct { - Version string `json:"version"` - Latest []string `json:"latest"` - Author person `json:"author"` - Authors people `json:"authors"` - Contributors people `json:"contributors"` - Maintainers people `json:"maintainers"` - License json.RawMessage `json:"license"` - Licenses json.RawMessage `json:"licenses"` - Name string `json:"name"` - Homepage string `json:"homepage"` - Description string `json:"description"` - Dependencies map[string]string `json:"dependencies"` - Repository repository `json:"repository"` - Private bool `json:"private"` + Version string `json:"version"` + Latest []string `json:"latest"` + Author person `json:"author"` + Authors people `json:"authors"` + Contributors people `json:"contributors"` + Maintainers people `json:"maintainers"` + License json.RawMessage `json:"license"` + Licenses json.RawMessage `json:"licenses"` + Name string `json:"name"` + Homepage string `json:"homepage"` + Description string `json:"description"` + Dependencies map[string]string `json:"dependencies"` + DevDependencies map[string]string `json:"devDependencies"` + Repository repository `json:"repository"` + Private bool `json:"private"` } type person struct { diff --git a/syft/pkg/cataloger/javascript/parse_yarn_lock.go b/syft/pkg/cataloger/javascript/parse_yarn_lock.go index beb1ca878..7132d1819 100644 --- a/syft/pkg/cataloger/javascript/parse_yarn_lock.go +++ b/syft/pkg/cataloger/javascript/parse_yarn_lock.go @@ -3,6 +3,7 @@ package javascript import ( "bytes" "context" + "encoding/json" "fmt" "io" "maps" @@ -13,6 +14,7 @@ import ( "github.com/goccy/go-yaml" "github.com/scylladb/go-set/strset" + "github.com/anchore/syft/internal" "github.com/anchore/syft/internal/log" "github.com/anchore/syft/internal/unknown" "github.com/anchore/syft/syft/artifact" @@ -51,7 +53,7 @@ type yarnPackage struct { Version string Resolved string Integrity string - Dependencies map[string]string // We don't currently support dependencies for yarn v1 lock files + Dependencies map[string]string } type yarnV2PackageEntry struct { @@ -71,6 +73,98 @@ func newGenericYarnLockAdapter(cfg CatalogerConfig) genericYarnLockAdapter { } } +// readPackageJSONDeps reads the package.json adjacent to the given lockfile location. +// NOTE: in yarn workspaces, only the root package.json is consulted. Packages declared +// as devDependencies only in a workspace package.json (not the root) will not be detected +// as dev-only. This is a safe degradation — those packages will be included in the SBOM +// rather than incorrectly filtered out. +func readPackageJSONDeps(resolver file.Resolver, lockfileLocation file.Location) (prod, dev map[string]string) { + prod = make(map[string]string) + dev = make(map[string]string) + + if resolver == nil { + return prod, dev + } + + pkgJSONLocation := resolver.RelativeFileByPath(lockfileLocation, "package.json") + if pkgJSONLocation == nil { + log.WithFields("lockfile", lockfileLocation.RealPath).Debug("could not find package.json for dev dependency detection") + return prod, dev + } + + reader, err := resolver.FileContentsByLocation(*pkgJSONLocation) + if err != nil { + log.WithFields("location", pkgJSONLocation.RealPath, "error", err).Debug("could not read package.json for dev dependency detection") + return prod, dev + } + defer internal.CloseAndLogError(reader, pkgJSONLocation.RealPath) + + var pkgJSON packageJSON + if err := json.NewDecoder(reader).Decode(&pkgJSON); err != nil { + log.WithFields("location", pkgJSONLocation.RealPath, "error", err).Debug("could not parse package.json for dev dependency detection") + return prod, dev + } + + if pkgJSON.Dependencies != nil { + prod = pkgJSON.Dependencies + } + if pkgJSON.DevDependencies != nil { + dev = pkgJSON.DevDependencies + } + + return prod, dev +} + +// findReachable returns all package names reachable from the given roots via BFS +// through the dependency graph. +func findReachable(roots map[string]string, pkgByName map[string]yarnPackage) map[string]bool { + visited := make(map[string]bool) + queue := make([]string, 0, len(roots)) + + for name := range roots { + queue = append(queue, name) + } + + for len(queue) > 0 { + name := queue[0] + queue = queue[1:] + + if visited[name] { + continue + } + visited[name] = true + + if pkg, exists := pkgByName[name]; exists { + for depName := range pkg.Dependencies { + if !visited[depName] { + queue = append(queue, depName) + } + } + } + } + + return visited +} + +func findDevOnlyPkgs(yarnPkgs []yarnPackage, prodDeps, devDeps map[string]string) map[string]bool { + pkgByName := make(map[string]yarnPackage) + for _, p := range yarnPkgs { + pkgByName[p.Name] = p + } + + prodTransitive := findReachable(prodDeps, pkgByName) + devTransitive := findReachable(devDeps, pkgByName) + + devOnly := make(map[string]bool) + for name := range devTransitive { + if !prodTransitive[name] { + devOnly[name] = true + } + } + + return devOnly +} + func parseYarnV1LockFile(reader io.ReadCloser) ([]yarnPackage, error) { content, err := io.ReadAll(reader) if err != nil { @@ -190,9 +284,17 @@ func (a genericYarnLockAdapter) parseYarnLock(ctx context.Context, resolver file return nil, nil, fmt.Errorf("failed to parse yarn.lock file: %w", err) } - packages := make([]pkg.Package, len(yarnPkgs)) - for i, p := range yarnPkgs { - packages[i] = newYarnLockPackage(ctx, a.cfg, resolver, reader.Location, p.Name, p.Version, p.Resolved, p.Integrity, p.Dependencies) + // get dependencies from sibling package.json for dev dependency detection + prodDeps, devDeps := readPackageJSONDeps(resolver, reader.Location) + + devOnlyPkgs := findDevOnlyPkgs(yarnPkgs, prodDeps, devDeps) + + packages := make([]pkg.Package, 0, len(yarnPkgs)) + for _, p := range yarnPkgs { + if devOnlyPkgs[p.Name] && !a.cfg.IncludeDevDependencies { + continue + } + packages = append(packages, newYarnLockPackage(ctx, a.cfg, resolver, reader.Location, p.Name, p.Version, p.Resolved, p.Integrity, p.Dependencies)) } pkg.Sort(packages) diff --git a/syft/pkg/cataloger/javascript/parse_yarn_lock_test.go b/syft/pkg/cataloger/javascript/parse_yarn_lock_test.go index 3b7eed4a7..38b84ee37 100644 --- a/syft/pkg/cataloger/javascript/parse_yarn_lock_test.go +++ b/syft/pkg/cataloger/javascript/parse_yarn_lock_test.go @@ -739,6 +739,274 @@ func TestParseYarnFindPackageNames(t *testing.T) { } } +func TestParseYarnLock_DevDependencies(t *testing.T) { + tests := []struct { + name string + fixtureDir string + includeDev bool + expected func(file.LocationSet) ([]pkg.Package, []artifact.Relationship) + }{ + { + name: "v1 include dev dependencies", + fixtureDir: "test-fixtures/yarn-dev-deps", + includeDev: true, + expected: func(locations file.LocationSet) ([]pkg.Package, []artifact.Relationship) { + pkgs := []pkg.Package{ + { + Name: "dev-only-transitive", + Version: "1.0.0", + Locations: locations, + PURL: "pkg:npm/dev-only-transitive@1.0.0", + Language: pkg.JavaScript, + Type: pkg.NpmPkg, + Metadata: pkg.YarnLockEntry{ + Resolved: "https://registry.yarnpkg.com/dev-only-transitive/-/dev-only-transitive-1.0.0.tgz#abc123", + Integrity: "sha512-devonlytransitive==", + Dependencies: map[string]string{}, + }, + }, + { + Name: "dev-pkg", + Version: "1.0.0", + Locations: locations, + PURL: "pkg:npm/dev-pkg@1.0.0", + Language: pkg.JavaScript, + Type: pkg.NpmPkg, + Metadata: pkg.YarnLockEntry{ + Resolved: "https://registry.yarnpkg.com/dev-pkg/-/dev-pkg-1.0.0.tgz#def456", + Integrity: "sha512-devpkg==", + Dependencies: map[string]string{ + "dev-only-transitive": "^1.0.0", + }, + }, + }, + { + Name: "prod-pkg", + Version: "1.0.0", + Locations: locations, + PURL: "pkg:npm/prod-pkg@1.0.0", + Language: pkg.JavaScript, + Type: pkg.NpmPkg, + Metadata: pkg.YarnLockEntry{ + Resolved: "https://registry.yarnpkg.com/prod-pkg/-/prod-pkg-1.0.0.tgz#ghi789", + Integrity: "sha512-prodpkg==", + Dependencies: map[string]string{ + "shared-pkg": "^1.0.0", + }, + }, + }, + { + Name: "shared-pkg", + Version: "1.0.0", + Locations: locations, + PURL: "pkg:npm/shared-pkg@1.0.0", + Language: pkg.JavaScript, + Type: pkg.NpmPkg, + Metadata: pkg.YarnLockEntry{ + Resolved: "https://registry.yarnpkg.com/shared-pkg/-/shared-pkg-1.0.0.tgz#jkl012", + Integrity: "sha512-sharedpkg==", + Dependencies: map[string]string{}, + }, + }, + } + rels := []artifact.Relationship{ + { + From: pkgs[0], // dev-only-transitive + To: pkgs[1], // dev-pkg + Type: artifact.DependencyOfRelationship, + }, + { + From: pkgs[3], // shared-pkg + To: pkgs[2], // prod-pkg + Type: artifact.DependencyOfRelationship, + }, + } + return pkgs, rels + }, + }, + { + name: "v1 exclude dev dependencies", + fixtureDir: "test-fixtures/yarn-dev-deps", + includeDev: false, + expected: func(locations file.LocationSet) ([]pkg.Package, []artifact.Relationship) { + pkgs := []pkg.Package{ + { + Name: "prod-pkg", + Version: "1.0.0", + Locations: locations, + PURL: "pkg:npm/prod-pkg@1.0.0", + Language: pkg.JavaScript, + Type: pkg.NpmPkg, + Metadata: pkg.YarnLockEntry{ + Resolved: "https://registry.yarnpkg.com/prod-pkg/-/prod-pkg-1.0.0.tgz#ghi789", + Integrity: "sha512-prodpkg==", + Dependencies: map[string]string{ + "shared-pkg": "^1.0.0", + }, + }, + }, + { + Name: "shared-pkg", + Version: "1.0.0", + Locations: locations, + PURL: "pkg:npm/shared-pkg@1.0.0", + Language: pkg.JavaScript, + Type: pkg.NpmPkg, + Metadata: pkg.YarnLockEntry{ + Resolved: "https://registry.yarnpkg.com/shared-pkg/-/shared-pkg-1.0.0.tgz#jkl012", + Integrity: "sha512-sharedpkg==", + Dependencies: map[string]string{}, + }, + }, + } + rels := []artifact.Relationship{ + { + From: pkgs[1], // shared-pkg + To: pkgs[0], // prod-pkg + Type: artifact.DependencyOfRelationship, + }, + } + return pkgs, rels + }, + }, + { + name: "v2 (berry) include dev dependencies", + fixtureDir: "test-fixtures/yarn-berry-dev-deps", + includeDev: true, + expected: func(locations file.LocationSet) ([]pkg.Package, []artifact.Relationship) { + pkgs := []pkg.Package{ + { + Name: "dev-only-transitive", + Version: "1.0.0", + Locations: locations, + PURL: "pkg:npm/dev-only-transitive@1.0.0", + Language: pkg.JavaScript, + Type: pkg.NpmPkg, + Metadata: pkg.YarnLockEntry{ + Resolved: "dev-only-transitive@npm:1.0.0", + Integrity: "abc123", + }, + }, + { + Name: "dev-pkg", + Version: "1.0.0", + Locations: locations, + PURL: "pkg:npm/dev-pkg@1.0.0", + Language: pkg.JavaScript, + Type: pkg.NpmPkg, + Metadata: pkg.YarnLockEntry{ + Resolved: "dev-pkg@npm:1.0.0", + Integrity: "def456", + Dependencies: map[string]string{ + "dev-only-transitive": "^1.0.0", + }, + }, + }, + { + Name: "prod-pkg", + Version: "1.0.0", + Locations: locations, + PURL: "pkg:npm/prod-pkg@1.0.0", + Language: pkg.JavaScript, + Type: pkg.NpmPkg, + Metadata: pkg.YarnLockEntry{ + Resolved: "prod-pkg@npm:1.0.0", + Integrity: "ghi789", + Dependencies: map[string]string{ + "shared-pkg": "^1.0.0", + }, + }, + }, + { + Name: "shared-pkg", + Version: "1.0.0", + Locations: locations, + PURL: "pkg:npm/shared-pkg@1.0.0", + Language: pkg.JavaScript, + Type: pkg.NpmPkg, + Metadata: pkg.YarnLockEntry{ + Resolved: "shared-pkg@npm:1.0.0", + Integrity: "jkl012", + }, + }, + } + rels := []artifact.Relationship{ + { + From: pkgs[0], // dev-only-transitive + To: pkgs[1], // dev-pkg + Type: artifact.DependencyOfRelationship, + }, + { + From: pkgs[3], // shared-pkg + To: pkgs[2], // prod-pkg + Type: artifact.DependencyOfRelationship, + }, + } + return pkgs, rels + }, + }, + { + name: "v2 (berry) exclude dev dependencies", + fixtureDir: "test-fixtures/yarn-berry-dev-deps", + includeDev: false, + expected: func(locations file.LocationSet) ([]pkg.Package, []artifact.Relationship) { + pkgs := []pkg.Package{ + { + Name: "prod-pkg", + Version: "1.0.0", + Locations: locations, + PURL: "pkg:npm/prod-pkg@1.0.0", + Language: pkg.JavaScript, + Type: pkg.NpmPkg, + Metadata: pkg.YarnLockEntry{ + Resolved: "prod-pkg@npm:1.0.0", + Integrity: "ghi789", + Dependencies: map[string]string{ + "shared-pkg": "^1.0.0", + }, + }, + }, + { + Name: "shared-pkg", + Version: "1.0.0", + Locations: locations, + PURL: "pkg:npm/shared-pkg@1.0.0", + Language: pkg.JavaScript, + Type: pkg.NpmPkg, + Metadata: pkg.YarnLockEntry{ + Resolved: "shared-pkg@npm:1.0.0", + Integrity: "jkl012", + }, + }, + } + rels := []artifact.Relationship{ + { + From: pkgs[1], // shared-pkg + To: pkgs[0], // prod-pkg + Type: artifact.DependencyOfRelationship, + }, + } + return pkgs, rels + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fixture := tt.fixtureDir + "/yarn.lock" + locations := file.NewLocationSet(file.NewLocation(fixture)) + expectedPkgs, expectedRels := tt.expected(locations) + + adapter := newGenericYarnLockAdapter(CatalogerConfig{IncludeDevDependencies: tt.includeDev}) + pkgtest.NewCatalogTester(). + FromDirectory(t, tt.fixtureDir). + FromFile(t, fixture). + Expects(expectedPkgs, expectedRels). + TestParser(t, adapter.parseYarnLock) + }) + } +} + func generateMockYarnRegistryHandler(responseFixture string) func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) diff --git a/syft/pkg/cataloger/javascript/test-fixtures/yarn-berry-dev-deps/package.json b/syft/pkg/cataloger/javascript/test-fixtures/yarn-berry-dev-deps/package.json new file mode 100644 index 000000000..a2ceb796b --- /dev/null +++ b/syft/pkg/cataloger/javascript/test-fixtures/yarn-berry-dev-deps/package.json @@ -0,0 +1,10 @@ +{ + "name": "test-project", + "version": "1.0.0", + "dependencies": { + "prod-pkg": "^1.0.0" + }, + "devDependencies": { + "dev-pkg": "^1.0.0" + } +} diff --git a/syft/pkg/cataloger/javascript/test-fixtures/yarn-berry-dev-deps/yarn.lock b/syft/pkg/cataloger/javascript/test-fixtures/yarn-berry-dev-deps/yarn.lock new file mode 100644 index 000000000..19cea15bd --- /dev/null +++ b/syft/pkg/cataloger/javascript/test-fixtures/yarn-berry-dev-deps/yarn.lock @@ -0,0 +1,38 @@ +# This file is generated by running "yarn install" inside your project. +# Manual changes might be lost - proceed with caution! + +__metadata: + version: 6 + cacheKey: 8 + +"dev-only-transitive@npm:^1.0.0": + version: 1.0.0 + resolution: "dev-only-transitive@npm:1.0.0" + checksum: abc123 + languageName: node + linkType: hard + +"dev-pkg@npm:^1.0.0": + version: 1.0.0 + resolution: "dev-pkg@npm:1.0.0" + dependencies: + dev-only-transitive: ^1.0.0 + checksum: def456 + languageName: node + linkType: hard + +"prod-pkg@npm:^1.0.0": + version: 1.0.0 + resolution: "prod-pkg@npm:1.0.0" + dependencies: + shared-pkg: ^1.0.0 + checksum: ghi789 + languageName: node + linkType: hard + +"shared-pkg@npm:^1.0.0": + version: 1.0.0 + resolution: "shared-pkg@npm:1.0.0" + checksum: jkl012 + languageName: node + linkType: hard diff --git a/syft/pkg/cataloger/javascript/test-fixtures/yarn-dev-deps/package.json b/syft/pkg/cataloger/javascript/test-fixtures/yarn-dev-deps/package.json new file mode 100644 index 000000000..a2ceb796b --- /dev/null +++ b/syft/pkg/cataloger/javascript/test-fixtures/yarn-dev-deps/package.json @@ -0,0 +1,10 @@ +{ + "name": "test-project", + "version": "1.0.0", + "dependencies": { + "prod-pkg": "^1.0.0" + }, + "devDependencies": { + "dev-pkg": "^1.0.0" + } +} diff --git a/syft/pkg/cataloger/javascript/test-fixtures/yarn-dev-deps/yarn.lock b/syft/pkg/cataloger/javascript/test-fixtures/yarn-dev-deps/yarn.lock new file mode 100644 index 000000000..8e1db8574 --- /dev/null +++ b/syft/pkg/cataloger/javascript/test-fixtures/yarn-dev-deps/yarn.lock @@ -0,0 +1,27 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +dev-only-transitive@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/dev-only-transitive/-/dev-only-transitive-1.0.0.tgz#abc123" + integrity sha512-devonlytransitive== + +dev-pkg@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/dev-pkg/-/dev-pkg-1.0.0.tgz#def456" + integrity sha512-devpkg== + dependencies: + dev-only-transitive "^1.0.0" + +prod-pkg@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/prod-pkg/-/prod-pkg-1.0.0.tgz#ghi789" + integrity sha512-prodpkg== + dependencies: + shared-pkg "^1.0.0" + +shared-pkg@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/shared-pkg/-/shared-pkg-1.0.0.tgz#jkl012" + integrity sha512-sharedpkg==