diff --git a/src/providers/javascript_npm.js b/src/providers/javascript_npm.js index 037c0205..f6d46338 100644 --- a/src/providers/javascript_npm.js +++ b/src/providers/javascript_npm.js @@ -17,4 +17,26 @@ export default class Javascript_npm extends Base_javascript { _updateLockFileCmdArgs() { return ['install', '--package-lock-only']; } + + _buildDependencyTree(includeTransitive, opts = {}) { + // npm ls --json returns a single tree rooted at the workspace root. + // When analyzing a workspace member, its deps are nested under the + // root's dependencies keyed by the member name — extract that subtree + // so downstream analysis sees only the member's dependencies. + const tree = super._buildDependencyTree(includeTransitive, opts); + const memberName = this._getManifest().name; + if (tree.name === memberName) { + return tree; + } + const memberEntry = tree.dependencies?.[memberName]; + if (memberEntry) { + return { + name: memberName, + version: memberEntry.version || this._getManifest().version, + dependencies: memberEntry.dependencies, + optionalDependencies: memberEntry.optionalDependencies, + }; + } + return tree; + } } diff --git a/test/providers/javascript.test.js b/test/providers/javascript.test.js index 5101d80f..1aa0b118 100644 --- a/test/providers/javascript.test.js +++ b/test/providers/javascript.test.js @@ -90,7 +90,7 @@ suite('testing the javascript-npm data provider', async () => { compareSboms(providedDataForStack.content, expectedSbom); - }).timeout(process.env.GITHUB_ACTIONS ? 30000 : 10000); + }).timeout(30000); test(`verify package.json data provided for ${providerName} - component analysis - ${scenario}`, async () => { // load the expected list for the scenario let expectedSbom = fs.readFileSync(`test/providers/tst_manifests/js-common/${testCase}/component_expected_sbom.json`,).toString().trim() @@ -102,10 +102,44 @@ suite('testing the javascript-npm data provider', async () => { let providedDataForComponent = provider.provideComponent(manifestPath); compareSboms(providedDataForComponent.content, expectedSbom); - }).timeout(process.env.GITHUB_ACTIONS ? 15000 : 10000) + }).timeout(15000) }); + [ + { providerName: 'npm', testCase: 'workspace_member' }, + ].forEach(({ providerName, testCase }) => { + /// Verifies that stack analysis resolves transitive dependencies for a workspace member. + test(`verify workspace member data provided for ${providerName} - stack analysis`, async () => { + // Given a workspace member manifest and mock listing from the workspace root + const listing = fs.readFileSync(`test/providers/tst_manifests/${providerName}/${testCase}/listing_stack.json`).toString(); + const expectedSbom = fs.readFileSync(`test/providers/tst_manifests/${providerName}/${testCase}/stack_expected_sbom.json`).toString(); + const provider = await createMockProvider(providerName, listing); + const manifestPath = `test/providers/tst_manifests/${providerName}/${testCase}/packages/member-a/package.json`; + + // When running stack analysis on the workspace member + const result = provider.provideStack(manifestPath); + + // Then the SBOM should contain the member's transitive dependencies + compareSboms(result.content, expectedSbom); + }).timeout(30000); + + /// Verifies that component analysis resolves direct dependencies for a workspace member. + test(`verify workspace member data provided for ${providerName} - component analysis`, async () => { + // Given a workspace member manifest and mock listing from the workspace root + const listing = fs.readFileSync(`test/providers/tst_manifests/${providerName}/${testCase}/listing_component.json`).toString(); + const expectedSbom = fs.readFileSync(`test/providers/tst_manifests/${providerName}/${testCase}/component_expected_sbom.json`).toString(); + const provider = await createMockProvider(providerName, listing); + const manifestPath = `test/providers/tst_manifests/${providerName}/${testCase}/packages/member-a/package.json`; + + // When running component analysis on the workspace member + const result = provider.provideComponent(manifestPath); + + // Then the SBOM should contain only the member's direct dependencies + compareSboms(result.content, expectedSbom); + }).timeout(15000); + }); + test('loads a valid manifest with ignored dependencies', () => { const testCase = 'package_json_deps_with_exhortignore_object'; const manifestPath = `test/providers/tst_manifests/npm/${testCase}/package.json`; diff --git a/test/providers/tst_manifests/npm/workspace_member/component_expected_sbom.json b/test/providers/tst_manifests/npm/workspace_member/component_expected_sbom.json new file mode 100644 index 00000000..b1033c7e --- /dev/null +++ b/test/providers/tst_manifests/npm/workspace_member/component_expected_sbom.json @@ -0,0 +1,36 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "version": 1, + "metadata": { + "timestamp": "2023-08-07T00:00:00.000Z", + "component": { + "name": "member-a", + "version": "1.0.0", + "purl": "pkg:npm/member-a@1.0.0", + "type": "application", + "bom-ref": "pkg:npm/member-a@1.0.0" + } + }, + "components": [ + { + "name": "axios", + "version": "0.21.1", + "purl": "pkg:npm/axios@0.21.1", + "type": "library", + "bom-ref": "pkg:npm/axios@0.21.1" + } + ], + "dependencies": [ + { + "ref": "pkg:npm/member-a@1.0.0", + "dependsOn": [ + "pkg:npm/axios@0.21.1" + ] + }, + { + "ref": "pkg:npm/axios@0.21.1", + "dependsOn": [] + } + ] +} diff --git a/test/providers/tst_manifests/npm/workspace_member/listing_component.json b/test/providers/tst_manifests/npm/workspace_member/listing_component.json new file mode 100644 index 00000000..d78cabbd --- /dev/null +++ b/test/providers/tst_manifests/npm/workspace_member/listing_component.json @@ -0,0 +1,17 @@ +{ + "name": "test-workspace-root", + "dependencies": { + "member-a": { + "version": "1.0.0", + "resolved": "file:../packages/member-a", + "overridden": false, + "dependencies": { + "axios": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz", + "overridden": false + } + } + } + } +} diff --git a/test/providers/tst_manifests/npm/workspace_member/listing_stack.json b/test/providers/tst_manifests/npm/workspace_member/listing_stack.json new file mode 100644 index 00000000..16a6a05e --- /dev/null +++ b/test/providers/tst_manifests/npm/workspace_member/listing_stack.json @@ -0,0 +1,24 @@ +{ + "name": "test-workspace-root", + "dependencies": { + "member-a": { + "version": "1.0.0", + "resolved": "file:../packages/member-a", + "overridden": false, + "dependencies": { + "axios": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz", + "overridden": false, + "dependencies": { + "follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "overridden": false + } + } + } + } + } + } +} diff --git a/test/providers/tst_manifests/npm/workspace_member/package-lock.json b/test/providers/tst_manifests/npm/workspace_member/package-lock.json new file mode 100644 index 00000000..268208f5 --- /dev/null +++ b/test/providers/tst_manifests/npm/workspace_member/package-lock.json @@ -0,0 +1,4 @@ +{ + "name": "test-workspace-root", + "lockfileVersion": 3 +} diff --git a/test/providers/tst_manifests/npm/workspace_member/package.json b/test/providers/tst_manifests/npm/workspace_member/package.json new file mode 100644 index 00000000..98101a90 --- /dev/null +++ b/test/providers/tst_manifests/npm/workspace_member/package.json @@ -0,0 +1,5 @@ +{ + "name": "test-workspace-root", + "private": true, + "workspaces": ["packages/*"] +} diff --git a/test/providers/tst_manifests/npm/workspace_member/packages/member-a/package.json b/test/providers/tst_manifests/npm/workspace_member/packages/member-a/package.json new file mode 100644 index 00000000..fba8d2af --- /dev/null +++ b/test/providers/tst_manifests/npm/workspace_member/packages/member-a/package.json @@ -0,0 +1,7 @@ +{ + "name": "member-a", + "version": "1.0.0", + "dependencies": { + "axios": "0.21.1" + } +} diff --git a/test/providers/tst_manifests/npm/workspace_member/stack_expected_sbom.json b/test/providers/tst_manifests/npm/workspace_member/stack_expected_sbom.json new file mode 100644 index 00000000..28da3d7a --- /dev/null +++ b/test/providers/tst_manifests/npm/workspace_member/stack_expected_sbom.json @@ -0,0 +1,49 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "version": 1, + "metadata": { + "timestamp": "2023-08-07T00:00:00.000Z", + "component": { + "name": "member-a", + "version": "1.0.0", + "purl": "pkg:npm/member-a@1.0.0", + "type": "application", + "bom-ref": "pkg:npm/member-a@1.0.0" + } + }, + "components": [ + { + "name": "axios", + "version": "0.21.1", + "purl": "pkg:npm/axios@0.21.1", + "type": "library", + "bom-ref": "pkg:npm/axios@0.21.1" + }, + { + "name": "follow-redirects", + "version": "1.16.0", + "purl": "pkg:npm/follow-redirects@1.16.0", + "type": "library", + "bom-ref": "pkg:npm/follow-redirects@1.16.0" + } + ], + "dependencies": [ + { + "ref": "pkg:npm/member-a@1.0.0", + "dependsOn": [ + "pkg:npm/axios@0.21.1" + ] + }, + { + "ref": "pkg:npm/axios@0.21.1", + "dependsOn": [ + "pkg:npm/follow-redirects@1.16.0" + ] + }, + { + "ref": "pkg:npm/follow-redirects@1.16.0", + "dependsOn": [] + } + ] +}