diff --git a/package-lock.json b/package-lock.json index b5b87c3959..926fb311eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -72,9 +72,9 @@ "snyk-gradle-plugin": "7.0.0", "snyk-module": "3.1.0", "snyk-mvn-plugin": "4.8.0", - "snyk-nodejs-lockfile-parser": "2.8.1", - "snyk-nodejs-plugin": "^2.0.1", - "snyk-nuget-plugin": "4.2.0", + "snyk-nodejs-lockfile-parser": "2.9.0", + "snyk-nodejs-plugin": "^2.1.0", + "snyk-nuget-plugin": "4.2.3", "snyk-php-plugin": "1.12.1", "snyk-policy": "^4.1.6", "snyk-python-plugin": "^3.2.1", @@ -20126,9 +20126,9 @@ "license": "0BSD" }, "node_modules/snyk-nodejs-lockfile-parser": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/snyk-nodejs-lockfile-parser/-/snyk-nodejs-lockfile-parser-2.8.1.tgz", - "integrity": "sha512-DlUuMVcKC+zZkSYKLsOTuoqDc6SneG1zN8hqc5p5gi8pvhr4AoCgHScICbLoW9ZnukLZi5gMOLVFF/ea61dv/A==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/snyk-nodejs-lockfile-parser/-/snyk-nodejs-lockfile-parser-2.9.0.tgz", + "integrity": "sha512-phjgK+aC0Nme1xhNApczZL/JZgOVFI12V6wtCrUEHmDuHT0szahPrzNNqK+a8Rq+l1n8IP66ICyjZ9G6aryiLg==", "license": "Apache-2.0", "dependencies": { "@snyk/dep-graph": "^2.12.0", @@ -20183,9 +20183,9 @@ } }, "node_modules/snyk-nodejs-plugin": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/snyk-nodejs-plugin/-/snyk-nodejs-plugin-2.0.1.tgz", - "integrity": "sha512-X0/LOSyLOaqgOPtmRMBq2Wyzf/4eQJjaU24Sjs1ivWwEFhb6sm6RVzyKlovUnkyoOLqvM49UicYya5EcSB791Q==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/snyk-nodejs-plugin/-/snyk-nodejs-plugin-2.1.0.tgz", + "integrity": "sha512-DM8oXrVRF/WygpbpuedGLT3I+WgilPvfI8okNut5LxEupYlpDOM9Gbla4qrBzBIE9Q6lWB9IAB7PPJmd3pPkmA==", "license": "Apache-2.0", "dependencies": { "@snyk/cli-interface": "^2.13.0", @@ -20196,7 +20196,7 @@ "lodash.isempty": "^4.4.0", "lodash.sortby": "^4.7.0", "micromatch": "4.0.8", - "snyk-nodejs-lockfile-parser": "2.8.1", + "snyk-nodejs-lockfile-parser": "2.9.0", "snyk-resolve-deps": "4.8.0" }, "engines": { diff --git a/package.json b/package.json index 8f4fa16408..598ef0b1f1 100644 --- a/package.json +++ b/package.json @@ -121,8 +121,8 @@ "snyk-gradle-plugin": "7.0.0", "snyk-module": "3.1.0", "snyk-mvn-plugin": "4.8.0", - "snyk-nodejs-lockfile-parser": "2.8.1", - "snyk-nodejs-plugin": "^2.0.1", + "snyk-nodejs-lockfile-parser": "2.9.0", + "snyk-nodejs-plugin": "^2.1.0", "snyk-nuget-plugin": "4.2.3", "snyk-php-plugin": "1.12.1", "snyk-policy": "^4.1.6", diff --git a/src/lib/plugins/get-multi-plugin-result.ts b/src/lib/plugins/get-multi-plugin-result.ts index c3f124b117..7b57f4b813 100644 --- a/src/lib/plugins/get-multi-plugin-result.ts +++ b/src/lib/plugins/get-multi-plugin-result.ts @@ -257,6 +257,7 @@ async function processWorkspacesProjects( dev: options.dev, exclude: options.exclude, showNpmScope: featureFlags.has(SHOW_NPM_SCOPE), + includeComponentMetadata: options['include-component-metadata'], }, targetFiles, ); diff --git a/src/lib/plugins/get-single-plugin-result.ts b/src/lib/plugins/get-single-plugin-result.ts index 19cd7cbb30..50a5c6885b 100644 --- a/src/lib/plugins/get-single-plugin-result.ts +++ b/src/lib/plugins/get-single-plugin-result.ts @@ -35,10 +35,12 @@ export async function getSinglePluginResult( ), // Internal/undocumented flag: surfaced to the plugin in camelCase here // rather than via the user-facing arg transform list, so it stays off the - // documented CLI surface. Single convergence point for single- and - // multi-project (all-projects/aggregate) scans. Only added when set so the - // default plugin-options shape is unchanged (the flag is gateway-driven - // and absent for the vast majority of scans). + // documented CLI surface. Convergence point for single-project scans and + // the non-workspace files of all-projects/aggregate scans; npm workspace + // projects are handled earlier in getMultiPluginResult and forward the + // flag themselves (component metadata is currently npm-only). Only added + // when set so the default plugin-options shape is unchanged (the flag is + // gateway-driven and absent for the vast majority of scans). ...(options['include-component-metadata'] !== undefined && { includeComponentMetadata: options['include-component-metadata'], }), diff --git a/src/lib/plugins/nodejs-plugin/npm-lock-parser.ts b/src/lib/plugins/nodejs-plugin/npm-lock-parser.ts index 9e98090cf2..fa7d832a8d 100644 --- a/src/lib/plugins/nodejs-plugin/npm-lock-parser.ts +++ b/src/lib/plugins/nodejs-plugin/npm-lock-parser.ts @@ -14,6 +14,8 @@ import { import { Options } from '../types'; import { DepGraph } from '@snyk/dep-graph'; +const defaultIncludeComponentMetadata = false; + export async function parse( root: string, targetFile: string, @@ -75,6 +77,8 @@ export async function parse( pruneCycles: true, honorAliases: true, showNpmScope: options.showNpmScope, + includeComponentMetadata: + options.includeComponentMetadata || defaultIncludeComponentMetadata, }, ); } @@ -89,6 +93,7 @@ export async function parse( strictOutOfSync, true, options.showNpmScope, + options.includeComponentMetadata || defaultIncludeComponentMetadata, ); } finally { await spinner.clear(resolveModuleSpinnerLabel)(); diff --git a/src/lib/plugins/nodejs-plugin/npm-workspaces-parser.ts b/src/lib/plugins/nodejs-plugin/npm-workspaces-parser.ts index fc68a5a96e..4edae55ea7 100644 --- a/src/lib/plugins/nodejs-plugin/npm-workspaces-parser.ts +++ b/src/lib/plugins/nodejs-plugin/npm-workspaces-parser.ts @@ -1,7 +1,7 @@ import * as baseDebug from 'debug'; import * as pathUtil from 'path'; -const sortBy = require('lodash.sortby'); -const groupBy = require('lodash.groupby'); +import * as sortBy from 'lodash.sortby'; +import * as groupBy from 'lodash.groupby'; import * as micromatch from 'micromatch'; const debug = baseDebug('snyk-npm-workspaces'); @@ -20,6 +20,7 @@ export async function processNpmWorkspaces( dev?: boolean; yarnWorkspaces?: boolean; showNpmScope?: boolean; + includeComponentMetadata?: boolean; }, targetFiles: string[], ): Promise { @@ -103,6 +104,7 @@ export async function processNpmWorkspaces( includeOptionalDeps: false, pruneCycles: true, showNpmScope: settings.showNpmScope, + includeComponentMetadata: settings.includeComponentMetadata || false, }, ); diff --git a/src/lib/plugins/types.ts b/src/lib/plugins/types.ts index 03af8a712b..1ebf60a7ed 100644 --- a/src/lib/plugins/types.ts +++ b/src/lib/plugins/types.ts @@ -22,6 +22,7 @@ export interface Options { scanAllUnmanaged?: boolean; showNpmScope?: boolean; allProjects?: boolean; + includeComponentMetadata?: boolean; } export interface Plugin { diff --git a/test/fixtures/npm-include-component-metadata/lock-v1/package-lock.json b/test/fixtures/npm-include-component-metadata/lock-v1/package-lock.json new file mode 100644 index 0000000000..61a34c6609 --- /dev/null +++ b/test/fixtures/npm-include-component-metadata/lock-v1/package-lock.json @@ -0,0 +1,13 @@ +{ + "name": "npm-component-metadata-v1", + "version": "1.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "lodash": { + "version": "4.17.15", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", + "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==" + } + } +} diff --git a/test/fixtures/npm-include-component-metadata/lock-v1/package.json b/test/fixtures/npm-include-component-metadata/lock-v1/package.json new file mode 100644 index 0000000000..15b32e81ea --- /dev/null +++ b/test/fixtures/npm-include-component-metadata/lock-v1/package.json @@ -0,0 +1,7 @@ +{ + "name": "npm-component-metadata-v1", + "version": "1.0.0", + "dependencies": { + "lodash": "4.17.15" + } +} diff --git a/test/fixtures/npm-include-component-metadata/lock-v2/package-lock.json b/test/fixtures/npm-include-component-metadata/lock-v2/package-lock.json new file mode 100644 index 0000000000..2a70b0e4a1 --- /dev/null +++ b/test/fixtures/npm-include-component-metadata/lock-v2/package-lock.json @@ -0,0 +1,27 @@ +{ + "name": "npm-component-metadata-v2", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "npm-component-metadata-v2", + "version": "1.0.0", + "dependencies": { + "lodash": "4.17.15" + } + }, + "node_modules/lodash": { + "version": "4.17.15", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", + "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==" + } + }, + "dependencies": { + "lodash": { + "version": "4.17.15", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", + "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==" + } + } +} diff --git a/test/fixtures/npm-include-component-metadata/lock-v2/package.json b/test/fixtures/npm-include-component-metadata/lock-v2/package.json new file mode 100644 index 0000000000..c0df97e82d --- /dev/null +++ b/test/fixtures/npm-include-component-metadata/lock-v2/package.json @@ -0,0 +1,7 @@ +{ + "name": "npm-component-metadata-v2", + "version": "1.0.0", + "dependencies": { + "lodash": "4.17.15" + } +} diff --git a/test/fixtures/npm-include-component-metadata/lock-v3/package-lock.json b/test/fixtures/npm-include-component-metadata/lock-v3/package-lock.json new file mode 100644 index 0000000000..990ca3a92f --- /dev/null +++ b/test/fixtures/npm-include-component-metadata/lock-v3/package-lock.json @@ -0,0 +1,20 @@ +{ + "name": "npm-component-metadata-v3", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "npm-component-metadata-v3", + "version": "1.0.0", + "dependencies": { + "lodash": "4.17.15" + } + }, + "node_modules/lodash": { + "version": "4.17.15", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", + "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==" + } + } +} diff --git a/test/fixtures/npm-include-component-metadata/lock-v3/package.json b/test/fixtures/npm-include-component-metadata/lock-v3/package.json new file mode 100644 index 0000000000..dce2ba7ea1 --- /dev/null +++ b/test/fixtures/npm-include-component-metadata/lock-v3/package.json @@ -0,0 +1,7 @@ +{ + "name": "npm-component-metadata-v3", + "version": "1.0.0", + "dependencies": { + "lodash": "4.17.15" + } +} diff --git a/test/fixtures/npm-include-component-metadata/workspace/package-lock.json b/test/fixtures/npm-include-component-metadata/workspace/package-lock.json new file mode 100644 index 0000000000..8ea9c91f39 --- /dev/null +++ b/test/fixtures/npm-include-component-metadata/workspace/package-lock.json @@ -0,0 +1,53 @@ +{ + "name": "npm-workspace-component-metadata", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "npm-workspace-component-metadata", + "version": "1.0.0", + "license": "MIT", + "workspaces": [ + "packages/a", + "packages/b" + ] + }, + "node_modules/a": { + "resolved": "packages/a", + "link": true + }, + "node_modules/b": { + "resolved": "packages/b", + "link": true + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "packages/a": { + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "ms": "2.1.3" + } + }, + "packages/b": { + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "a": "^1.0.0", + "is-number": "7.0.0" + } + } + } +} diff --git a/test/fixtures/npm-include-component-metadata/workspace/package.json b/test/fixtures/npm-include-component-metadata/workspace/package.json new file mode 100644 index 0000000000..9d878c7c38 --- /dev/null +++ b/test/fixtures/npm-include-component-metadata/workspace/package.json @@ -0,0 +1,10 @@ +{ + "name": "npm-workspace-component-metadata", + "version": "1.0.0", + "license": "MIT", + "private": true, + "workspaces": [ + "packages/a", + "packages/b" + ] +} diff --git a/test/fixtures/npm-include-component-metadata/workspace/packages/a/package.json b/test/fixtures/npm-include-component-metadata/workspace/packages/a/package.json new file mode 100644 index 0000000000..8f57f1cef3 --- /dev/null +++ b/test/fixtures/npm-include-component-metadata/workspace/packages/a/package.json @@ -0,0 +1,8 @@ +{ + "name": "a", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "ms": "2.1.3" + } +} diff --git a/test/fixtures/npm-include-component-metadata/workspace/packages/b/package.json b/test/fixtures/npm-include-component-metadata/workspace/packages/b/package.json new file mode 100644 index 0000000000..816251e060 --- /dev/null +++ b/test/fixtures/npm-include-component-metadata/workspace/packages/b/package.json @@ -0,0 +1,9 @@ +{ + "name": "b", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "is-number": "7.0.0", + "a": "^1.0.0" + } +} diff --git a/test/jest/acceptance/snyk-test/npm-include-component-metadata.spec.ts b/test/jest/acceptance/snyk-test/npm-include-component-metadata.spec.ts new file mode 100644 index 0000000000..c8097c6ff5 --- /dev/null +++ b/test/jest/acceptance/snyk-test/npm-include-component-metadata.spec.ts @@ -0,0 +1,150 @@ +import { createProjectFromFixture } from '../../util/createProject'; +import { runSnykCLI } from '../../util/runSnykCLI'; + +jest.setTimeout(1000 * 60); + +// `--include-component-metadata` makes the npm plugin forward the flag to +// snyk-nodejs-lockfile-parser, which reads the install-time `integrity` and +// `resolved` fields already recorded in the lockfile and surfaces them as +// `hash:` and `distribution:url` labels on the dep-graph nodes. +// Unlike maven there is nothing to resolve first — the metadata lives in the +// lockfile — so these fixtures need no `npm install`. +// +// Covers npm lockfile v1 (legacy depTree path, converted to a dep-graph for +// printing) and v2/v3 (native dep-graph path). +describe('`snyk test --include-component-metadata` (npm)', () => { + interface PrintedGraph { + target: string; + graph: any; + } + + // `--print-graph` emits one `DepGraph data:\n\nDepGraph target:\n + // \nDepGraph end` block per scanned project. Split on the block delimiter so + // each graph can be asserted against by its target file rather than lumping + // every node together (which would hide which project a label came from). + const parseDepGraphs = (printGraphStdout: string): PrintedGraph[] => + printGraphStdout + .split('DepGraph end') + .filter((block) => block.includes('DepGraph data:')) + .map((block) => ({ + graph: JSON.parse( + block.split('DepGraph data:')[1].split('DepGraph target:')[0], + ), + target: block.split('DepGraph target:')[1].trim(), + })); + + const labelKeys = (graph: any, prefix: string): string[] => + graph.graph.nodes + .flatMap((node) => Object.keys(node.info?.labels ?? {})) + .filter((key) => key.startsWith(prefix)); + + const fixtures = [ + ['v1', 'npm-include-component-metadata/lock-v1'], + ['v2', 'npm-include-component-metadata/lock-v2'], + ['v3', 'npm-include-component-metadata/lock-v3'], + ]; + + describe.each(fixtures)('lockfile %s', (_version, fixture) => { + it('attaches hash and distribution:url labels with the flag', async () => { + const project = await createProjectFromFixture(fixture); + + const { code, stdout } = await runSnykCLI( + 'test --include-component-metadata --print-graph --file=package-lock.json', + { cwd: project.path() }, + ); + + expect(code).toEqual(0); + const graphs = parseDepGraphs(stdout); + expect(graphs).toHaveLength(1); + expect(labelKeys(graphs[0].graph, 'hash:').length).toBeGreaterThan(0); + expect( + labelKeys(graphs[0].graph, 'distribution:url').length, + ).toBeGreaterThan(0); + }); + + // Control: without the flag the same project must not produce the labels, + // proving they are driven by `--include-component-metadata`. + it('does not attach the labels without the flag', async () => { + const project = await createProjectFromFixture(fixture); + + const { code, stdout } = await runSnykCLI( + 'test --print-graph --file=package-lock.json', + { cwd: project.path() }, + ); + + expect(code).toEqual(0); + const graphs = parseDepGraphs(stdout); + expect(graphs).toHaveLength(1); + expect(labelKeys(graphs[0].graph, 'hash:')).toHaveLength(0); + expect(labelKeys(graphs[0].graph, 'distribution:url')).toHaveLength(0); + }); + }); + + // Workspace scans go through getMultiPluginResult -> processNpmWorkspaces, + // bypassing the single-file plugin path, so the flag must be forwarded there + // too. Without that wiring `--all-projects` silently produced no metadata + // labels for workspace projects. + // + // The fixture declares `workspaces` in the array form (`["packages/a", ...]`): + // processNpmWorkspaces' getWorkspacesMap only recognises that shape, so each + // member's package.json resolves against the root lockfile and no per-package + // lockfile or `npm install` is needed. `--all-projects` prints three graphs: + // the project-level root (package.json) which has no external direct deps and + // therefore no metadata labels, plus the two members whose deps (ms / + // is-number) carry them. + describe('npm workspaces (`--all-projects`)', () => { + const fixture = 'npm-include-component-metadata/workspace'; + const root = 'package.json'; + const members = ['packages/a/package.json', 'packages/b/package.json']; + + it('attaches hash and distribution:url labels to the workspace members', async () => { + const project = await createProjectFromFixture(fixture); + + const { code, stdout } = await runSnykCLI( + 'test --include-component-metadata --print-graph --all-projects', + { cwd: project.path() }, + ); + + expect(code).toEqual(0); + const graphs = parseDepGraphs(stdout); + expect(graphs.map((g) => g.target).sort()).toEqual([root, ...members]); + + const byTarget = (target: string) => { + const found = graphs.find((g) => g.target === target); + if (!found) { + throw new Error(`no printed dep-graph for target ${target}`); + } + return found.graph; + }; + + // The root project itself has no external direct dependencies, so it + // carries no component-metadata labels — only the members do. + expect(labelKeys(byTarget(root), 'hash:')).toHaveLength(0); + + for (const member of members) { + expect(labelKeys(byTarget(member), 'hash:').length).toBeGreaterThan(0); + expect( + labelKeys(byTarget(member), 'distribution:url').length, + ).toBeGreaterThan(0); + } + }); + + it('does not attach the labels without the flag', async () => { + const project = await createProjectFromFixture(fixture); + + const { code, stdout } = await runSnykCLI( + 'test --print-graph --all-projects', + { cwd: project.path() }, + ); + + expect(code).toEqual(0); + const graphs = parseDepGraphs(stdout); + expect(graphs.map((g) => g.target).sort()).toEqual([root, ...members]); + + for (const { graph } of graphs) { + expect(labelKeys(graph, 'hash:')).toHaveLength(0); + expect(labelKeys(graph, 'distribution:url')).toHaveLength(0); + } + }); + }); +});