Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitleaksignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cliv2-private/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions cliv2-private/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
2 changes: 1 addition & 1 deletion cliv2/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions cliv2/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
26 changes: 26 additions & 0 deletions cliv2/pkg/core/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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...)
Comment on lines +42 to +43

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty string filtering is inconsistent across input types. The CSV string handler filters empty strings (if trimmed != ""), and the interface slice handler filters them (if str != ""), but the string slice handler does not filter empty strings.

If a user provides []string{"a", "", "b"} via configuration, the empty string will be added to the retry paths, which could cause GAF's retry middleware to incorrectly match all paths (depending on how it handles empty path segments).

Fix:

} else if strSlice, ok := existingValue.([]string); ok {
	for _, s := range strSlice {
		if s != "" {
			paths = append(paths, s)
		}
	}
}

This ensures consistent empty string filtering across all input type handlers.

Suggested change
} else if strSlice, ok := existingValue.([]string); ok {
paths = append(paths, strSlice...)
} else if strSlice, ok := existingValue.([]string); ok {
for _, s := range strSlice {
if s != "" {
paths = append(paths, s)
}
}

Spotted by Graphite

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

} 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
}
1 change: 1 addition & 0 deletions cliv2/pkg/core/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
43 changes: 43 additions & 0 deletions cliv2/pkg/core/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
45 changes: 43 additions & 2 deletions test/jest/acceptance/exitcode.spec.ts
Original file line number Diff line number Diff line change
@@ -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);

Expand All @@ -26,10 +28,49 @@ describeIf(notWindows)('exit code behaviour - legacycli', () => {
});

describe('exit code behaviour - general', () => {
let server: ReturnType<typeof fakeServer>;
let baseEnv: Record<string, string>;

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',
};
Comment on lines 65 to 74

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why this change? I don't think this should be changed.
If the test was failing and this was an attempt to get it to pass, please discard these changes. There is an incident in Slack regarding this, and the failure was due to a rollout of another team.


const { code } = await runSnykCLI(`test --all-projects -d`, {
Expand Down
132 changes: 132 additions & 0 deletions test/jest/acceptance/snyk-code/gaf-retry-allowed-paths.spec.ts
Original file line number Diff line number Diff line change
@@ -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<typeof fakeServer>;
let deepCodeServer: ReturnType<typeof fakeDeepCodeServer>;
let baseEnv: Record<string, string>;
const port = getServerPort(process);
const baseApi = '/api/v1';

beforeAll(async () => {
deepCodeServer = fakeDeepCodeServer();
await new Promise<void>((resolve) =>
deepCodeServer.listen(() => resolve()),
);
server = fakeServer(baseApi, 'snykToken');
await new Promise<void>((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<string, string>;
});

afterAll(async () => {
await new Promise<void>((resolve) => deepCodeServer.close(() => resolve()));
await new Promise<void>((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<string> {
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);
});
});