diff --git a/.gitleaksignore b/.gitleaksignore index 3127037fa8..07029d3a13 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -83,6 +83,7 @@ c2de35484dcad696a6ee32f2fa317d5cfaffc133:test/fixtures/code/sample-analyze-folde 4c12242de73be79ebd768468e065790f0b9d23a7:test/jest/unit/lib/iac/drift/fixtures/all.console:aws-access-token:98 25f37b4c609380452b0b96c3853b69e4dc29bb48:test/jest/unit/lib/iac/drift/fixtures/all.console:aws-access-token:98 ccd03cce97470452766ab397f2ba770dbb2e002e:test/jest/unit/lib/iac/drift/fixtures/all.console:aws-access-token:98 +test/jest/acceptance/exitcode.spec.ts:snyk-api-token:50 test/jest/acceptance/instrumentation.spec.ts:snyk-api-token:19 1b65935bc7c69b1029d7c63808af211ae6030c98:test/fixtures/sast/shallow_sast_webgoat/JWTFinalEndpointTest.java:jwt:31 test/jest/acceptance/snyk-code/snyk-code-integration.spec.ts:snyk-api-token:181 diff --git a/cliv2-private/go.mod b/cliv2-private/go.mod index 802da0c5e4..1701e1261f 100644 --- a/cliv2-private/go.mod +++ b/cliv2-private/go.mod @@ -222,7 +222,7 @@ require ( github.com/snyk/container-cli v0.0.0-20260213211631-cd2b2cf8f3ea // indirect github.com/snyk/dep-graph/go v0.0.0-20260127160647-c836da762c62 // indirect github.com/snyk/error-catalog-golang-public v0.0.0-20260806122555-28dc45bbbde6 // indirect - github.com/snyk/go-application-framework v0.16.1 // indirect + github.com/snyk/go-application-framework v0.17.0 // indirect github.com/snyk/go-httpauth v0.0.0-20260810142636-0f6182aaccbc // indirect github.com/snyk/policy-engine v1.1.4 // indirect github.com/snyk/snyk-iac-capture v0.6.5 // indirect diff --git a/cliv2-private/go.sum b/cliv2-private/go.sum index ff4536f446..b8b249935a 100644 --- a/cliv2-private/go.sum +++ b/cliv2-private/go.sum @@ -592,8 +592,8 @@ github.com/snyk/dep-graph/go v0.0.0-20260127160647-c836da762c62 h1:kgZNQ5ztI4+n3 github.com/snyk/dep-graph/go v0.0.0-20260127160647-c836da762c62/go.mod h1:hTr91da/4ze2nk9q6ZW1BmfM2Z8rLUZSEZ3kK+6WGpc= github.com/snyk/error-catalog-golang-public v0.0.0-20260806122555-28dc45bbbde6 h1:XUPFP85nBh+zDCTvxxBuouZP9yG7H1qXZiMGFVMWVKM= github.com/snyk/error-catalog-golang-public v0.0.0-20260806122555-28dc45bbbde6/go.mod h1:0dz+HUR/r7VLlQpLfF0a/F1tdHH84NLTZzCxjZ+Q1nk= -github.com/snyk/go-application-framework v0.16.1 h1:k4eyP4EX/kqnNyu5uuFLEw4wflTom22ea23ZuG74JU8= -github.com/snyk/go-application-framework v0.16.1/go.mod h1:qJBU+FIY8s/lIg0IaKBj7WGeGERiWqyJvhajzxiA3Ls= +github.com/snyk/go-application-framework v0.17.0 h1:wRWHXVSgXRIckYRi1NaJJi7FIKd1og3ZBACfBo7BBLc= +github.com/snyk/go-application-framework v0.17.0/go.mod h1:qJBU+FIY8s/lIg0IaKBj7WGeGERiWqyJvhajzxiA3Ls= github.com/snyk/go-httpauth v0.0.0-20260810142636-0f6182aaccbc h1:tuZVhmJFxS4qJlwYIIIw8xgw3VaVqIR3IAV0WaaFVnI= github.com/snyk/go-httpauth v0.0.0-20260810142636-0f6182aaccbc/go.mod h1:f42qLL7WXOS0od7dXJV/hK3myjms/r6HsXgLrg1HRRY= github.com/snyk/policy-engine v1.1.4 h1:0XpaMpl7ixSk4+dlpHYg2iKEBuv+5Ci+QIcbsmhktao= diff --git a/cliv2/go.mod b/cliv2/go.mod index 0fae5fc906..83a1d5305b 100644 --- a/cliv2/go.mod +++ b/cliv2/go.mod @@ -22,7 +22,7 @@ require ( github.com/snyk/code-client-go v1.31.8 github.com/snyk/container-cli v0.0.0-20260213211631-cd2b2cf8f3ea github.com/snyk/error-catalog-golang-public v0.0.0-20260806122555-28dc45bbbde6 - github.com/snyk/go-application-framework v0.16.1 + github.com/snyk/go-application-framework v0.17.0 github.com/snyk/go-httpauth v0.0.0-20260810142636-0f6182aaccbc github.com/snyk/snyk-iac-capture v0.6.5 github.com/snyk/snyk-ls v0.0.0-20260814164657-a99d4cf1c3e7 diff --git a/cliv2/go.sum b/cliv2/go.sum index 741ff85ef1..c9ab35d65b 100644 --- a/cliv2/go.sum +++ b/cliv2/go.sum @@ -542,8 +542,8 @@ github.com/snyk/dep-graph/go v0.0.0-20260127160647-c836da762c62 h1:kgZNQ5ztI4+n3 github.com/snyk/dep-graph/go v0.0.0-20260127160647-c836da762c62/go.mod h1:hTr91da/4ze2nk9q6ZW1BmfM2Z8rLUZSEZ3kK+6WGpc= github.com/snyk/error-catalog-golang-public v0.0.0-20260806122555-28dc45bbbde6 h1:XUPFP85nBh+zDCTvxxBuouZP9yG7H1qXZiMGFVMWVKM= github.com/snyk/error-catalog-golang-public v0.0.0-20260806122555-28dc45bbbde6/go.mod h1:0dz+HUR/r7VLlQpLfF0a/F1tdHH84NLTZzCxjZ+Q1nk= -github.com/snyk/go-application-framework v0.16.1 h1:k4eyP4EX/kqnNyu5uuFLEw4wflTom22ea23ZuG74JU8= -github.com/snyk/go-application-framework v0.16.1/go.mod h1:qJBU+FIY8s/lIg0IaKBj7WGeGERiWqyJvhajzxiA3Ls= +github.com/snyk/go-application-framework v0.17.0 h1:wRWHXVSgXRIckYRi1NaJJi7FIKd1og3ZBACfBo7BBLc= +github.com/snyk/go-application-framework v0.17.0/go.mod h1:qJBU+FIY8s/lIg0IaKBj7WGeGERiWqyJvhajzxiA3Ls= github.com/snyk/go-httpauth v0.0.0-20260810142636-0f6182aaccbc h1:tuZVhmJFxS4qJlwYIIIw8xgw3VaVqIR3IAV0WaaFVnI= github.com/snyk/go-httpauth v0.0.0-20260810142636-0f6182aaccbc/go.mod h1:f42qLL7WXOS0od7dXJV/hK3myjms/r6HsXgLrg1HRRY= github.com/snyk/policy-engine v1.1.4 h1:0XpaMpl7ixSk4+dlpHYg2iKEBuv+5Ci+QIcbsmhktao= diff --git a/cliv2/pkg/core/configuration.go b/cliv2/pkg/core/configuration.go index da406d85b0..d2553ad662 100644 --- a/cliv2/pkg/core/configuration.go +++ b/cliv2/pkg/core/configuration.go @@ -2,6 +2,7 @@ package core import ( "os" + "strings" "github.com/snyk/go-application-framework/pkg/auth" "github.com/snyk/go-application-framework/pkg/configuration" @@ -27,3 +28,28 @@ func defaultOAuthFF(config configuration.Configuration) configuration.DefaultVal return true, nil } } + +func defaultNetworkRequestRetryAllowedPaths() configuration.DefaultValueFunction { + callback := func(_ configuration.Configuration, existingValue interface{}) (interface{}, error) { + paths := []string{"oauth2/token", "test-dep-graph", "verify/token", "feature_flags/evaluation"} + + if raw, ok := existingValue.(string); ok { + for _, part := range strings.Split(raw, ",") { + if trimmed := strings.TrimSpace(part); trimmed != "" { + paths = append(paths, trimmed) + } + } + } else if strSlice, ok := existingValue.([]string); ok { + paths = append(paths, strSlice...) + } else if ifaceSlice, ok := existingValue.([]interface{}); ok { + for _, v := range ifaceSlice { + if str, ok := v.(string); ok && str != "" { + paths = append(paths, str) + } + } + } + + return paths, nil + } + return callback +} diff --git a/cliv2/pkg/core/main.go b/cliv2/pkg/core/main.go index bd7350c914..0b5377e581 100644 --- a/cliv2/pkg/core/main.go +++ b/cliv2/pkg/core/main.go @@ -603,6 +603,7 @@ func mainWithErrorCode(additionalExts []workflow.ExtensionInit) int { globalConfiguration.AddDefaultValue(configuration.FF_OAUTH_AUTH_FLOW_ENABLED, defaultOAuthFF(globalConfiguration)) globalConfiguration.AddDefaultValue(configuration.FF_TRANSFORMATION_WORKFLOW, configuration.StandardDefaultValueFunction(true)) + globalConfiguration.AddDefaultValue(configuration.NETWORK_REQUEST_RETRY_ALLOWED_PATHS, defaultNetworkRequestRetryAllowedPaths()) if noProxyAuth := globalConfiguration.GetBool(basic_workflows.PROXY_NOAUTH); noProxyAuth { globalConfiguration.Set(configuration.PROXY_AUTHENTICATION_MECHANISM, httpauth.StringFromAuthenticationMechanism(httpauth.NoAuth)) diff --git a/cliv2/pkg/core/main_test.go b/cliv2/pkg/core/main_test.go index 7d5f70b318..11189ae6c0 100644 --- a/cliv2/pkg/core/main_test.go +++ b/cliv2/pkg/core/main_test.go @@ -893,3 +893,46 @@ func testHelpRouter() *helprouting.Router { HasUserDoc: helpDocs.HasUserDoc, } } + +func Test_defaultNetworkRequestRetryAllowedPaths(t *testing.T) { + tests := []struct { + name string + existingValue interface{} + expected interface{} + }{ + {"nil yields CLI defaults", nil, []string{"oauth2/token", "test-dep-graph", "verify/token", "feature_flags/evaluation"}}, + {"csv string splits and merges with defaults", "a,b", []string{"oauth2/token", "test-dep-graph", "verify/token", "feature_flags/evaluation", "a", "b"}}, + {"csv string trims whitespace and merges", "a, b", []string{"oauth2/token", "test-dep-graph", "verify/token", "feature_flags/evaluation", "a", "b"}}, + {"empty string yields just CLI defaults", "", []string{"oauth2/token", "test-dep-graph", "verify/token", "feature_flags/evaluation"}}, + {"non-empty slice merges with defaults", []string{"x"}, []string{"oauth2/token", "test-dep-graph", "verify/token", "feature_flags/evaluation", "x"}}, + {"empty slice yields CLI defaults", []string{}, []string{"oauth2/token", "test-dep-graph", "verify/token", "feature_flags/evaluation"}}, + {"interface slice from JSON merges with defaults", []interface{}{"y", "z"}, []string{"oauth2/token", "test-dep-graph", "verify/token", "feature_flags/evaluation", "y", "z"}}, + } + + config := configuration.NewWithOpts() + defaultFunction := defaultNetworkRequestRetryAllowedPaths() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := defaultFunction(config, tt.existingValue) + + assert.NoError(t, err) + assert.Equal(t, tt.expected, result) + }) + } +} + +func Test_NetworkRequestRetryAllowedPaths_Integration(t *testing.T) { + defer cleanup() + oldArgs := append([]string{}, os.Args...) + os.Args = []string{"snyk", "--version"} + defer func() { os.Args = oldArgs }() + + _ = mainWithErrorCode(nil) + + paths := globalConfiguration.GetStringSlice(configuration.NETWORK_REQUEST_RETRY_ALLOWED_PATHS) + assert.Contains(t, paths, "oauth2/token") + assert.Contains(t, paths, "test-dep-graph") + assert.Contains(t, paths, "verify/token") + assert.Contains(t, paths, "feature_flags/evaluation") +} diff --git a/test/jest/acceptance/exitcode.spec.ts b/test/jest/acceptance/exitcode.spec.ts index 459b7ff996..ea554c4f83 100644 --- a/test/jest/acceptance/exitcode.spec.ts +++ b/test/jest/acceptance/exitcode.spec.ts @@ -1,6 +1,8 @@ import { runSnykCLI } from '../util/runSnykCLI'; import { isWindowsOperatingSystem, describeIf } from '../../utils'; import { EXIT_CODES } from '../../../src/cli/exit-codes'; +import { fakeServer, getFirstIPv4Address } from '../../acceptance/fake-server'; +import { getAvailableServerPort } from '../util/getServerPort'; jest.setTimeout(1000 * 60); @@ -26,10 +28,49 @@ describeIf(notWindows)('exit code behaviour - legacycli', () => { }); describe('exit code behaviour - general', () => { + let server: ReturnType; + let baseEnv: Record; + + beforeAll(async () => { + const ipAddr = getFirstIPv4Address(); + const port = await getAvailableServerPort(process); + const baseApi = '/api/v1'; + + baseEnv = { + ...process.env, + SNYK_API: 'http://' + ipAddr + ':' + port + baseApi, + SNYK_HOST: 'http://' + ipAddr + ':' + port, + SNYK_TOKEN: '123456789', + SNYK_HTTP_PROTOCOL_UPGRADE: '0', + // A configured org skips the CLI's default-org network lookup. Without it, that + // lookup is a GET (a "safe" HTTP method, always eligible for retry regardless of + // any allow-list) which also hits the delayed server below and gets retried + // multiple times, adding tens of seconds before the CLI can exit -- well past + // the watchdog's kill window and past this suite's jest timeout. + SNYK_CFG_ORG: '11111111-1111-1111-1111-111111111111', + }; + + server = fakeServer(baseApi, baseEnv.SNYK_TOKEN); + await server.listenPromise(port); + }); + + afterEach(() => { + server.restore(); + }); + + afterAll(async () => { + await server.closePromise(); + }); + it('Correct exit code when snyk_timeout_secs expires', async () => { + // Response delay exceeds the watchdog's kill window (timeout + grace period), so + // the CLI is always force-killed before any response can arrive -- deterministic + // regardless of how many retries GAF performs underneath. + server.setResponseDelay(10000); + const testEnv = { - ...process.env, - SNYK_TIMEOUT_SECS: '1', + ...baseEnv, + SNYK_TIMEOUT_SECS: '5', }; const { code } = await runSnykCLI(`test --all-projects -d`, { diff --git a/test/jest/acceptance/snyk-code/gaf-retry-allowed-paths.spec.ts b/test/jest/acceptance/snyk-code/gaf-retry-allowed-paths.spec.ts new file mode 100644 index 0000000000..9d44e448e8 --- /dev/null +++ b/test/jest/acceptance/snyk-code/gaf-retry-allowed-paths.spec.ts @@ -0,0 +1,132 @@ +// Verifies that GAF's network-retry middleware actually retries a transient failure on +// /feature_flags/evaluation -- one of the paths cliv2/pkg/core/configuration.go's +// defaultNetworkRequestRetryAllowedPaths() adds back to GAF's default retry-allowed-paths +// list. This endpoint is called directly by the Go binary (config_utils.AddFeatureFlagToConfig +// -> featureflaggateway.EvaluateFlags), with no TypeScript-level retry wrapper, so it cleanly +// isolates GAF's retry behavior from the CLI's own legacy retry loop. +import { runSnykCLI } from '../../util/runSnykCLI'; +import { runCommand } from '../../util/runCommand'; +import { fakeServer } from '../../../acceptance/fake-server'; +import { fakeDeepCodeServer } from '../../../acceptance/deepcode-fake-server'; +import { getServerPort } from '../../util/getServerPort'; +import * as fs from 'fs'; +import * as os from 'os'; +import { join } from 'path'; + +jest.setTimeout(1000 * 60); + +const ORG = '11111111-2222-3333-4444-555555555555'; +const EVALUATION_PATH = `/api/hidden/orgs/${ORG}/feature_flags/evaluation`; + +describe('GAF retry-allowed-paths: feature_flags/evaluation endpoint', () => { + let server: ReturnType; + let deepCodeServer: ReturnType; + let baseEnv: Record; + const port = getServerPort(process); + const baseApi = '/api/v1'; + + beforeAll(async () => { + deepCodeServer = fakeDeepCodeServer(); + await new Promise((resolve) => + deepCodeServer.listen(() => resolve()), + ); + server = fakeServer(baseApi, 'snykToken'); + await new Promise((resolve) => server.listen(port, () => resolve())); + + baseEnv = { + ...process.env, + SNYK_API: `http://localhost:${port}${baseApi}`, + SNYK_HOST: `http://localhost:${port}`, + SNYK_TOKEN: '123456789', + SNYK_CFG_ORG: ORG, + INTERNAL_SNYK_CODE_NATIVE_IMPLEMENTATION: 'true', + // Preview/dev builds force feature flags on locally, bypassing the remote + // evaluation call entirely (cliv2/pkg/core/workflows.go) -- without this override + // the endpoint under test is never even called, and every assertion below is vacuous. + INTERNAL_PREVIEW_FEATURES_ENABLED: 'false', + } as Record; + }); + + afterAll(async () => { + await new Promise((resolve) => deepCodeServer.close(() => resolve())); + await new Promise((resolve) => server.close(() => resolve())); + }); + + function configureServers() { + server.restore(); + deepCodeServer.restore(); + server.setOrgSetting('sast', true); + server.setLocalCodeEngineConfiguration({ + enabled: true, + allowCloudUpload: true, + url: `http://localhost:${deepCodeServer.getPort()}`, + }); + deepCodeServer.setFiltersResponse({ configFiles: [], extensions: ['.js'] }); + deepCodeServer.setSarifResponse({ + $schema: 'https://json.schemastore.org/sarif-2.1.0.json', + version: '2.1.0', + runs: [], + }); + server.setFeatureFlag('clientFileFilterGitignore_MetaCharFix', true); + } + + /** A minimal repo to scan with snyk code test. */ + async function buildFixture(): Promise { + const root = fs.mkdtempSync(join(os.tmpdir(), 'snyk-retry-test-')); + fs.writeFileSync(join(root, 'test.js'), 'const x = 0;\n'); + await runCommand('git', ['init'], { cwd: root }); + await runCommand('git', ['add', '.'], { cwd: root }); + return root; + } + + /** + * snyk code test naturally calls feature_flags/evaluation more than once (once per + * file-filter config variant it evaluates), so a raw hit count can't distinguish a real + * retry from that natural behavior. GAF's retry middleware reuses the same + * Snyk-Request-Id across attempts of the *same* logical request (see the duplicate-id + * check in resilience.spec.ts's "maintenance-window" scenario), so a duplicated id + * among requests to this path is the reliable signal that a retry occurred. + */ + function hasDuplicateRequestId(): boolean { + const ids = server + .getRequests() + .filter((r) => (r.url as string).includes('feature_flags/evaluation')) + .map((r) => { + const header = r.headers?.['snyk-request-id']; + return Array.isArray(header) ? header[0] : header; + }) + .filter(Boolean); + return new Set(ids).size < ids.length; + } + + it('retries feature_flags/evaluation on a transient (500) failure when retries are enabled', async () => { + configureServers(); + server.setEndpointStatusCodes(EVALUATION_PATH, [500, 200]); + const root = await buildFixture(); + + await runSnykCLI(`code test ${root}`, { + env: { + ...baseEnv, + INTERNAL_NETWORK_REQUEST_RETRIES_ENABLED: '1', + SNYK_MAX_ATTEMPTS: '3', + }, + }); + + expect(hasDuplicateRequestId()).toBe(true); + }); + + it('does not retry feature_flags/evaluation when retries are disabled', async () => { + configureServers(); + server.setEndpointStatusCodes(EVALUATION_PATH, [500, 200]); + const root = await buildFixture(); + + await runSnykCLI(`code test ${root}`, { + env: { + ...baseEnv, + INTERNAL_NETWORK_REQUEST_RETRIES_ENABLED: '0', + }, + }); + + expect(hasDuplicateRequestId()).toBe(false); + }); +});