diff --git a/cliv2/cmd/cliv2/behavior/maperrortoexitcode.go b/cliv2/cmd/cliv2/behavior/maperrortoexitcode.go index eed0d4bd21..96f0b446ed 100644 --- a/cliv2/cmd/cliv2/behavior/maperrortoexitcode.go +++ b/cliv2/cmd/cliv2/behavior/maperrortoexitcode.go @@ -3,6 +3,7 @@ package behavior import ( "github.com/snyk/error-catalog-golang-public/aibom" "github.com/snyk/error-catalog-golang-public/code" + "github.com/snyk/error-catalog-golang-public/snyk" "github.com/snyk/error-catalog-golang-public/snyk_errors" "github.com/snyk/cli/cliv2/internal/constants" @@ -15,6 +16,7 @@ func mapErrorToExitCode(err *snyk_errors.Error, defaultValue int) int { var errorCatalogToExitCodeMap = map[string]int{ code.NewUnsupportedProjectError("").ErrorCode: constants.SNYK_EXIT_CODE_UNSUPPORTED_PROJECTS, aibom.NewNoSupportedFilesError("").ErrorCode: constants.SNYK_EXIT_CODE_UNSUPPORTED_PROJECTS, + snyk.NewMaintenanceWindowError("").ErrorCode: constants.SNYK_EXIT_CODE_EX_TEMPFAIL, // Add new mappings here } diff --git a/cliv2/cmd/cliv2/main.go b/cliv2/cmd/cliv2/main.go index 51697a1cd8..37e4517752 100644 --- a/cliv2/cmd/cliv2/main.go +++ b/cliv2/cmd/cliv2/main.go @@ -528,6 +528,9 @@ func MainWithErrorCode() int { ua := networking.UserAgent(networking.UaWithConfig(globalConfiguration), networking.UaWithRuntimeInfo(rInfo), networking.UaWithOS(internalOS)) networkAccess := globalEngine.GetNetworkAccess() networkAccess.AddErrorHandler(func(err error, ctx context.Context) error { + if err == nil { + return nil + } errorListMutex.Lock() defer errorListMutex.Unlock() @@ -624,18 +627,12 @@ func MainWithErrorCode() int { } if err != nil { - err = decorateError(err) + err, errorList = processError(err, errorList) - errorList = append(errorList, err) for _, tempError := range errorList { - cliAnalytics.AddError(tempError) - } - - err = legacyCLITerminated(err, errorList) - - // ensure to apply exit code mapping based on errors - if exitCode := mapErrorToExitCode(err); exitCode != unsetExitCode { - err = createErrorWithExitCode(exitCode, err) + if tempError != nil { + cliAnalytics.AddError(tempError) + } } } @@ -665,13 +662,32 @@ func MainWithErrorCode() int { return exitCode } -func legacyCLITerminated(err error, errorList []error) error { - exitErr, isExitError := err.(*exec.ExitError) - if isExitError && exitErr.ExitCode() == constants.SNYK_EXIT_CODE_TS_CLI_TERMINATED { +func processError(err error, errorList []error) (error, []error) { + // ensure to use generic fallback error catalog error if no other is available + err = decorateError(err) + + // filter legacycli terminate errors since it is only used for internal purposes + if exitErr, isExitError := err.(*exec.ExitError); isExitError && exitErr.ExitCode() == constants.SNYK_EXIT_CODE_TS_CLI_TERMINATED { + err = nil + } + + // add all errors to analytics + if err != nil { errorList = append([]error{err}, errorList...) + } + + // create a single error from all errors + if len(errorList) == 1 { + err = errorList[0] + } else if len(errorList) > 1 { err = errors.Join(errorList...) } - return err + + // ensure to apply exit code mapping based on errors + if exitCode := mapErrorToExitCode(err); exitCode != unsetExitCode { + err = createErrorWithExitCode(exitCode, err) + } + return err, errorList } func setTimeout(config configuration.Configuration, onTimeout func()) { diff --git a/cliv2/go.mod b/cliv2/go.mod index d8bfc2cdb0..d1b014ba08 100644 --- a/cliv2/go.mod +++ b/cliv2/go.mod @@ -19,7 +19,7 @@ require ( github.com/snyk/cli-extension-sbom v0.0.0-20260109124810-cfdd074f8eeb github.com/snyk/container-cli v0.0.0-20250321132345-1e2e01681dd7 github.com/snyk/error-catalog-golang-public v0.0.0-20260108110943-21ad0c940c14 - github.com/snyk/go-application-framework v0.0.0-20260106115317-a1fb6f13accd + github.com/snyk/go-application-framework v0.0.0-20260114101209-fc9715a5539a github.com/snyk/go-httpauth v0.0.0-20240307114523-1f5ea3f55c65 github.com/snyk/snyk-iac-capture v0.6.5 github.com/snyk/snyk-ls v0.0.0-20260113102244-36303931affc @@ -188,7 +188,7 @@ require ( github.com/shirou/gopsutil v3.21.11+incompatible // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/skeema/knownhosts v1.3.1 // indirect - github.com/snyk/code-client-go v1.24.4 // indirect + github.com/snyk/code-client-go v1.24.5 // indirect github.com/snyk/dep-graph/go v0.0.0-20251219134535-fcb262dc6d25 // indirect github.com/snyk/policy-engine v1.1.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect @@ -227,7 +227,7 @@ require ( go.uber.org/multierr v1.11.0 // indirect golang.org/x/crypto v0.46.0 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect - golang.org/x/net v0.47.0 // indirect + golang.org/x/net v0.48.0 // indirect golang.org/x/oauth2 v0.33.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.39.0 // indirect @@ -249,7 +249,7 @@ require ( // version 2491eb6c1c75 contains a valid license replace github.com/mattn/go-localereader v0.0.1 => github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 -//replace github.com/snyk/go-application-framework => ../../go-application-framework +// replace github.com/snyk/go-application-framework => ../../go-application-framework //replace github.com/snyk/snyk-ls => ../../snyk-ls //replace github.com/snyk/code-client-go => ../../code-client-go diff --git a/cliv2/go.sum b/cliv2/go.sum index 02d0013ee5..ec9d400791 100644 --- a/cliv2/go.sum +++ b/cliv2/go.sum @@ -1310,16 +1310,16 @@ github.com/snyk/cli-extension-os-flows v0.0.0-20260108122334-2d86a741f1f7 h1:njG github.com/snyk/cli-extension-os-flows v0.0.0-20260108122334-2d86a741f1f7/go.mod h1:TWWxoMwavH+jAluWZtaaDbdOuwt8C5n51xnEuWvrv1g= github.com/snyk/cli-extension-sbom v0.0.0-20260109124810-cfdd074f8eeb h1:5cAi3VwdoE4d6kc6D6qSge11e/ALBMmuBatySFd8rfE= github.com/snyk/cli-extension-sbom v0.0.0-20260109124810-cfdd074f8eeb/go.mod h1:jIACVV10j4pW7LFrlYYtjn9mZm2JnXeFBM6/aTNJgvM= -github.com/snyk/code-client-go v1.24.4 h1:19rmeqZFvjQMKaAmSZ0CdYZb1d0ENsDad2Cp32jeWOA= -github.com/snyk/code-client-go v1.24.4/go.mod h1:uMlmMToe4uuNhNLs+yxjM3WFbytna+ytDWhpbnNwTSk= +github.com/snyk/code-client-go v1.24.5 h1:ySsfTeaNmi3nWSuM3YaVIiSAG+H/ikD2hACxqneIZJw= +github.com/snyk/code-client-go v1.24.5/go.mod h1:YYggK3UbOHl5rg7uBhLzsKZBFNavcwFUse/EWCKWdzw= github.com/snyk/container-cli v0.0.0-20250321132345-1e2e01681dd7 h1:/2+2piwQtB9fEJCkXEOjboZjY+77lQfnvqBZ/60xNHk= github.com/snyk/container-cli v0.0.0-20250321132345-1e2e01681dd7/go.mod h1:38w+dcAQp9eG3P5t2eNS9eG0reut10AeJjLv5lJ5lpM= github.com/snyk/dep-graph/go v0.0.0-20251219134535-fcb262dc6d25 h1:dwJ4Kdp4c5aaWI+waHomarhouWF6BUYzfen0B6aqaNA= github.com/snyk/dep-graph/go v0.0.0-20251219134535-fcb262dc6d25/go.mod h1:hTr91da/4ze2nk9q6ZW1BmfM2Z8rLUZSEZ3kK+6WGpc= github.com/snyk/error-catalog-golang-public v0.0.0-20260108110943-21ad0c940c14 h1:R74dgtKtcrIOG/349YDV8arH7D09pob3lAcJc290FqI= github.com/snyk/error-catalog-golang-public v0.0.0-20260108110943-21ad0c940c14/go.mod h1:Ytttq7Pw4vOCu9NtRQaOeDU2dhBYUyNBe6kX4+nIIQ4= -github.com/snyk/go-application-framework v0.0.0-20260106115317-a1fb6f13accd h1:mGFCdZOB+e7CdNbJ1+FezEVW9i7tc4PgC72bMwJmbxU= -github.com/snyk/go-application-framework v0.0.0-20260106115317-a1fb6f13accd/go.mod h1:T+dt4+4XFAJ4PmoGgt/hrx7LiY+vaz+m9V4UYe24Rpc= +github.com/snyk/go-application-framework v0.0.0-20260114101209-fc9715a5539a h1:486sWqs6AAF7LuE1ztTQ9gs5MbVUc8O7VkT4iW6GRxE= +github.com/snyk/go-application-framework v0.0.0-20260114101209-fc9715a5539a/go.mod h1:LPR080GrK2jqNN9/hgVwKkXTVS3BlvwqmTN60lX5wdA= github.com/snyk/go-httpauth v0.0.0-20240307114523-1f5ea3f55c65 h1:CEQuYv0Go6MEyRCD3YjLYM2u3Oxkx8GpCpFBd4rUTUk= github.com/snyk/go-httpauth v0.0.0-20240307114523-1f5ea3f55c65/go.mod h1:88KbbvGYlmLgee4OcQ19yr0bNpXpOr2kciOthaSzCAg= github.com/snyk/policy-engine v1.1.0 h1:vFbFZbs3B0Y3XuGSur5om2meo4JEcCaKfNzshZFGOUs= @@ -1615,8 +1615,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= diff --git a/package-lock.json b/package-lock.json index fcaaf50ef4..c547da8d6c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "@snyk/code-client": "^4.23.5", "@snyk/dep-graph": "^2.10.0", "@snyk/docker-registry-v2-client": "^2.11.0", - "@snyk/error-catalog-nodejs-public": "^5.44.1", + "@snyk/error-catalog-nodejs-public": "^5.72.0", "@snyk/fix": "file:packages/snyk-fix", "@snyk/gemfile": "1.2.0", "@snyk/snyk-cocoapods-plugin": "3.1.0", @@ -2829,7 +2829,9 @@ } }, "node_modules/@snyk/error-catalog-nodejs-public": { - "version": "5.44.1", + "version": "5.72.0", + "resolved": "https://registry.npmjs.org/@snyk/error-catalog-nodejs-public/-/error-catalog-nodejs-public-5.72.0.tgz", + "integrity": "sha512-KJXKJEfTcHe8V+E/3mr0xkk0584llGoZM+HhVlU+kLNsd7OWmUhYT82c81hfYdSLnh/PeP4H6SHOgIhA3ctAfQ==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.8.1", diff --git a/package.json b/package.json index 179be5c84c..1c57add385 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "@snyk/code-client": "^4.23.5", "@snyk/dep-graph": "^2.10.0", "@snyk/docker-registry-v2-client": "^2.11.0", - "@snyk/error-catalog-nodejs-public": "^5.44.1", + "@snyk/error-catalog-nodejs-public": "^5.72.0", "@snyk/fix": "file:packages/snyk-fix", "@snyk/gemfile": "1.2.0", "@snyk/snyk-cocoapods-plugin": "3.1.0", diff --git a/test/acceptance/fake-server.ts b/test/acceptance/fake-server.ts index b5a5fc89a9..9312721a39 100644 --- a/test/acceptance/fake-server.ts +++ b/test/acceptance/fake-server.ts @@ -55,7 +55,11 @@ export type FakeServer = { setSarifResponse: (next: Record) => void; setNextResponse: (r: any) => void; setNextStatusCode: (c: number) => void; - setGlobalResponse: (response: Record, code: number) => void; + setGlobalResponse: ( + response: Record, + code: number, + headers?: Record, + ) => void; setEndpointResponse: ( endpoint: string, @@ -96,6 +100,7 @@ export const fakeServer = (basePath: string, snykToken: string): FakeServer => { let nextResponse: any = undefined; let endpointResponses: Map> = new Map(); let endpointStatusCodes: Map = new Map(); + let endpointHeaders: Map = new Map(); let customResponse: Record | undefined = undefined; let sarifResponse: Record | undefined = undefined; let server: http.Server | undefined = undefined; @@ -108,6 +113,7 @@ export const fakeServer = (basePath: string, snykToken: string): FakeServer => { sarifResponse = undefined; endpointResponses = new Map(); endpointStatusCodes = new Map(); + endpointHeaders = new Map(); featureFlags = featureFlagDefaults(); availableSettings = new Map(); unauthorizedActions = new Map(); @@ -168,9 +174,15 @@ export const fakeServer = (basePath: string, snykToken: string): FakeServer => { const setGlobalResponse = ( response: Record, code: number, + headers?: Record, ) => { endpointResponses.set('*', response); endpointStatusCodes.set('*', code); + if (headers) { + for (const [key, value] of Object.entries(headers)) { + endpointHeaders.set(key, value); + } + } }; const setEndpointResponse = ( @@ -226,6 +238,13 @@ export const fakeServer = (basePath: string, snykToken: string): FakeServer => { endpointStatusCode = endpointStatusCodes.get(endpoint); } + // configure any response headers + if (endpointHeaders.size > 0) { + endpointHeaders.forEach((value, key) => { + res.set(key, value); + }); + } + if (endpointResponse) { res.status(endpointStatusCode || 200); res.send(endpointResponse); diff --git a/test/jest/acceptance/error-catalog.spec.ts b/test/jest/acceptance/error-catalog.spec.ts index d45645f9d5..3b9532d9c7 100644 --- a/test/jest/acceptance/error-catalog.spec.ts +++ b/test/jest/acceptance/error-catalog.spec.ts @@ -7,6 +7,9 @@ import { CLI } from '@snyk/error-catalog-nodejs-public'; const TEST_DISTROLESS_STATIC_IMAGE = 'gcr.io/distroless/static@sha256:7198a357ff3a8ef750b041324873960cf2153c11cc50abb9d8d5f8bb089f6b4e'; +const TEST_WINDOWS_AMD64_IMAGE = + 'mcr.microsoft.com/windows/nanoserver:ltsc2019'; + interface Workflow { type: string; cmd: string; @@ -25,11 +28,20 @@ const integrationWorkflows: Workflow[] = [ type: 'typescript', cmd: 'monitor', }, - { +]; + +// Use a different image for Windows as the distroless image is not available on Windows +if (isWindowsOperatingSystem()) { + integrationWorkflows.push({ + type: 'typescript', + cmd: `container monitor ${TEST_WINDOWS_AMD64_IMAGE}`, + }); +} else { + integrationWorkflows.push({ type: 'typescript', cmd: `container monitor ${TEST_DISTROLESS_STATIC_IMAGE}`, - }, -]; + }); +} const snykOrg = '11111111-2222-3333-4444-555555555555'; @@ -111,7 +123,7 @@ describe.each(integrationWorkflows)( expect(code).toBe(2); expect(errors[0].code).toEqual('500'); - }); + }, 50000); }); }); @@ -138,7 +150,7 @@ describe.each(integrationWorkflows)( }, ); -describe('Special error cases', () => { +describe('special error cases', () => { let server: ReturnType; let env: Record; diff --git a/test/jest/acceptance/https.spec.ts b/test/jest/acceptance/https.spec.ts index 669854fbe9..bf7fe34b31 100644 --- a/test/jest/acceptance/https.spec.ts +++ b/test/jest/acceptance/https.spec.ts @@ -8,9 +8,13 @@ import { createProjectFromWorkspace } from '../util/createProject'; import { getFixturePath } from '../util/getFixturePath'; import { runSnykCLI } from '../util/runSnykCLI'; import { getServerPort } from '../util/getServerPort'; +import { Snyk } from '@snyk/error-catalog-nodejs-public'; +import { EXIT_CODES } from '../../../src/cli/exit-codes'; jest.setTimeout(1000 * 30); +const snykOrg = '11111111-2222-3333-4444-555555555555'; + describe('https', () => { let server: FakeServer; let env: Record; @@ -79,3 +83,112 @@ describe('https', () => { }); }); }); + +describe('network', () => { + let server: ReturnType; + let env: Record; + + beforeAll(async () => { + const ipAddr = getFirstIPv4Address(); + const port = getServerPort(process); + const baseApi = '/api/v1'; + + env = { + ...process.env, + SNYK_API: 'http://' + ipAddr + ':' + port + baseApi, + SNYK_TOKEN: '123456789', + SNYK_HTTP_PROTOCOL_UPGRADE: '0', + SNYK_CFG_ORG: snykOrg, + INTERNAL_NETWORK_REQUEST_MAX_ATTEMPTS: '1', // reduce test duration by reducing retry + INTERNAL_NETWORK_REQUEST_RETRY_AFTER_SECONDS: '1', // reduce test duration by reducing retry + }; + server = fakeServer(baseApi, env.SNYK_TOKEN); + await new Promise((resolve) => { + server.listen(port, () => { + resolve(); + }); + }); + }); + + afterEach(async () => { + server.restore(); + }); + + afterAll(async () => { + await new Promise((resolve) => { + server.close(() => { + resolve(); + }); + }); + }); + + describe('retries', () => { + it('respects max attempts', async () => { + const errorResponse = { + jsonapi: { version: '1.0' }, + errors: [new Snyk.ServerError('').toJsonApiErrorObject()], + description: 'Internal server error', + }; + server.setGlobalResponse( + errorResponse, + parseInt(errorResponse['errors'][0].status), + { 'retry-after': '1' }, + ); + await runSnykCLI(`test`, { + env: { + ...env, + INTERNAL_NETWORK_REQUEST_MAX_ATTEMPTS: '2', + }, + }); + + const requests = server.getRequests(); + const actualNetorkAttempts = requests.filter( + (r) => r.url.includes('/test-dep-graph') || r.url.includes('/vuln/'), + ).length; + + expect(actualNetorkAttempts).toBe(2); + }); + + describe('maintenance error [SNYK-0099]', () => { + const maintenanceErrorRes = { + jsonapi: { version: '1.0' }, + errors: [new Snyk.MaintenanceWindowError('').toJsonApiErrorObject()], + description: 'Maintenance window', + }; + + beforeEach(() => { + server.setGlobalResponse( + maintenanceErrorRes, + parseInt(maintenanceErrorRes.errors[0].status), + ); + }); + + it('does not attempt any retries', async () => { + await runSnykCLI(`test -d --log-level=trace`, { + env: { + ...env, + // apply a user configured attempts of 10 + INTERNAL_NETWORK_REQUEST_MAX_ATTEMPTS: '10', + }, + }); + + // Count how many times an endpoint was hit + const requests = server.getRequests(); + const actualNetworkAttempts = requests.filter( + (r) => r.url.includes('/test-dep-graph') || r.url.includes('/vuln/'), + ).length; + + expect(actualNetworkAttempts).toBe(1); + }); + + it('returns correct exit code', async () => { + const { code, stdout } = await runSnykCLI(`test`, { + env, + }); + + expect(stdout).toContain(maintenanceErrorRes['errors'][0].code); + expect(code).toEqual(EXIT_CODES.EX_TEMPFAIL); + }); + }); + }); +});