diff --git a/syft/pkg/cataloger/javascript/parse_package_lock.go b/syft/pkg/cataloger/javascript/parse_package_lock.go index 3ac451c0d..4d12410ae 100644 --- a/syft/pkg/cataloger/javascript/parse_package_lock.go +++ b/syft/pkg/cataloger/javascript/parse_package_lock.go @@ -27,10 +27,11 @@ type packageLock struct { // lockDependency represents a single package dependency listed in the package.lock json file type lockDependency struct { - Version string `json:"version"` - Resolved string `json:"resolved"` - Integrity string `json:"integrity"` - Dev bool `json:"dev"` + Version string `json:"version"` + Resolved string `json:"resolved"` + Integrity string `json:"integrity"` + Dev bool `json:"dev"` + Dependencies map[string]lockDependency `json:"dependencies"` } type lockPackage struct { @@ -77,14 +78,7 @@ func (a genericPackageLockAdapter) parsePackageLock(ctx context.Context, resolve } if lock.LockfileVersion == 1 { - for name, pkgMeta := range lock.Dependencies { - // skip packages that are only present as a dev dependency - if !a.cfg.IncludeDevDependencies && pkgMeta.Dev { - continue - } - - pkgs = append(pkgs, newPackageLockV1Package(ctx, a.cfg, resolver, reader.Location, name, pkgMeta)) - } + pkgs = append(pkgs, a.packageLockV1Packages(ctx, resolver, reader.Location, lock.Dependencies)...) } if lock.LockfileVersion == 2 || lock.LockfileVersion == 3 { @@ -116,6 +110,32 @@ func (a genericPackageLockAdapter) parsePackageLock(ctx context.Context, resolve return pkgs, dependency.Resolve(packageLockDependencySpecifier, pkgs), unknown.IfEmptyf(pkgs, "unable to determine packages") } +func (a genericPackageLockAdapter) packageLockV1Packages(ctx context.Context, resolver file.Resolver, location file.Location, dependencies map[string]lockDependency) []pkg.Package { + var pkgs []pkg.Package + seen := make(map[string]struct{}) + + var walk func(map[string]lockDependency) + walk = func(dependencies map[string]lockDependency) { + for name, pkgMeta := range dependencies { + // Skipping a dev-only dependency also skips its dev-only subtree. + if !a.cfg.IncludeDevDependencies && pkgMeta.Dev { + continue + } + + p := newPackageLockV1Package(ctx, a.cfg, resolver, location, name, pkgMeta) + if _, exists := seen[p.PURL]; !exists { + pkgs = append(pkgs, p) + seen[p.PURL] = struct{}{} + } + + walk(pkgMeta.Dependencies) + } + } + + walk(dependencies) + return pkgs +} + func (licenses *packageLockLicense) UnmarshalJSON(data []byte) (err error) { // The license field could be either a string or an array. diff --git a/syft/pkg/cataloger/javascript/parse_package_lock_test.go b/syft/pkg/cataloger/javascript/parse_package_lock_test.go index 35a51b394..c7fc55144 100644 --- a/syft/pkg/cataloger/javascript/parse_package_lock_test.go +++ b/syft/pkg/cataloger/javascript/parse_package_lock_test.go @@ -111,6 +111,66 @@ func TestParsePackageLock(t *testing.T) { pkgtest.TestFileParser(t, fixture, adapter.parsePackageLock, expectedPkgs, expectedRelationships) } +func TestParsePackageLockV1NestedDependencies(t *testing.T) { + fixture := "testdata/pkg-lock/nested-package-lock-1.json" + expectedPkgs := []pkg.Package{ + { + Name: "duplicate", + Version: "1.0.0", + PURL: "pkg:npm/duplicate@1.0.0", + Language: pkg.JavaScript, + Type: pkg.NpmPkg, + Metadata: pkg.NpmPackageLockEntry{Resolved: "https://registry.npmjs.org/duplicate/-/duplicate-1.0.0.tgz", Integrity: "sha512-duplicate"}, + }, + { + Name: "middle", + Version: "1.0.0", + PURL: "pkg:npm/middle@1.0.0", + Language: pkg.JavaScript, + Type: pkg.NpmPkg, + Metadata: pkg.NpmPackageLockEntry{Resolved: "https://registry.npmjs.org/middle/-/middle-1.0.0.tgz", Integrity: "sha512-middle"}, + }, + { + Name: "parent", + Version: "1.0.0", + PURL: "pkg:npm/parent@1.0.0", + Language: pkg.JavaScript, + Type: pkg.NpmPkg, + Metadata: pkg.NpmPackageLockEntry{Resolved: "https://registry.npmjs.org/parent/-/parent-1.0.0.tgz", Integrity: "sha512-parent"}, + }, + { + Name: "shared", + Version: "0.5.0", + PURL: "pkg:npm/shared@0.5.0", + Language: pkg.JavaScript, + Type: pkg.NpmPkg, + Metadata: pkg.NpmPackageLockEntry{Resolved: "https://registry.npmjs.org/shared/-/shared-0.5.0.tgz", Integrity: "sha512-shared-0.5"}, + }, + { + Name: "shared", + Version: "1.0.0", + PURL: "pkg:npm/shared@1.0.0", + Language: pkg.JavaScript, + Type: pkg.NpmPkg, + Metadata: pkg.NpmPackageLockEntry{Resolved: "https://registry.npmjs.org/shared/-/shared-1.0.0.tgz", Integrity: "sha512-shared-1"}, + }, + { + Name: "shared", + Version: "2.0.0", + PURL: "pkg:npm/shared@2.0.0", + Language: pkg.JavaScript, + Type: pkg.NpmPkg, + Metadata: pkg.NpmPackageLockEntry{Resolved: "https://registry.npmjs.org/shared/-/shared-2.0.0.tgz", Integrity: "sha512-shared-2"}, + }, + } + for i := range expectedPkgs { + expectedPkgs[i].Locations.Add(file.NewLocation(fixture)) + } + + adapter := newGenericPackageLockAdapter(CatalogerConfig{}) + pkgtest.TestFileParser(t, fixture, adapter.parsePackageLock, expectedPkgs, nil) +} + func TestParsePackageLockV2(t *testing.T) { ctx := context.TODO() fixture := "testdata/pkg-lock/package-lock-2.json" diff --git a/syft/pkg/cataloger/javascript/testdata/pkg-lock/nested-package-lock-1.json b/syft/pkg/cataloger/javascript/testdata/pkg-lock/nested-package-lock-1.json new file mode 100644 index 000000000..51d25701f --- /dev/null +++ b/syft/pkg/cataloger/javascript/testdata/pkg-lock/nested-package-lock-1.json @@ -0,0 +1,54 @@ +{ + "requires": true, + "lockfileVersion": 1, + "dependencies": { + "parent": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/parent/-/parent-1.0.0.tgz", + "integrity": "sha512-parent", + "dependencies": { + "shared": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shared/-/shared-1.0.0.tgz", + "integrity": "sha512-shared-1" + }, + "middle": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/middle/-/middle-1.0.0.tgz", + "integrity": "sha512-middle", + "dependencies": { + "shared": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/shared/-/shared-0.5.0.tgz", + "integrity": "sha512-shared-0.5" + } + } + }, + "duplicate": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/duplicate/-/duplicate-1.0.0.tgz", + "integrity": "sha512-duplicate" + } + } + }, + "shared": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shared/-/shared-2.0.0.tgz", + "integrity": "sha512-shared-2" + }, + "duplicate": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/duplicate/-/duplicate-1.0.0.tgz", + "integrity": "sha512-duplicate" + }, + "dev-parent": { + "version": "1.0.0", + "dev": true, + "dependencies": { + "dev-nested": { + "version": "1.0.0" + } + } + } + } +}