diff --git a/src/cli/commands/constants.ts b/src/cli/commands/constants.ts index f7074f16a6..30802f846a 100644 --- a/src/cli/commands/constants.ts +++ b/src/cli/commands/constants.ts @@ -2,6 +2,8 @@ export const SCAN_USR_LIB_JARS_FEATURE_FLAG = 'scanUsrLibJars'; export const CONTAINER_CLI_APP_VULNS_ENABLED_FEATURE_FLAG = 'containerCliAppVulnsEnabled'; +export const DISABLE_CONTAINER_MONITOR_PROJECT_NAME_FIX_FEATURE_FLAG = + 'disableContainerMonitorProjectNameFix'; // CLI option names export const INCLUDE_SYSTEM_JARS_OPTION = 'include-system-jars'; diff --git a/src/cli/commands/monitor/index.ts b/src/cli/commands/monitor/index.ts index 0b8c63be81..9c073327fe 100644 --- a/src/cli/commands/monitor/index.ts +++ b/src/cli/commands/monitor/index.ts @@ -55,6 +55,7 @@ import { import { SCAN_USR_LIB_JARS_FEATURE_FLAG, CONTAINER_CLI_APP_VULNS_ENABLED_FEATURE_FLAG, + DISABLE_CONTAINER_MONITOR_PROJECT_NAME_FIX_FEATURE_FLAG, INCLUDE_SYSTEM_JARS_OPTION, EXCLUDE_APP_VULNS_OPTION, APP_VULNS_OPTION, @@ -147,6 +148,17 @@ export default async function monitor(...args0: MethodArgs): Promise { if (scanUsrLibJarsEnabled) { options[INCLUDE_SYSTEM_JARS_OPTION] = true; } + + // Check disableContainerMonitorProjectNameFix feature flag + // When enabled, reverts to legacy behavior (using id instead of projectName in JSON output) + const disableContainerMonitorProjectNameFix = await hasFeatureFlagOrDefault( + DISABLE_CONTAINER_MONITOR_PROJECT_NAME_FIX_FEATURE_FLAG, + options, + false, + ); + if (disableContainerMonitorProjectNameFix) { + options.disableContainerMonitorProjectNameFix = true; + } } // Handles no image arg provided to the container command until diff --git a/src/lib/ecosystems/monitor.ts b/src/lib/ecosystems/monitor.ts index 929bc088b7..7ed79a21b5 100644 --- a/src/lib/ecosystems/monitor.ts +++ b/src/lib/ecosystems/monitor.ts @@ -218,7 +218,10 @@ export async function getFormattedMonitorOutput( ok: true, data: monOutput, path: monitorResult.path, - projectName: monitorResult.id, + // Use correct projectName by default; feature flag reverts to legacy behavior (id) + projectName: options.disableContainerMonitorProjectNameFix + ? monitorResult.id + : monitorResult.projectName, }); } for (const monitorError of errors) { diff --git a/src/lib/types.ts b/src/lib/types.ts index a00961f412..a1146d68a0 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -130,6 +130,7 @@ export interface Options { // Feature Flags useImprovedDotnetWithoutPublish?: boolean; + disableContainerMonitorProjectNameFix?: boolean; } // TODO(kyegupov): catch accessing ['undefined-properties'] via noImplicitAny diff --git a/test/fixtures/container-projects/container-deb-scan-result.json b/test/fixtures/container-projects/container-deb-scan-result.json new file mode 100644 index 0000000000..25b4ffff93 --- /dev/null +++ b/test/fixtures/container-projects/container-deb-scan-result.json @@ -0,0 +1,27 @@ +{ + "identity": { + "type": "deb" + }, + "facts": [ + { + "type": "depGraph", + "data": { + "schemaVersion": "1.2.0", + "pkgManager": { + "name": "deb", + "repositories": [{"alias": "debian:11"}] + }, + "pkgs": [{"id": "alpine@3.18", "info": {"name": "alpine", "version": "3.18"}}], + "graph": { + "rootNodeId": "root-node", + "nodes": [{"nodeId": "root-node", "pkgId": "alpine@3.18", "deps": []}] + } + } + } + ], + "target": { + "image": "alpine:latest" + }, + "name": "my-custom-project-name" +} + diff --git a/test/fixtures/container-projects/monitor-dependencies-response-with-project-name.json b/test/fixtures/container-projects/monitor-dependencies-response-with-project-name.json new file mode 100644 index 0000000000..086f7c1783 --- /dev/null +++ b/test/fixtures/container-projects/monitor-dependencies-response-with-project-name.json @@ -0,0 +1,12 @@ +{ + "ok": true, + "org": "test-org", + "id": "7c7305e2-fbcb-44d7-8fbf-8367371c509f", + "isMonitored": true, + "licensesPolicy": null, + "uri": "https://app.snyk.io/org/test-org/project/3dda9b21-ca42-4de6-be7a-85696fa6e866/history/f60dce17-8a72-4cca-8a76-e9c88df546aa", + "trialStarted": false, + "path": "/srv", + "projectName": "my-custom-project-name" +} + diff --git a/test/jest/acceptance/snyk-container/container.spec.ts b/test/jest/acceptance/snyk-container/container.spec.ts index 01cac166f9..ab17fd39dc 100644 --- a/test/jest/acceptance/snyk-container/container.spec.ts +++ b/test/jest/acceptance/snyk-container/container.spec.ts @@ -18,8 +18,8 @@ describe('snyk container', () => { }); } - const TEST_DISTROLESS_STATIC_IMAGE = - 'gcr.io/distroless/static@sha256:7198a357ff3a8ef750b041324873960cf2153c11cc50abb9d8d5f8bb089f6b4e'; + const TEST_DISTROLESS_STATIC_IMAGE_NAME = 'gcr.io/distroless/static'; + const TEST_DISTROLESS_STATIC_IMAGE = `${TEST_DISTROLESS_STATIC_IMAGE_NAME}@sha256:7198a357ff3a8ef750b041324873960cf2153c11cc50abb9d8d5f8bb089f6b4e`; const TEST_DISTROLESS_STATIC_IMAGE_DEPGRAPH = { schemaVersion: '1.3.0', pkgManager: { @@ -675,6 +675,7 @@ DepGraph end`, expect.objectContaining({ ok: true, packageManager: 'deb', + projectName: `docker-image|${TEST_DISTROLESS_STATIC_IMAGE_NAME}`, manageUrl: expect.stringContaining('://'), scanResult: expect.objectContaining({ facts: expect.arrayContaining([ @@ -709,6 +710,7 @@ DepGraph end`, expect.objectContaining({ ok: true, packageManager: 'deb', + projectName: 'docker-image|snyk/snyk', manageUrl: expect.stringContaining('://'), scanResult: expect.objectContaining({ facts: expect.arrayContaining([ @@ -731,6 +733,7 @@ DepGraph end`, expect.objectContaining({ ok: true, packageManager: 'gomodules', + projectName: 'docker-image|snyk/snyk:/usr/local/bin/snyk', manageUrl: expect.stringContaining('://'), scanResult: expect.objectContaining({ facts: expect.arrayContaining([ @@ -748,6 +751,18 @@ DepGraph end`, ]), ); }); + + it('snyk container monitor json returns custom projectName when --project-name is provided', async () => { + const customProjectName = 'my-custom-project-name'; + const { code, stdout } = await runSnykCLI( + `container monitor --platform=linux/amd64 --project-name=${customProjectName} --json ${TEST_DISTROLESS_STATIC_IMAGE}`, + ); + expect(code).toEqual(0); + const result = JSON.parse(stdout); + + // projectName should match the --project-name flag value + expect(result.projectName).toBe(customProjectName); + }); }); describe('snyk container monitor supports --target-reference', () => { diff --git a/test/jest/unit/cli-commands/test-and-monitor.spec.ts b/test/jest/unit/cli-commands/test-and-monitor.spec.ts index 5ecd1b77ba..9bce7aa380 100644 --- a/test/jest/unit/cli-commands/test-and-monitor.spec.ts +++ b/test/jest/unit/cli-commands/test-and-monitor.spec.ts @@ -3,6 +3,7 @@ import { DOTNET_WITHOUT_PUBLISH_FEATURE_FLAG } from '../../../../src/lib/package import * as featureFlags from '../../../../src/lib/feature-flags'; import { SCAN_USR_LIB_JARS_FEATURE_FLAG, + DISABLE_CONTAINER_MONITOR_PROJECT_NAME_FIX_FEATURE_FLAG, INCLUDE_SYSTEM_JARS_OPTION, EXCLUDE_APP_VULNS_OPTION, } from '../../../../src/cli/commands/constants'; @@ -403,4 +404,98 @@ describe('monitor & test', () => { }); }); }); + + describe('docker disableContainerMonitorProjectNameFix feature flag', () => { + beforeEach(() => { + getEcosystemSpy.mockReturnValue(undefined); + analyticsSpy.mockReturnValue(false); + }); + + describe('monitor command', () => { + it('should set disableContainerMonitorProjectNameFix on options when feature flag is enabled (to revert to legacy behavior)', async () => { + const options: any = { + docker: true, + [EXCLUDE_APP_VULNS_OPTION]: true, + }; + + (featureFlags.hasFeatureFlagOrDefault as jest.Mock).mockImplementation( + (flag: string) => { + if (flag === DISABLE_CONTAINER_MONITOR_PROJECT_NAME_FIX_FEATURE_FLAG) { + return Promise.resolve(true); + } + return Promise.resolve(false); + }, + ); + + try { + await monitor('docker-image:latest', options); + } catch (error) { + // We expect this to fail since we are not mocking all dependencies. + // We only care about the feature flag being called correctly. + } + + expect(featureFlags.hasFeatureFlagOrDefault).toHaveBeenCalledWith( + DISABLE_CONTAINER_MONITOR_PROJECT_NAME_FIX_FEATURE_FLAG, + expect.objectContaining({ + docker: true, + }), + false, + ); + + // When flag is enabled, it triggers legacy behavior (using id instead of projectName) + expect(options.disableContainerMonitorProjectNameFix).toBe(true); + }); + + it('should not set disableContainerMonitorProjectNameFix on options by default (uses new correct behavior)', async () => { + const options: any = { + docker: true, + [EXCLUDE_APP_VULNS_OPTION]: true, + }; + + (featureFlags.hasFeatureFlagOrDefault as jest.Mock).mockImplementation( + () => { + return Promise.resolve(false); + }, + ); + + try { + await monitor('docker-image:latest', options); + } catch (error) { + // We expect this to fail since we are not mocking all dependencies. + // We only care about the feature flag being called correctly. + } + + expect(featureFlags.hasFeatureFlagOrDefault).toHaveBeenCalledWith( + DISABLE_CONTAINER_MONITOR_PROJECT_NAME_FIX_FEATURE_FLAG, + expect.objectContaining({ + docker: true, + }), + false, + ); + + // When flag is not set, uses new correct behavior (projectName) + expect(options.disableContainerMonitorProjectNameFix).toBeUndefined(); + }); + + it('should not check disableContainerMonitorProjectNameFix feature flag for non-docker scans', async () => { + const options: any = { + docker: false, + }; + + try { + await monitor('path/to/project', options); + } catch (error) { + // We expect this to fail since we are not mocking all dependencies. + // We only care about the options being set correctly. + } + + expect(featureFlags.hasFeatureFlagOrDefault).not.toHaveBeenCalledWith( + DISABLE_CONTAINER_MONITOR_PROJECT_NAME_FIX_FEATURE_FLAG, + options, + false, + ); + expect(options.disableContainerMonitorProjectNameFix).toBeUndefined(); + }); + }); + }); }); diff --git a/test/jest/unit/ecosystems-monitor-docker.spec.ts b/test/jest/unit/ecosystems-monitor-docker.spec.ts index 1fbcb57776..b2f113b9cc 100644 --- a/test/jest/unit/ecosystems-monitor-docker.spec.ts +++ b/test/jest/unit/ecosystems-monitor-docker.spec.ts @@ -186,4 +186,103 @@ describe('monitorEcosystem docker/container', () => { makeRequestSpy.mock.calls[0][0].body.pruneRepeatedSubdependencies, ).toBeUndefined(); }); + + it('should return projectName from registry response in JSON output by default', async () => { + const containerScanResult = readJsonFixture( + 'container-deb-scan-result.json', + ) as ScanResult; + const monitorDependenciesResponse = readJsonFixture( + 'monitor-dependencies-response-with-project-name.json', + ) as ecosystemsTypes.MonitorDependenciesResponse; + + jest + .spyOn(dockerPlugin, 'scan') + .mockResolvedValue({ scanResults: [containerScanResult] }); + jest + .spyOn(request, 'makeRequest') + .mockResolvedValue(monitorDependenciesResponse); + + const results: Array = []; + + const [monitorResults, monitorErrors] = await ecosystems.monitorEcosystem( + 'docker', + ['/srv'], + { + path: '/srv', + docker: true, + org: 'test-org', + }, + ); + + const jsonOutput = await getFormattedMonitorOutput( + results, + monitorResults, + monitorErrors, + { + path: '/srv', + docker: true, + org: 'test-org', + json: true, + // Feature flag not set - uses new correct behavior by default + } as Options, + ); + + const parsedOutput = JSON.parse(jsonOutput); + + // projectName should be the actual project name from the registry by default + expect(parsedOutput.projectName).toBe('my-custom-project-name'); + expect(parsedOutput.projectName).not.toBe( + '7c7305e2-fbcb-44d7-8fbf-8367371c509f', + ); + }); + + it('should return id as projectName in JSON output when feature flag is enabled (legacy behavior)', async () => { + const containerScanResult = readJsonFixture( + 'container-deb-scan-result.json', + ) as ScanResult; + const monitorDependenciesResponse = readJsonFixture( + 'monitor-dependencies-response-with-project-name.json', + ) as ecosystemsTypes.MonitorDependenciesResponse; + + jest + .spyOn(dockerPlugin, 'scan') + .mockResolvedValue({ scanResults: [containerScanResult] }); + jest + .spyOn(request, 'makeRequest') + .mockResolvedValue(monitorDependenciesResponse); + + const results: Array = []; + + const [monitorResults, monitorErrors] = await ecosystems.monitorEcosystem( + 'docker', + ['/srv'], + { + path: '/srv', + docker: true, + org: 'test-org', + }, + ); + + const jsonOutput = await getFormattedMonitorOutput( + results, + monitorResults, + monitorErrors, + { + path: '/srv', + docker: true, + org: 'test-org', + json: true, + // Feature flag enabled - reverts to legacy behavior + disableContainerMonitorProjectNameFix: true, + } as Options, + ); + + const parsedOutput = JSON.parse(jsonOutput); + + // projectName should be the id (UUID) when feature flag is enabled (legacy escape hatch) + expect(parsedOutput.projectName).toBe( + '7c7305e2-fbcb-44d7-8fbf-8367371c509f', + ); + expect(parsedOutput.projectName).not.toBe('my-custom-project-name'); + }); });