diff --git a/cmd/audit.go b/cmd/audit.go index 92ad991..28dac72 100644 --- a/cmd/audit.go +++ b/cmd/audit.go @@ -40,6 +40,15 @@ func AuditCommand() *cobra.Command { } } + var npmProjects []resolver.Project + if cfg.Npm.Lockfile != "" { + npmProjects, err = resolver.ProjectsFromNpmLockfileV3(cfg.Npm.Lockfile) + + if err != nil { + return errors.Wrap(err, "failed to discover dependencies from npm lockfile "+cfg.Npm.Lockfile) + } + } + var depProjects []resolver.Project if cfg.Dep.Lockfile != "" { depProjects, err = resolver.ProjectsFromDepLockfile(cfg.Dep.Lockfile) @@ -59,10 +68,10 @@ func AuditCommand() *cobra.Command { var yarnResolved = make(map[string]resolver.Dependency) if len(yarnProjects) > 0 { dirList := cfg.Yarn.NodeModulesDirs - fmt.Printf("Processing JS deps directories: %v \n", dirList) + fmt.Printf("Processing JS (yarn) deps directories: %v \n", dirList) currentDeps, err := resolver.LocateProjects(dirList, yarnProjects) if err != nil { - return errors.Wrapf(err, "failed to locate js dependencies in dirs %v", dirList) + return errors.Wrapf(err, "failed to locate js dependencies (yarn) in dirs %v", dirList) } for _, v := range currentDeps { fmt.Printf("Target dependency: %s \n", v) @@ -71,7 +80,18 @@ func AuditCommand() *cobra.Command { yarnResolved[keyWithVersion] = v } + } + var npmResolved = make(map[string]resolver.Dependency) + if len(npmProjects) > 0 { + rootDir := cfg.Npm.NodeModulesDir + currentDeps, err := resolver.LocateNpmPackageLockV3Projects(rootDir, npmProjects) + if err != nil { + return errors.Wrapf(err, "failed to locate js dependencies (npm) in dir %v", rootDir) + } + for versionedKey, dep := range currentDeps { + npmResolved[versionedKey] = dep + } } var depResolved map[string]resolver.Dependency @@ -92,7 +112,7 @@ func AuditCommand() *cobra.Command { } } - dependencies, err := joinDeps(cfg.PatternConfig, yarnResolved, depResolved, goModResolved) + dependencies, err := joinDeps(cfg.PatternConfig, yarnResolved, npmResolved, depResolved, goModResolved) if err != nil { return errors.Wrap(err, "resolving dependencies") } diff --git a/config/config.go b/config/config.go index e1e15cb..d42e4d2 100644 --- a/config/config.go +++ b/config/config.go @@ -17,6 +17,7 @@ type Config struct { Dep DepConfig `json:"dep"` GoMod GoModConfig `json:"gomod"` Yarn YarnConfig `json:"yarn"` + Npm NpmConfig `json:"npm"` PatternConfig } @@ -34,6 +35,11 @@ type YarnConfig struct { Lockfile string `json:"lockfile"` } +type NpmConfig struct { + NodeModulesDir string `json:"node-modules-dir"` + Lockfile string `json:"lockfile"` +} + func Load(filename string) (*Config, error) { body, err := ioutil.ReadFile(filename) if err != nil { diff --git a/resolver/npm.go b/resolver/npm.go new file mode 100644 index 0000000..e59dd89 --- /dev/null +++ b/resolver/npm.go @@ -0,0 +1,78 @@ +package resolver + +import ( + "encoding/json" + "os" + "strings" +) + +func ProjectsFromNpmLockfileV3(filename string) ([]Project, error) { + byteValue, err := os.ReadFile(filename) + if err != nil { + return nil, err + } + + var packageLock PackageLockV3 + + json.Unmarshal(byteValue, &packageLock) + + return asNpmProjects(packageLock), nil +} + +type NpmProject struct { + name string + version string + optional bool +} + +var _ Project = (*NpmProject)(nil) + +func (p NpmProject) Name() string { + return p.name +} + +func (p NpmProject) Optional() bool { + return p.optional +} + +func (p NpmProject) Version() string { + return p.version +} + +type PackageLockV3 struct { + Name string `json:"name"` + Version string `json:"version"` + Packages map[string]NpmPackage `json:"packages"` +} + +type NpmPackage struct { + Name string `json:"name"` + Version string `json:"version"` +} + +func asNpmProjects(packageLock PackageLockV3) []Project { + projectSet := make(map[string]NpmProject, len(packageLock.Packages)-1) + + for pkg, entry := range packageLock.Packages { + if pkg == "" { + // skip the top-level package as it refers to the project itself + continue + } + + projectSet[pkg] = NpmProject{ + // Remove the `node_modules/` root directory prefix from each `pkg` + name: strings.TrimPrefix(pkg, "node_modules/"), + version: entry.Version, + // optional packages that are included as dependencies in the build will be declared at the top + // level of packageLock.Packages, so we can explicitly mark optional as false here + optional: false, + } + } + + projectList := make([]Project, 0, len(projectSet)) + for _, project := range projectSet { + projectList = append(projectList, project) + } + + return projectList +} diff --git a/resolver/npm_test.go b/resolver/npm_test.go new file mode 100644 index 0000000..efbf03d --- /dev/null +++ b/resolver/npm_test.go @@ -0,0 +1,41 @@ +package resolver + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestProjectsFromNpmLockfileV3(t *testing.T) { + actualProjects, err := ProjectsFromNpmLockfileV3("testdata/package-lock.json") + require.Nil(t, err) + + expectedProjects := make(map[string]NpmProject, 4) + expectedProjects["@aashutoshrathi/word-wrap"] = NpmProject{ + name: "@aashutoshrathi/word-wrap", + optional: false, + version: "1.2.6", + } + expectedProjects["@adobe/css-tools"] = NpmProject{ + name: "@adobe/css-tools", + optional: false, + version: "4.3.2", + } + expectedProjects["yup/node_modules/type-fest"] = NpmProject{ + name: "yup/node_modules/type-fest", + optional: false, + version: "2.19.0", + } + expectedProjects["@apollo/client"] = NpmProject{ + name: "@apollo/client", + optional: false, + version: "3.8.7", + } + + for _, actualProject := range actualProjects { + expectedProject, ok := expectedProjects[actualProject.Name()] + require.True(t, ok) + assert.Equal(t, expectedProject, actualProject) + } +} diff --git a/resolver/projects.go b/resolver/projects.go index 2a8a14c..1f4497b 100644 --- a/resolver/projects.go +++ b/resolver/projects.go @@ -3,6 +3,7 @@ package resolver import ( "os" "path/filepath" + "regexp" "sort" "strings" @@ -23,6 +24,8 @@ type Dependency struct { Version string } +var nodeModulePrefixRegexp = regexp.MustCompile(`.*node_modules\/`) + func LocateGoModProjects(projects []GoModProject) (map[string]Dependency, error) { deps := make(map[string]Dependency, len(projects)) @@ -38,6 +41,32 @@ func LocateGoModProjects(projects []GoModProject) (map[string]Dependency, error) return deps, nil } +func LocateNpmPackageLockV3Projects(root string, projects []Project) (map[string]Dependency, error) { + deps := make(map[string]Dependency, len(projects)) + + for _, project := range projects { + baseDependencyName := nodeModulePrefixRegexp.ReplaceAllString(project.Name(), "") + sourceDir := filepath.Join(root, project.Name()) + if _, err := os.Stat(sourceDir); os.IsNotExist(err) { + // If a direct path to a nested dependency is not found, it was hoisted to the root to + // be shared among multiple parent dependencies. + // + // e.g. If multiple dependencies share a common transitive dependency on `type-fest`, the + // path `node_modules/yup/node_modules/type-fest` could become `node_modules/type-fest` + // when installed on disk. + sourceDir = filepath.Join(root, baseDependencyName) + } + dep := Dependency{ + Name: baseDependencyName, + Version: project.Version(), + SourceDir: sourceDir, + } + deps[baseDependencyName+dep.Version] = dep + } + + return deps, nil +} + func LocateProjects(roots []string, projects []Project) (map[string]Dependency, error) { locations := make(map[string]Dependency) diff --git a/resolver/testdata/package-lock.json b/resolver/testdata/package-lock.json new file mode 100644 index 0000000..a5094e9 --- /dev/null +++ b/resolver/testdata/package-lock.json @@ -0,0 +1,82 @@ +{ + "name": "@stackrox/platform-app", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@stackrox/platform-app", + "version": "0.0.0", + "license": "UNLICENSED", + "dependencies": [], + "devDependencies": [] + }, + "node_modules/@aashutoshrathi/word-wrap": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", + "integrity": "sha1-vZFUrsmYP3ezoDTsqgFcLkIB9s8= sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.3.2.tgz", + "integrity": "sha1-pqvHFftohIUfyp2tN/w0c5oE/RE= sha512-DA5a1C0gD/pLOvhv33YMrbf2FK3oUzwNl9oOJqE4XVjuEtt6XIakRcsd7eLiOSPkp1kTRQGICTA8cKra/vFbjw==", + "dev": true + }, + "node_modules/yup/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha1-iAaAFbszA2pZi5UuVekxGmD9Ops= sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@apollo/client": { + "version": "3.8.7", + "resolved": "https://registry.npmjs.org/@apollo/client/-/client-3.8.7.tgz", + "integrity": "sha1-CQsVGPUTUDuaamkO4+rsSVKYIuE= sha512-DnQtFkQrCyxHTSa9gR84YRLmU/al6HeXcLZazVe+VxKBmx/Hj4rV8xWtzfWYX5ijartsqDR7SJgV037MATEecA==", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@wry/context": "^0.7.3", + "@wry/equality": "^0.5.6", + "@wry/trie": "^0.4.3", + "graphql-tag": "^2.12.6", + "hoist-non-react-statics": "^3.3.2", + "optimism": "^0.17.5", + "prop-types": "^15.7.2", + "response-iterator": "^0.2.6", + "symbol-observable": "^4.0.0", + "ts-invariant": "^0.10.3", + "tslib": "^2.3.0", + "zen-observable-ts": "^1.2.5" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0", + "graphql-ws": "^5.5.5", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0", + "subscriptions-transport-ws": "^0.9.0 || ^0.11.0" + }, + "peerDependenciesMeta": { + "graphql-ws": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "subscriptions-transport-ws": { + "optional": true + } + } + } + } +}