feat: add yarn lock dev dep detection; fixed #4548

---------
Signed-off-by: Rez Moss <hi@rezmoss.com>
Signed-off-by: Christopher Phillips <32073428+spiffcs@users.noreply.github.com>
Co-authored-by: Christopher Phillips <32073428+spiffcs@users.noreply.github.com>
This commit is contained in:
Rez Moss 2026-02-05 17:27:17 -05:00 committed by GitHub
parent 48ee12be0c
commit c185657d71
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 476 additions and 19 deletions

View File

@ -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() {

View File

@ -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 {

View File

@ -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)

View File

@ -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)

View File

@ -0,0 +1,10 @@
{
"name": "test-project",
"version": "1.0.0",
"dependencies": {
"prod-pkg": "^1.0.0"
},
"devDependencies": {
"dev-pkg": "^1.0.0"
}
}

View File

@ -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

View File

@ -0,0 +1,10 @@
{
"name": "test-project",
"version": "1.0.0",
"dependencies": {
"prod-pkg": "^1.0.0"
},
"devDependencies": {
"dev-pkg": "^1.0.0"
}
}

View File

@ -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==