From 6befdbd8e70fb53f9bdf7c4eb97510d8779915d0 Mon Sep 17 00:00:00 2001 From: Felix Sargent Date: Tue, 16 Jun 2026 11:46:31 +0100 Subject: [PATCH 1/2] feat: support Snyk Code Local Engine on the native code-client-go path The native implementation was explicitly disabled for SCLE orgs. Three things were missing for it to work; this commit addresses all of them. 1. URL: codeClientConfig.SnykCodeApi now returns localCodeEngine.url from the cached SAST settings when SCLE is enabled, instead of the naive api->deeproxy string replacement that ignored it. 2. Auth: register the local engine URL in AUTHENTICATION_ADDITIONAL_URLS so the networking stack attaches credentials (including OAuth2 tokens) to the custom SCLE host, which otherwise matches neither the API host nor the "deeproxy" subdomain and would be called unauthenticated. 3. API surface: SCLE implements the old deeproxy API, not the new test service. Route SCLE scans through the legacy scanner (UploadAndAnalyzeLegacy / RunLegacyTest) instead of UploadAndAnalyze, which the local engine does not implement. --report is not supported for SCLE (reporting goes through the test service); it now errors early instead of silently using the test-service path. Also drop the '&& !scleEnabled' guard so SCLE flows use the native path when the native/consistent-ignores feature flags are on, and centralize the SAST settings / SLCE config keys in the code_workflow package. This is the functional-parity prerequisite for removing the TypeScript @snyk/code-client dependency from the CLI, and closes the OAuth2-on-SCLE failure tracked in CLI-589. Refs: COIN-2539, CLI-589 --- .../code_workflow/code_client_helper.go | 20 ++++++ .../code_workflow/code_client_helper_test.go | 44 +++++++++++++ .../commands/code_workflow/native_workflow.go | 45 +++++++++++++ .../code_workflow/native_workflow_test.go | 17 +++++ pkg/code/code.go | 43 +++++++++++- pkg/code/code_test.go | 65 +++++++++++++++++-- 6 files changed, 227 insertions(+), 7 deletions(-) diff --git a/internal/commands/code_workflow/code_client_helper.go b/internal/commands/code_workflow/code_client_helper.go index d3f043a..2fdbb1f 100644 --- a/internal/commands/code_workflow/code_client_helper.go +++ b/internal/commands/code_workflow/code_client_helper.go @@ -6,6 +6,8 @@ import ( "time" "github.com/snyk/go-application-framework/pkg/configuration" + + "github.com/snyk/code-client-go/pkg/code/sast_contract" ) var defaultSnykCodeTimeout = 12 * time.Hour @@ -23,9 +25,27 @@ func (c *codeClientConfig) IsFedramp() bool { } func (c *codeClientConfig) SnykCodeApi() string { + // When Snyk Code Local Engine (SCLE) is enabled, requests must go to the + // local engine endpoint advertised in the SAST settings rather than the + // cloud deeproxy host derived from the API URL. + if c.localConfiguration.GetBool(ConfigurationSlceEnabled) { + if localEngineURL := c.localCodeEngineURL(); localEngineURL != "" { + return localEngineURL + } + } return strings.ReplaceAll(c.localConfiguration.GetString(configuration.API_URL), "api", "deeproxy") } +// localCodeEngineURL returns the Snyk Code Local Engine URL from the cached SAST +// settings, or an empty string if the settings are unavailable or unset. +func (c *codeClientConfig) localCodeEngineURL() string { + settings, ok := c.localConfiguration.Get(ConfigurationSastSettings).(*sast_contract.SastResponse) + if !ok || settings == nil { + return "" + } + return settings.LocalCodeEngine.Url +} + func (c *codeClientConfig) SnykApi() string { return c.localConfiguration.GetString(configuration.API_URL) } diff --git a/internal/commands/code_workflow/code_client_helper_test.go b/internal/commands/code_workflow/code_client_helper_test.go index 4aca528..b86b40c 100644 --- a/internal/commands/code_workflow/code_client_helper_test.go +++ b/internal/commands/code_workflow/code_client_helper_test.go @@ -6,8 +6,52 @@ import ( "github.com/stretchr/testify/assert" "github.com/snyk/go-application-framework/pkg/configuration" + + "github.com/snyk/code-client-go/pkg/code/sast_contract" ) +func Test_SnykCodeApi(t *testing.T) { + t.Run("derives deeproxy host from API URL when SCLE is disabled", func(t *testing.T) { + config := configuration.NewWithOpts() + config.Set(configuration.API_URL, "https://api.snyk.io") + c := &codeClientConfig{localConfiguration: config} + assert.Equal(t, "https://deeproxy.snyk.io", c.SnykCodeApi()) + }) + + t.Run("returns the local engine URL when SCLE is enabled", func(t *testing.T) { + config := configuration.NewWithOpts() + config.Set(configuration.API_URL, "https://api.snyk.io") + config.Set(ConfigurationSlceEnabled, true) + config.Set(ConfigurationSastSettings, &sast_contract.SastResponse{ + LocalCodeEngine: sast_contract.LocalCodeEngine{ + Enabled: true, + Url: "https://local-engine.example.com", + }, + }) + c := &codeClientConfig{localConfiguration: config} + assert.Equal(t, "https://local-engine.example.com", c.SnykCodeApi()) + }) + + t.Run("falls back to deeproxy host when SCLE is enabled but the URL is empty", func(t *testing.T) { + config := configuration.NewWithOpts() + config.Set(configuration.API_URL, "https://api.snyk.io") + config.Set(ConfigurationSlceEnabled, true) + config.Set(ConfigurationSastSettings, &sast_contract.SastResponse{ + LocalCodeEngine: sast_contract.LocalCodeEngine{Enabled: true}, + }) + c := &codeClientConfig{localConfiguration: config} + assert.Equal(t, "https://deeproxy.snyk.io", c.SnykCodeApi()) + }) + + t.Run("falls back to deeproxy host when SCLE is enabled but settings are missing", func(t *testing.T) { + config := configuration.NewWithOpts() + config.Set(configuration.API_URL, "https://api.snyk.io") + config.Set(ConfigurationSlceEnabled, true) + c := &codeClientConfig{localConfiguration: config} + assert.Equal(t, "https://deeproxy.snyk.io", c.SnykCodeApi()) + }) +} + func Test_GetReportType(t *testing.T) { t.Run("no repport", func(t *testing.T) { config := configuration.NewWithOpts() diff --git a/internal/commands/code_workflow/native_workflow.go b/internal/commands/code_workflow/native_workflow.go index 82c0b22..a9a69a5 100644 --- a/internal/commands/code_workflow/native_workflow.go +++ b/internal/commands/code_workflow/native_workflow.go @@ -39,6 +39,8 @@ const ( ConfigurationTargetReference = "target-reference" ConfigurationProjectId = "project-id" ConfigurationCommitId = "commit-id" + ConfigurationSastSettings = "internal_sast_settings" + ConfigurationSlceEnabled = "internal_snyk_scle_enabled" MetadataBundleHash = "Snyk-Bundle-Hash" ) @@ -204,6 +206,12 @@ func defaultAnalyzeFunction(ctx context.Context, path string, httpClientFunc fun return nil, "", nil, err } + // Reporting goes through the test service, which Snyk Code Local Engine does + // not implement, so --report is not supported for SCLE. + if reportMode != noReport && config.GetBool(ConfigurationSlceEnabled) { + return nil, "", nil, errors.New("--report is not supported with Snyk Code Local Engine") + } + httpClient := codeclienthttp.NewHTTPClient( httpClientFunc, codeclienthttp.WithLogger(logger), @@ -276,11 +284,48 @@ func defaultAnalyzeFunction(ctx context.Context, path string, httpClientFunc fun changedFiles := make(map[string]bool) var bundleHash string + // Snyk Code Local Engine implements the old deeproxy API rather than the new + // test service, so SCLE scans must use the legacy ("deeproxy") scanner. + if config.GetBool(ConfigurationSlceEnabled) { + return analyzeWithLegacyEngine(ctx, codeScanner, requestId, target, files, changedFiles, logger) + } + result, bundleHash, resultMetaData, err = codeScanner.UploadAndAnalyzeWithOptions(ctx, requestId, target, files, changedFiles, analysisOptions...) return result, bundleHash, resultMetaData, err } +// analyzeWithLegacyEngine runs the deeproxy-based ("legacy") scanner, which is +// the API surface that Snyk Code Local Engine implements. The legacy scanner +// streams progress over a status channel, so it is drained concurrently to keep +// the analysis goroutine from blocking on send. +// +// Its return signature mirrors the OptionalAnalysisFunctions contract; the +// ResultMetaData is always nil because the legacy/deeproxy API returns no +// test-service metadata (web UI URL, project/snapshot id) — there is none to +// surface for an SCLE scan. +func analyzeWithLegacyEngine( + ctx context.Context, + codeScanner codeclient.CodeScanner, + requestId string, + target scan.Target, + files <-chan string, + changedFiles map[string]bool, + logger *zerolog.Logger, +) (*sarif.SarifResponse, string, *scan.ResultMetaData, error) { + statusChannel := make(chan scan.LegacyScanStatus) + go func() { + for status := range statusChannel { + logger.Trace().Msgf("Snyk Code Local Engine analysis status: %s", status.Message) + } + }() + + // shardKey is used by deeproxy only for cache sharding and is optional here. + const shardKey = "" + result, bundleHash, err := codeScanner.UploadAndAnalyzeLegacy(ctx, requestId, target, shardKey, files, changedFiles, statusChannel) + return result, bundleHash, nil, err +} + func determineAnalyzeInput(path string, config configuration.Configuration, logger *zerolog.Logger) (scan.Target, <-chan string, error) { var files <-chan string diff --git a/internal/commands/code_workflow/native_workflow_test.go b/internal/commands/code_workflow/native_workflow_test.go index 91d73f3..05da5a7 100644 --- a/internal/commands/code_workflow/native_workflow_test.go +++ b/internal/commands/code_workflow/native_workflow_test.go @@ -1,6 +1,7 @@ package code_workflow import ( + "context" "net/http" "net/http/httptest" "os" @@ -15,6 +16,22 @@ import ( "github.com/snyk/go-application-framework/pkg/networking" ) +func Test_defaultAnalyzeFunction_reportNotSupportedWithSCLE(t *testing.T) { + logger := zerolog.Nop() + + t.Run("errors when --report is requested for an SCLE org", func(t *testing.T) { + config := configuration.NewWithOpts() + config.Set(ConfigurationReportFlag, true) + config.Set(ConfigurationProjectName, "my-project") // makes report mode localCode + config.Set(ConfigurationSlceEnabled, true) + + _, _, _, err := defaultAnalyzeFunction(context.Background(), t.TempDir(), nil, &logger, config, nil) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "Snyk Code Local Engine") + }) +} + func writeFile(t *testing.T, filename string) { t.Helper() err := os.WriteFile(filename, []byte("hello"), 0644) diff --git a/pkg/code/code.go b/pkg/code/code.go index 04f7dae..b236a76 100644 --- a/pkg/code/code.go +++ b/pkg/code/code.go @@ -26,8 +26,8 @@ const ( const ( ConfigurationSastEnabled = "internal_sast_enabled" - ConfigurationSastSettings = "internal_sast_settings" - ConfigurarionSlceEnabled = "internal_snyk_scle_enabled" + ConfigurationSastSettings = code_workflow.ConfigurationSastSettings + ConfigurarionSlceEnabled = code_workflow.ConfigurationSlceEnabled FfNameNativeImplementation = "snykCodeClientNativeImplementation" ) @@ -166,7 +166,10 @@ func useNativeImplementation(config configuration.Configuration, logger *zerolog reportEnabled := config.GetBool(code_workflow.ConfigurationReportFlag) scleEnabled := config.GetBool(ConfigurarionSlceEnabled) - nativeImplementationEnabled := (useConsistentIgnoresFF || useNativeImplementationFF) && !scleEnabled + // SCLE no longer forces the legacy path: code-client-go honors the local + // engine URL from SAST settings (see codeClientConfig.SnykCodeApi), so the + // native implementation supports SCLE flows directly. + nativeImplementationEnabled := useConsistentIgnoresFF || useNativeImplementationFF logger.Debug().Msgf("SAST Enabled: %v", sastEnabled) logger.Debug().Msgf("Report enabled: %v", reportEnabled) @@ -224,6 +227,7 @@ func codeWorkflowEntryPoint(invocationCtx workflow.InvocationContext, _ []workfl logger.Debug().Msgf("Implementation: %s", implementationName) if nativeImplementation { + registerLocalEngineAuthURL(config, logger) result, err = code_workflow.EntryPointNative(invocationCtx) } else { result, err = code_workflow.EntryPointLegacy(invocationCtx) @@ -233,3 +237,36 @@ func codeWorkflowEntryPoint(invocationCtx workflow.InvocationContext, _ []workfl return result, err } + +// registerLocalEngineAuthURL ensures the Snyk Code Local Engine (SCLE) URL is +// treated as an authenticated Snyk endpoint. The networking stack only attaches +// credentials (including OAuth2 tokens) to the configured API host, its +// "deeproxy" subdomain, or URLs in AUTHENTICATION_ADDITIONAL_URLS. SCLE serves +// the deeproxy API from a customer-hosted URL that matches none of those, so it +// must be registered explicitly or requests would be sent unauthenticated. +func registerLocalEngineAuthURL(config configuration.Configuration, logger *zerolog.Logger) { + if !config.GetBool(ConfigurarionSlceEnabled) { + return + } + + sastResponse, err := getSastResponseFromConfig(config) + if err != nil { + logger.Debug().Err(err).Msg("Unable to read SAST settings to register the local engine auth URL.") + return + } + + localEngineURL := sastResponse.LocalCodeEngine.Url + if localEngineURL == "" { + return + } + + existing := config.GetStringSlice(configuration.AUTHENTICATION_ADDITIONAL_URLS) + for _, u := range existing { + if u == localEngineURL { + return + } + } + + config.Set(configuration.AUTHENTICATION_ADDITIONAL_URLS, append(existing, localEngineURL)) + logger.Debug().Msgf("Registered Snyk Code Local Engine URL for authentication: %s", localEngineURL) +} diff --git a/pkg/code/code_test.go b/pkg/code/code_test.go index c2d5988..578781e 100644 --- a/pkg/code/code_test.go +++ b/pkg/code/code_test.go @@ -52,15 +52,17 @@ func Test_Code_entrypoint(t *testing.T) { sastSettings := &sast_contract.SastResponse{ SastEnabled: true, LocalCodeEngine: sast_contract.LocalCodeEngine{ - Enabled: true, /* ensures that legacycli will be called */ + Enabled: true, }, } err := json.NewEncoder(w).Encode(sastSettings) assert.NoError(t, err) } else if strings.Contains(r.URL.String(), "/v1/cli-config/feature-flags/") { + // Disable the native-implementation feature flags so the workflow + // dispatches to legacycli (the path exercised by this test). featureFlag := OrgFeatureFlagResponse{ - Ok: true, + Ok: false, } err := json.NewEncoder(w).Encode(featureFlag) @@ -513,8 +515,8 @@ func Test_Code_UseNativeImplementation(t *testing.T) { assert.Equal(t, expected, actual) }) - t.Run("cci feature flag enabled, native implementation enabled but scle enabled", func(t *testing.T) { - expected := false + t.Run("scle enabled no longer forces the legacy implementation", func(t *testing.T) { + expected := true config := configuration.NewWithOpts() config.Set(configuration.FF_CODE_CONSISTENT_IGNORES, true) config.Set(configuration.FF_CODE_NATIVE_IMPLEMENTATION, true) @@ -522,6 +524,61 @@ func Test_Code_UseNativeImplementation(t *testing.T) { actual := useNativeImplementation(config, &logger, true) assert.Equal(t, expected, actual) }) + + t.Run("scle enabled with no native feature flags stays on legacy", func(t *testing.T) { + expected := false + config := configuration.NewWithOpts() + config.Set(configuration.FF_CODE_CONSISTENT_IGNORES, false) + config.Set(configuration.FF_CODE_NATIVE_IMPLEMENTATION, false) + config.Set(ConfigurarionSlceEnabled, true) + actual := useNativeImplementation(config, &logger, true) + assert.Equal(t, expected, actual) + }) +} + +func Test_registerLocalEngineAuthURL(t *testing.T) { + logger := zerolog.Nop() + + withSettings := func(slceEnabled bool, url string) configuration.Configuration { + config := configuration.NewWithOpts() + config.Set(ConfigurarionSlceEnabled, slceEnabled) + config.Set(ConfigurationSastSettings, &sast_contract.SastResponse{ + LocalCodeEngine: sast_contract.LocalCodeEngine{Enabled: slceEnabled, Url: url}, + }) + return config + } + + t.Run("registers the local engine URL when SCLE is enabled", func(t *testing.T) { + config := withSettings(true, "https://scle.example.internal") + registerLocalEngineAuthURL(config, &logger) + assert.Equal(t, []string{"https://scle.example.internal"}, config.GetStringSlice(configuration.AUTHENTICATION_ADDITIONAL_URLS)) + }) + + t.Run("does nothing when SCLE is disabled", func(t *testing.T) { + config := withSettings(false, "https://scle.example.internal") + registerLocalEngineAuthURL(config, &logger) + assert.Empty(t, config.GetStringSlice(configuration.AUTHENTICATION_ADDITIONAL_URLS)) + }) + + t.Run("does nothing when the local engine URL is empty", func(t *testing.T) { + config := withSettings(true, "") + registerLocalEngineAuthURL(config, &logger) + assert.Empty(t, config.GetStringSlice(configuration.AUTHENTICATION_ADDITIONAL_URLS)) + }) + + t.Run("does not duplicate an already-registered URL", func(t *testing.T) { + config := withSettings(true, "https://scle.example.internal") + config.Set(configuration.AUTHENTICATION_ADDITIONAL_URLS, []string{"https://scle.example.internal"}) + registerLocalEngineAuthURL(config, &logger) + assert.Equal(t, []string{"https://scle.example.internal"}, config.GetStringSlice(configuration.AUTHENTICATION_ADDITIONAL_URLS)) + }) + + t.Run("preserves existing additional auth URLs", func(t *testing.T) { + config := withSettings(true, "https://scle.example.internal") + config.Set(configuration.AUTHENTICATION_ADDITIONAL_URLS, []string{"https://other.example.internal"}) + registerLocalEngineAuthURL(config, &logger) + assert.Equal(t, []string{"https://other.example.internal", "https://scle.example.internal"}, config.GetStringSlice(configuration.AUTHENTICATION_ADDITIONAL_URLS)) + }) } // setupMockEngine creates a mock engine with basic expectations From 7b0174deb79101546e28198571878088bdb7d5d0 Mon Sep 17 00:00:00 2001 From: Nasir Amin Date: Mon, 22 Jun 2026 12:53:25 +0100 Subject: [PATCH 2/2] feat: add --discover-sanitisers flag on code test Wire the flag through the native workflow and test-service poll path; surface Appendix-A candidates as human or JSON output. Reject SCLE and --report combinations (test-service-only features). Share poll/create helpers with the standard scan path. --- go.mod | 6 +- go.sum | 12 +- internal/analysis/analysis.go | 280 +++++++++++------- internal/analysis/analysis_discover_test.go | 218 ++++++++++++++ internal/analysis/analysis_private_test.go | 5 +- internal/analysis/mocks/analysis.go | 16 + internal/analysis/sanitizers/decode.go | 77 +++++ internal/analysis/sanitizers/decode_test.go | 87 ++++++ internal/analysis/sanitizers/model.go | 68 +++++ internal/analysis/sanitizers/render.go | 64 ++++ internal/analysis/sanitizers/render_test.go | 57 ++++ internal/api/test/2025-04-07/helper.go | 12 + .../api/test/2025-04-07/models/components.go | 5 +- .../test/2025-04-07/models/components.yaml | 1 + internal/api/test/2025-04-07/models/tests.go | 14 +- .../api/test/2025-04-07/models/tests.yaml | 2 + .../code_workflow/code_client_helper.go | 14 + .../code_workflow/code_client_helper_test.go | 37 +++ .../commands/code_workflow/native_workflow.go | 137 ++++++++- pkg/code/code.go | 1 + pkg/code/discover_flag_test.go | 32 ++ scan.go | 31 ++ 22 files changed, 1045 insertions(+), 131 deletions(-) create mode 100644 internal/analysis/analysis_discover_test.go create mode 100644 internal/analysis/sanitizers/decode.go create mode 100644 internal/analysis/sanitizers/decode_test.go create mode 100644 internal/analysis/sanitizers/model.go create mode 100644 internal/analysis/sanitizers/render.go create mode 100644 internal/analysis/sanitizers/render_test.go create mode 100644 pkg/code/discover_flag_test.go diff --git a/go.mod b/go.mod index a41ffb6..088f1ef 100644 --- a/go.mod +++ b/go.mod @@ -102,14 +102,14 @@ require ( go.uber.org/multierr v1.9.0 // indirect golang.org/x/crypto v0.52.0 // indirect golang.org/x/mod v0.35.0 // indirect - golang.org/x/oauth2 v0.27.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect golang.org/x/tools v0.44.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250404141209-ee84b53bf3d0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/grpc v1.71.0 // indirect - google.golang.org/protobuf v1.36.6 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index cc27d7e..7a1dcc1 100644 --- a/go.sum +++ b/go.sum @@ -348,8 +348,8 @@ golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su golang.org/x/net v0.0.0-20220725212005-46097bf591d3/go.mod h1:AaygXjzTFtRAg2ttMY5RMuhpJ3cNnI0XpyFJD1iQRSM= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= -golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -403,8 +403,8 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250404141209-ee84b53bf3d0 h1:0K7wTWyzxZ7J+L47+LbFogJW1nn/gnnMCN0vGXNYtTI= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250404141209-ee84b53bf3d0/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= @@ -415,8 +415,8 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/internal/analysis/analysis.go b/internal/analysis/analysis.go index 0449c20..edda8e3 100644 --- a/internal/analysis/analysis.go +++ b/internal/analysis/analysis.go @@ -36,6 +36,7 @@ import ( "github.com/snyk/code-client-go/bundle" "github.com/snyk/code-client-go/config" codeClientHTTP "github.com/snyk/code-client-go/http" + "github.com/snyk/code-client-go/internal/analysis/sanitizers" testApi "github.com/snyk/code-client-go/internal/api/test/2025-04-07" testModels "github.com/snyk/code-client-go/internal/api/test/2025-04-07/models" "github.com/snyk/code-client-go/observability" @@ -49,6 +50,7 @@ import ( type AnalysisOrchestrator interface { RunTest(ctx context.Context, orgId string, b bundle.Bundle, target scan.Target, reportingOptions AnalysisConfig) (*sarif.SarifResponse, *scan.ResultMetaData, error) RunTestRemote(ctx context.Context, orgId string, reportingOptions AnalysisConfig) (*sarif.SarifResponse, *scan.ResultMetaData, error) + RunDiscoverTest(ctx context.Context, orgId string, b bundle.Bundle, target scan.Target) (*sanitizers.Document, error) RunLegacyTest(ctx context.Context, bundleHash string, shardKey string, limitToFiles []string, severity int) (*sarif.SarifResponse, scan.LegacyScanStatus, error) } @@ -183,74 +185,75 @@ func (a *analysisOrchestrator) host(isHidden bool) string { return fmt.Sprintf("%s/%s", apiUrl, path) } +func repoTargetFields(target scan.Target) (commitId, repoUrl, branchName *string) { + if repoTarget, ok := target.(*scan.RepositoryTarget); ok { + if u := repoTarget.GetRepositoryUrl(); len(u) > 0 { + repoUrl = &u + } + if c := repoTarget.GetCommitId(); len(c) > 0 { + commitId = &c + } + if b := repoTarget.GetBranchName(); len(b) > 0 { + branchName = &b + } + } + return +} + func (a *analysisOrchestrator) createTestAndGetResults(ctx context.Context, orgId string, body *testApi.CreateTestApplicationVndAPIPlusJSONRequestBody, progressString string) (*sarif.SarifResponse, *scan.ResultMetaData, error) { tracker := a.trackerFactory.GenerateTracker() tracker.Begin(progressString, "Retrieving results...") - innerFunction := func() (*sarif.SarifResponse, *scan.ResultMetaData, error) { - params := testApi.CreateTestParams{Version: testApi.ApiVersion} - orgUuid := uuid.MustParse(orgId) - host := a.host(true) + client, orgUuid, testId, err := a.submitTest(ctx, orgId, body) + if err != nil { + tracker.End("Analysis failed.") + return nil, nil, err + } - client, err := testApi.NewClient(host, testApi.WithHTTPClient(a.httpClient)) - if err != nil { - return nil, nil, err - } + result, metadata, err := a.pollTestForFindings(ctx, client, orgUuid, testId) + if err != nil { + tracker.End("Analysis failed.") + return nil, nil, err + } - // create test - resp, err := client.CreateTestWithApplicationVndAPIPlusJSONBody(ctx, orgUuid, ¶ms, *body) - if err != nil { - return nil, nil, err - } + tracker.End("Analysis completed.") + return result, metadata, nil +} - parsedResponse, err := testApi.ParseCreateTestResponse(resp) - defer func() { - closeErr := resp.Body.Close() - if closeErr != nil { - a.logger.Err(closeErr).Msg("failed to close response body") - } - }() - if err != nil { - a.logger.Debug().Msg(err.Error()) - return nil, nil, err - } +func (a *analysisOrchestrator) submitTest(ctx context.Context, orgId string, body *testApi.CreateTestApplicationVndAPIPlusJSONRequestBody) (*testApi.Client, uuid.UUID, openapi_types.UUID, error) { + params := testApi.CreateTestParams{Version: testApi.ApiVersion} + orgUuid := uuid.MustParse(orgId) - switch parsedResponse.StatusCode() { - case http.StatusCreated: - // poll results - return a.pollTestForFindings(ctx, client, orgUuid, parsedResponse.ApplicationvndApiJSON201.Data.Id) - } - return nil, nil, nil + client, err := testApi.NewClient(a.host(true), testApi.WithHTTPClient(a.httpClient)) + if err != nil { + return nil, uuid.UUID{}, openapi_types.UUID{}, err } - result, metadata, err := innerFunction() + resp, err := client.CreateTestWithApplicationVndAPIPlusJSONBody(ctx, orgUuid, ¶ms, *body) if err != nil { - tracker.End("Analysis failed.") - } else { - tracker.End("Analysis completed.") + return nil, uuid.UUID{}, openapi_types.UUID{}, err } - return result, metadata, err + parsedResponse, err := testApi.ParseCreateTestResponse(resp) + defer func() { + closeErr := resp.Body.Close() + if closeErr != nil { + a.logger.Err(closeErr).Msg("failed to close response body") + } + }() + if err != nil { + return nil, uuid.UUID{}, openapi_types.UUID{}, err + } + + if parsedResponse.StatusCode() != http.StatusCreated { + return nil, uuid.UUID{}, openapi_types.UUID{}, fmt.Errorf("create test: unexpected status %d", parsedResponse.StatusCode()) + } + + return client, orgUuid, parsedResponse.ApplicationvndApiJSON201.Data.Id, nil } func (a *analysisOrchestrator) RunTest(ctx context.Context, orgId string, b bundle.Bundle, target scan.Target, reportingConfig AnalysisConfig) (*sarif.SarifResponse, *scan.ResultMetaData, error) { - var commitId *string = nil - var repoUrl *string = nil - var branchName *string = nil - if repoTarget, ok := target.(*scan.RepositoryTarget); ok { - tmpRepoUrl := repoTarget.GetRepositoryUrl() - if len(tmpRepoUrl) > 0 { - repoUrl = &tmpRepoUrl - } - tmpCommitId := repoTarget.GetCommitId() - if len(tmpCommitId) > 0 { - commitId = &tmpCommitId - } - tmpBranchName := repoTarget.GetBranchName() - if len(tmpBranchName) > 0 { - branchName = &tmpBranchName - } - } + commitId, repoUrl, branchName := repoTargetFields(target) body := testApi.NewCreateTestApplicationBody( testApi.WithInputBundle(b.GetBundleHash(), target.GetPath(), repoUrl, b.GetLimitToFiles(), commitId, branchName), @@ -281,8 +284,35 @@ func (a *analysisOrchestrator) RunTestRemote(ctx context.Context, orgId string, return a.createTestAndGetResults(ctx, orgId, body, "Snyk Code analysis for remote project") } -func (a *analysisOrchestrator) pollTestForFindings(ctx context.Context, client *testApi.Client, org uuid.UUID, testId openapi_types.UUID) (*sarif.SarifResponse, *scan.ResultMetaData, error) { - method := "analysis.pollTestForFindings" +func (a *analysisOrchestrator) RunDiscoverTest(ctx context.Context, orgId string, b bundle.Bundle, target scan.Target) (*sanitizers.Document, error) { + commitId, repoUrl, branchName := repoTargetFields(target) + + body := testApi.NewCreateTestApplicationBody( + testApi.WithInputBundle(b.GetBundleHash(), target.GetPath(), repoUrl, b.GetLimitToFiles(), commitId, branchName), + testApi.WithDiscoverScanConfig(), + ) + + tracker := a.trackerFactory.GenerateTracker() + tracker.Begin("Custom-sanitizer discovery for "+target.GetPath(), "Retrieving results...") + + client, orgUuid, testId, err := a.submitTest(ctx, orgId, body) + if err != nil { + tracker.End("Discovery failed.") + return nil, err + } + + doc, err := a.pollTestForDiscovery(ctx, client, orgUuid, testId) + if err != nil { + tracker.End("Discovery failed.") + return nil, err + } + + tracker.End("Discovery completed.") + return doc, nil +} + +func (a *analysisOrchestrator) pollUntilTestComplete(ctx context.Context, client *testApi.Client, org uuid.UUID, testId openapi_types.UUID, timeoutMsg string) error { + method := "analysis.pollUntilTestComplete" logger := a.logger.With().Str("method", method).Logger() pollingTicker := time.NewTicker(1 * time.Second) @@ -291,30 +321,27 @@ func (a *analysisOrchestrator) pollTestForFindings(ctx context.Context, client * defer timeoutTimer.Stop() for { select { + case <-ctx.Done(): + return ctx.Err() case <-timeoutTimer.C: - msg := "Snyk Code analysis timed out" - logger.Error().Str("scanJobId", testId.String()).Msg(msg) - return nil, nil, fmt.Errorf("%s: %w", msg, context.DeadlineExceeded) + logger.Error().Str("scanJobId", testId.String()).Msg(timeoutMsg) + return fmt.Errorf("%s: %w", timeoutMsg, context.DeadlineExceeded) case <-pollingTicker.C: - resultMetaData, complete, err := a.retrieveTestURL(ctx, client, org, testId) + completed, err := a.isTestComplete(ctx, client, org, testId) if err != nil { - return nil, nil, err + return err } - if complete { - findings, findingsErr := a.retrieveFindings(ctx, testId, resultMetaData.FindingsUrl) - if findingsErr != nil { - return nil, nil, findingsErr - } - return findings, resultMetaData, nil + if completed { + return nil } } } } -func (a *analysisOrchestrator) retrieveTestURL(ctx context.Context, client *testApi.Client, org uuid.UUID, testId openapi_types.UUID) (resultMetaData *scan.ResultMetaData, completed bool, err error) { - method := "analysis.retrieveTestURL" +func (a *analysisOrchestrator) isTestComplete(ctx context.Context, client *testApi.Client, org uuid.UUID, testId openapi_types.UUID) (bool, error) { + method := "analysis.isTestComplete" logger := a.logger.With().Str("method", method).Logger() - logger.Debug().Msg("retrieving Test URL") + logger.Debug().Msg("polling test result") httpResponse, err := client.GetTestResult( ctx, @@ -323,8 +350,8 @@ func (a *analysisOrchestrator) retrieveTestURL(ctx context.Context, client *test &testApi.GetTestResultParams{Version: testApi.ApiVersion}, ) if err != nil { - logger.Err(err).Str("testId", testId.String()).Msg("error requesting the ScanJobResult") - return nil, false, err + logger.Err(err).Str("testId", testId.String()).Msg("error requesting the test result") + return false, err } defer func() { closeErr := httpResponse.Body.Close() @@ -335,43 +362,81 @@ func (a *analysisOrchestrator) retrieveTestURL(ctx context.Context, client *test parsedResponse, err := testApi.ParseGetTestResultResponse(httpResponse) if err != nil { - return nil, false, err + return false, err } - switch parsedResponse.StatusCode() { - case 200: - stateDiscriminator, stateError := parsedResponse.ApplicationvndApiJSON200.Data.Attributes.Discriminator() - if stateError != nil { - return nil, false, stateError - } + if parsedResponse.StatusCode() != http.StatusOK { + return false, fmt.Errorf("unexpected response status \"%d\"", parsedResponse.StatusCode()) + } - switch stateDiscriminator { - case string(testModels.Accepted): - fallthrough - case string(testModels.InProgress): - return nil, false, nil - case string(testModels.Completed): - _, stateCompleteError := parsedResponse.ApplicationvndApiJSON200.Data.Attributes.AsTestCompletedState() - if stateCompleteError != nil { - return nil, false, stateCompleteError - } - components, err := a.retrieveTestComponents(ctx, client, org, testId) - if err != nil { - return nil, false, err - } + stateDiscriminator, err := parsedResponse.ApplicationvndApiJSON200.Data.Attributes.Discriminator() + if err != nil { + return false, err + } - return components, true, nil - case string(testModels.Error): - testError := parseTestError(parsedResponse, method) - return nil, false, testError - default: - return nil, false, fmt.Errorf("unexpected test status \"%s\"", stateDiscriminator) + switch stateDiscriminator { + case string(testModels.Accepted), string(testModels.InProgress): + return false, nil + case string(testModels.Completed): + if _, err := parsedResponse.ApplicationvndApiJSON200.Data.Attributes.AsTestCompletedState(); err != nil { + return false, err } + return true, nil + case string(testModels.Error): + return false, parseTestError(parsedResponse, method) default: - return nil, false, fmt.Errorf("unexpected response status \"%d\"", parsedResponse.StatusCode()) + return false, fmt.Errorf("unexpected test status \"%s\"", stateDiscriminator) } } +func (a *analysisOrchestrator) pollTestForDiscovery(ctx context.Context, client *testApi.Client, org uuid.UUID, testId openapi_types.UUID) (*sanitizers.Document, error) { + if err := a.pollUntilTestComplete(ctx, client, org, testId, "Custom-sanitizer discovery timed out"); err != nil { + return nil, err + } + findingsURL, err := a.discoveryFindingsURL(ctx, client, org, testId) + if err != nil { + return nil, err + } + return sanitizers.FetchDiscoveryDocument(ctx, a.httpClient, findingsURL) +} + +func (a *analysisOrchestrator) discoveryFindingsURL(ctx context.Context, client *testApi.Client, org uuid.UUID, testId openapi_types.UUID) (string, error) { + components, err := a.getTestComponents(ctx, client, org, testId) + if err != nil { + return "", err + } + for _, component := range components { + attrs := component.Attributes + if attrs.Type != string(testModels.SanitizerDiscovery) || !attrs.Success { + continue + } + if attrs.FindingsDocumentType != nil && + *attrs.FindingsDocumentType == testModels.CustomSanitizerDiscoveryDocument && + attrs.FindingsDocumentPath != nil { + return a.host(true) + *attrs.FindingsDocumentPath + "?version=" + testApi.DocumentApiVersion, nil + } + } + return "", errors.New("no custom-sanitizer-discovery component found") +} + +func (a *analysisOrchestrator) pollTestForFindings(ctx context.Context, client *testApi.Client, org uuid.UUID, testId openapi_types.UUID) (*sarif.SarifResponse, *scan.ResultMetaData, error) { + if err := a.pollUntilTestComplete(ctx, client, org, testId, "Snyk Code analysis timed out"); err != nil { + return nil, nil, err + } + + resultMetaData, err := a.retrieveTestComponents(ctx, client, org, testId) + if err != nil { + return nil, nil, err + } + + findings, err := a.retrieveFindings(ctx, testId, resultMetaData.FindingsUrl) + if err != nil { + return nil, nil, err + } + + return findings, resultMetaData, nil +} + func parseTestError(parsedResponse *testApi.GetTestResultResponse, method string) error { errorResponse, stateErrorStateError := parsedResponse.ApplicationvndApiJSON200.Data.Attributes.AsTestErrorState() @@ -406,10 +471,10 @@ func parseTestError(parsedResponse *testApi.GetTestResultResponse, method string return testError } -func (a *analysisOrchestrator) retrieveTestComponents(ctx context.Context, client *testApi.Client, org uuid.UUID, testId openapi_types.UUID) (*scan.ResultMetaData, error) { - method := "analysis.retrieveTestComponents" +func (a *analysisOrchestrator) getTestComponents(ctx context.Context, client *testApi.Client, org uuid.UUID, testId openapi_types.UUID) ([]testModels.GetComponentsResponseItem, error) { + method := "analysis.getTestComponents" logger := a.logger.With().Str("method", method).Logger() - logger.Debug().Msg("retrieving Test Components") + logger.Debug().Msg("retrieving test components") httpResponse, err := client.GetComponents( ctx, @@ -417,7 +482,6 @@ func (a *analysisOrchestrator) retrieveTestComponents(ctx context.Context, clien testId, &testApi.GetComponentsParams{Version: testApi.ApiVersion}, ) - if err != nil { logger.Err(err).Str("testId", testId.String()).Msg("error requesting the test components") return nil, err @@ -438,7 +502,17 @@ func (a *analysisOrchestrator) retrieveTestComponents(ctx context.Context, clien if parsedResponse.ApplicationvndApiJSON200 == nil { return nil, fmt.Errorf("%s: unexpected response status \"%d\"", method, parsedResponse.StatusCode()) } - data := parsedResponse.ApplicationvndApiJSON200.Data + + return parsedResponse.ApplicationvndApiJSON200.Data, nil +} + +func (a *analysisOrchestrator) retrieveTestComponents(ctx context.Context, client *testApi.Client, org uuid.UUID, testId openapi_types.UUID) (*scan.ResultMetaData, error) { + method := "analysis.retrieveTestComponents" + data, err := a.getTestComponents(ctx, client, org, testId) + if err != nil { + return nil, err + } + var sastComponent *testModels.GetComponentsResponseItem for _, component := range data { if component.Attributes.Type == "sast" { diff --git a/internal/analysis/analysis_discover_test.go b/internal/analysis/analysis_discover_test.go new file mode 100644 index 0000000..8edb170 --- /dev/null +++ b/internal/analysis/analysis_discover_test.go @@ -0,0 +1,218 @@ +/* + * © 2026 Snyk Limited All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package analysis_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + openapi_types "github.com/oapi-codegen/runtime/types" + mocks2 "github.com/snyk/code-client-go/bundle/mocks" + confMocks "github.com/snyk/code-client-go/config/mocks" + httpmocks "github.com/snyk/code-client-go/http/mocks" + "github.com/snyk/code-client-go/internal/analysis" + testApi "github.com/snyk/code-client-go/internal/api/test/2025-04-07" + externalRef0 "github.com/snyk/code-client-go/internal/api/test/2025-04-07/common" + testModels "github.com/snyk/code-client-go/internal/api/test/2025-04-07/models" + "github.com/snyk/code-client-go/scan" + trackerMocks "github.com/snyk/code-client-go/scan/mocks" +) + +func mockDiscoverTestCreated(t *testing.T, mockHTTP *httpmocks.MockHTTPClient, testID uuid.UUID, orgID string) { + t.Helper() + response := testApi.NewTestResponse() + response.Data.Id = testID + body, err := json.Marshal(response) + require.NoError(t, err) + + url := fmt.Sprintf("http://localhost/hidden/orgs/%s/tests?version=%s", orgID, testApi.ApiVersion) + mockHTTP.EXPECT().Do(mock.MatchedBy(func(i any) bool { + req := i.(*http.Request) + return req.Method == http.MethodPost && req.URL.String() == url + })).Times(1).Return(&http.Response{ + StatusCode: http.StatusCreated, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewReader(body)), + }, nil) +} + +func mockDiscoverTestCompleted(t *testing.T, mockHTTP *httpmocks.MockHTTPClient, testID uuid.UUID, orgID string) { + t.Helper() + response := testModels.TestResult{ + Data: struct { + Attributes testModels.TestState `json:"attributes"` + Id openapi_types.UUID `json:"id"` + Type testModels.TestResultDataType `json:"type"` + }{ + Id: testID, + Type: testModels.TestResultDataTypeTest, + }, + Jsonapi: externalRef0.JsonApi{Version: "1.0"}, + Links: externalRef0.SelfLink{Self: &externalRef0.LinkProperty{}}, + } + completed := map[string]any{ + "created_at": time.Now().Format(time.RFC3339), + "status": "completed", + "result": "passed", + } + stateBytes, err := json.Marshal(completed) + require.NoError(t, err) + response.Data.Attributes = testModels.TestState{} + require.NoError(t, response.Data.Attributes.UnmarshalJSON(stateBytes)) + body, err := json.Marshal(response) + require.NoError(t, err) + + url := fmt.Sprintf("http://localhost/hidden/orgs/%s/tests/%s?version=%s", orgID, testID, testApi.ApiVersion) + mockHTTP.EXPECT().Do(mock.MatchedBy(func(i any) bool { + req := i.(*http.Request) + return req.Method == http.MethodGet && req.URL.String() == url + })).Times(1).Return(&http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewReader(body)), + }, nil) +} + +func mockDiscoverComponents(t *testing.T, mockHTTP *httpmocks.MockHTTPClient, testID uuid.UUID, orgID, documentPath string) { + t.Helper() + state := testApi.NewGetComponentsState() + state.Data[0].Attributes.Type = string(testModels.SanitizerDiscovery) + state.Data[0].Attributes.Success = true + state.Data[0].Attributes.FindingsDocumentPath = &documentPath + docType := testModels.CustomSanitizerDiscoveryDocument + state.Data[0].Attributes.FindingsDocumentType = &docType + body, err := json.Marshal(state) + require.NoError(t, err) + + url := fmt.Sprintf("http://localhost/hidden/orgs/%s/tests/%s/components?version=%s", orgID, testID, testApi.ApiVersion) + mockHTTP.EXPECT().Do(mock.MatchedBy(func(i any) bool { + req := i.(*http.Request) + return req.Method == http.MethodGet && req.URL.String() == url + })).Times(1).Return(&http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewReader(body)), + }, nil) +} + +func mockDiscoverBlob(t *testing.T, mockHTTP *httpmocks.MockHTTPClient, documentPath string, payload []byte) { + t.Helper() + url := fmt.Sprintf("http://localhost/hidden%s?version=%s", documentPath, testApi.DocumentApiVersion) + mockHTTP.EXPECT().Do(mock.MatchedBy(func(i any) bool { + req := i.(*http.Request) + return req.Method == http.MethodGet && req.URL.String() == url + })).Times(1).Return(&http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewReader(payload)), + }, nil) +} + +func TestRunDiscoverTest_happyPath(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockHTTP := httpmocks.NewMockHTTPClient(ctrl) + mockCfg := confMocks.NewMockConfig(ctrl) + mockCfg.EXPECT().SnykApi().AnyTimes().Return("http://localhost") + mockCfg.EXPECT().SnykCodeAnalysisTimeout().AnyTimes().Return(30 * time.Second) + + mockTracker := trackerMocks.NewMockTracker(ctrl) + mockTracker.EXPECT().Begin(gomock.Any(), gomock.Any()) + mockTracker.EXPECT().End("Discovery completed.") + mockTrackerFactory := trackerMocks.NewMockTrackerFactory(ctrl) + mockTrackerFactory.EXPECT().GenerateTracker().Return(mockTracker) + + orgID := uuid.New().String() + testID := uuid.New() + documentPath := fmt.Sprintf("/orgs/%s/tests/%s/documents/%s/blob", orgID, testID, uuid.New()) + candidates := []byte(`{"scan_id":"fp","candidates":[{"kind":"sanitizer","fqn":"app.security.clean"}]}`) + + mockDiscoverTestCreated(t, mockHTTP, testID, orgID) + mockDiscoverTestCompleted(t, mockHTTP, testID, orgID) + mockDiscoverComponents(t, mockHTTP, testID, orgID, documentPath) + mockDiscoverBlob(t, mockHTTP, documentPath, candidates) + + mockBundle := mocks2.NewMockBundle(ctrl) + mockBundle.EXPECT().GetBundleHash().AnyTimes().Return("bundle-hash") + mockBundle.EXPECT().GetLimitToFiles().AnyTimes().Return([]string{}) + + target, err := scan.NewRepositoryTarget("../mypath/") + require.NoError(t, err) + + o := analysis.NewAnalysisOrchestrator( + mockCfg, + mockHTTP, + analysis.WithTrackerFactory(mockTrackerFactory), + ) + + doc, err := o.RunDiscoverTest(context.Background(), orgID, mockBundle, target) + require.NoError(t, err) + require.Len(t, doc.Candidates, 1) + assert.Equal(t, "app.security.clean", doc.Candidates[0].FQN) +} + +func TestRunDiscoverTest_createReject(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockHTTP := httpmocks.NewMockHTTPClient(ctrl) + mockCfg := confMocks.NewMockConfig(ctrl) + mockCfg.EXPECT().SnykApi().AnyTimes().Return("http://localhost") + mockCfg.EXPECT().SnykCodeAnalysisTimeout().AnyTimes().Return(30 * time.Second) + + mockTracker := trackerMocks.NewMockTracker(ctrl) + mockTracker.EXPECT().Begin(gomock.Any(), gomock.Any()) + mockTracker.EXPECT().End("Discovery failed.") + mockTrackerFactory := trackerMocks.NewMockTrackerFactory(ctrl) + mockTrackerFactory.EXPECT().GenerateTracker().Return(mockTracker) + + orgID := uuid.New().String() + url := fmt.Sprintf("http://localhost/hidden/orgs/%s/tests?version=%s", orgID, testApi.ApiVersion) + mockHTTP.EXPECT().Do(mock.MatchedBy(func(i any) bool { + req := i.(*http.Request) + return req.Method == http.MethodPost && req.URL.String() == url + })).Times(1).Return(&http.Response{ + StatusCode: http.StatusUnprocessableEntity, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewReader([]byte(`{"errors":[]}`))), + }, nil) + + mockBundle := mocks2.NewMockBundle(ctrl) + mockBundle.EXPECT().GetBundleHash().AnyTimes().Return("bundle-hash") + mockBundle.EXPECT().GetLimitToFiles().AnyTimes().Return([]string{}) + + target, err := scan.NewRepositoryTarget("../mypath/") + require.NoError(t, err) + + o := analysis.NewAnalysisOrchestrator(mockCfg, mockHTTP, analysis.WithTrackerFactory(mockTrackerFactory)) + _, err = o.RunDiscoverTest(context.Background(), orgID, mockBundle, target) + require.Error(t, err) + assert.Contains(t, err.Error(), "422") +} diff --git a/internal/analysis/analysis_private_test.go b/internal/analysis/analysis_private_test.go index 1100be1..197c3a8 100644 --- a/internal/analysis/analysis_private_test.go +++ b/internal/analysis/analysis_private_test.go @@ -15,7 +15,7 @@ import ( "github.com/stretchr/testify/assert" ) -func TestAnalysis_retrieveTestURL_TestResultError(t *testing.T) { +func TestAnalysis_isTestComplete_TestResultError(t *testing.T) { ctrl := gomock.NewController(t) config := confMocks.NewMockConfig(ctrl) mockHTTPClient := httpmocks.NewMockHTTPClient(ctrl) @@ -61,10 +61,9 @@ func TestAnalysis_retrieveTestURL_TestResultError(t *testing.T) { }`)), }, nil) - resultMetaData, completed, err := analysisOrchestrator.retrieveTestURL(t.Context(), apiClient, uuid.New(), uuid.New()) + completed, err := analysisOrchestrator.isTestComplete(t.Context(), apiClient, uuid.New(), uuid.New()) assert.Error(t, err) assert.False(t, completed) - assert.Nil(t, resultMetaData) expectedError := snyk_errors.Error{} assert.ErrorAs(t, err, &expectedError) diff --git a/internal/analysis/mocks/analysis.go b/internal/analysis/mocks/analysis.go index 9f7a796..54653a7 100644 --- a/internal/analysis/mocks/analysis.go +++ b/internal/analysis/mocks/analysis.go @@ -11,6 +11,7 @@ import ( gomock "github.com/golang/mock/gomock" bundle "github.com/snyk/code-client-go/bundle" analysis "github.com/snyk/code-client-go/internal/analysis" + sanitizers "github.com/snyk/code-client-go/internal/analysis/sanitizers" sarif "github.com/snyk/code-client-go/sarif" scan "github.com/snyk/code-client-go/scan" ) @@ -38,6 +39,21 @@ func (m *MockAnalysisOrchestrator) EXPECT() *MockAnalysisOrchestratorMockRecorde return m.recorder } +// RunDiscoverTest mocks base method. +func (m *MockAnalysisOrchestrator) RunDiscoverTest(ctx context.Context, orgId string, b bundle.Bundle, target scan.Target) (*sanitizers.Document, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RunDiscoverTest", ctx, orgId, b, target) + ret0, _ := ret[0].(*sanitizers.Document) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// RunDiscoverTest indicates an expected call of RunDiscoverTest. +func (mr *MockAnalysisOrchestratorMockRecorder) RunDiscoverTest(ctx, orgId, b, target interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RunDiscoverTest", reflect.TypeOf((*MockAnalysisOrchestrator)(nil).RunDiscoverTest), ctx, orgId, b, target) +} + // RunLegacyTest mocks base method. func (m *MockAnalysisOrchestrator) RunLegacyTest(ctx context.Context, bundleHash, shardKey string, limitToFiles []string, severity int) (*sarif.SarifResponse, scan.LegacyScanStatus, error) { m.ctrl.T.Helper() diff --git a/internal/analysis/sanitizers/decode.go b/internal/analysis/sanitizers/decode.go new file mode 100644 index 0000000..a5a7280 --- /dev/null +++ b/internal/analysis/sanitizers/decode.go @@ -0,0 +1,77 @@ +/* + * © 2026 Snyk Limited All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package sanitizers + +import ( + "context" + "encoding/json" + "io" + "net/http" + + errors "github.com/pkg/errors" +) + +// CandidatesFoundError signals that discovery surfaced one or more candidates. +// Callers should exit with code 1 (aibom convention: findings present). +type CandidatesFoundError struct { + Count int +} + +func (e *CandidatesFoundError) Error() string { + return "custom-sanitizer candidates found" +} + +// DecodeDocument decodes a candidate findings document (Appendix-A shape) and +// validates it. An empty candidate set is valid — discovery on safe code +// surfaces nothing — but every candidate must carry an FQN. +func DecodeDocument(r io.Reader) (*Document, error) { + var doc Document + if err := json.NewDecoder(r).Decode(&doc); err != nil { + return nil, errors.Wrap(err, "failed to decode candidate document") + } + for i, c := range doc.Candidates { + if c.FQN == "" { + return nil, errors.Errorf("candidate %d has no fqn", i) + } + } + return &doc, nil +} + +// FetchDiscoveryDocument GETs a completed discovery findings document and decodes it. +func FetchDiscoveryDocument(ctx context.Context, httpClient interface { + Do(*http.Request) (*http.Response, error) +}, findingsURL string) (*Document, error) { + if findingsURL == "" { + return nil, errors.New("do not have a findings URL") + } + req, err := http.NewRequest(http.MethodGet, findingsURL, nil) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + + rsp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = rsp.Body.Close() }() + + if rsp.StatusCode != http.StatusOK { + return nil, errors.Errorf("failed to retrieve candidates from findings URL: status %d", rsp.StatusCode) + } + return DecodeDocument(rsp.Body) +} diff --git a/internal/analysis/sanitizers/decode_test.go b/internal/analysis/sanitizers/decode_test.go new file mode 100644 index 0000000..4f5e0a9 --- /dev/null +++ b/internal/analysis/sanitizers/decode_test.go @@ -0,0 +1,87 @@ +/* + * © 2026 Snyk Limited All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package sanitizers_test + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/snyk/code-client-go/internal/analysis/sanitizers" +) + +func TestDecodeDocument_valid(t *testing.T) { + doc, err := sanitizers.DecodeDocument(strings.NewReader( + `{"scan_id":"fp","candidates":[{"kind":"sanitizer","fqn":"app.security.clean"}]}`)) + require.NoError(t, err) + assert.Equal(t, "fp", doc.ScanID) + require.Len(t, doc.Candidates, 1) + assert.Equal(t, "app.security.clean", doc.Candidates[0].FQN) +} + +func TestDecodeDocument_emptyCandidatesIsValid(t *testing.T) { + doc, err := sanitizers.DecodeDocument(strings.NewReader(`{"candidates":[]}`)) + require.NoError(t, err) + assert.Empty(t, doc.Candidates) +} + +func TestDecodeDocument_missingFQNErrors(t *testing.T) { + _, err := sanitizers.DecodeDocument(strings.NewReader( + `{"candidates":[{"kind":"sanitizer"}]}`)) + require.Error(t, err) + assert.Contains(t, err.Error(), "no fqn") +} + +func TestDecodeDocument_malformedErrors(t *testing.T) { + _, err := sanitizers.DecodeDocument(strings.NewReader(`{not json`)) + require.Error(t, err) + assert.Contains(t, err.Error(), "decode") +} + +func TestFetchDiscoveryDocument_success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"scan_id":"fp","candidates":[{"kind":"sanitizer","fqn":"app.security.clean"}]}`)) + })) + defer srv.Close() + + doc, err := sanitizers.FetchDiscoveryDocument(context.Background(), srv.Client(), srv.URL) + require.NoError(t, err) + require.Len(t, doc.Candidates, 1) + assert.Equal(t, "app.security.clean", doc.Candidates[0].FQN) +} + +func TestFetchDiscoveryDocument_non200Errors(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + _, err := sanitizers.FetchDiscoveryDocument(context.Background(), srv.Client(), srv.URL) + require.Error(t, err) + assert.Contains(t, err.Error(), "500") +} + +func TestFetchDiscoveryDocument_emptyURLErrors(t *testing.T) { + _, err := sanitizers.FetchDiscoveryDocument(context.Background(), http.DefaultClient, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "findings URL") +} diff --git a/internal/analysis/sanitizers/model.go b/internal/analysis/sanitizers/model.go new file mode 100644 index 0000000..f96b2fa --- /dev/null +++ b/internal/analysis/sanitizers/model.go @@ -0,0 +1,68 @@ +/* + * © 2026 Snyk Limited All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Package sanitizers holds the custom-sanitizer discovery candidate document — +// the output of `snyk code test --discover-sanitisers`. The shape follows the +// auto-discovery PRD's Appendix A; it is the contract between the CLI and the +// suggest discovery RPC, kept independent of the analysis findings model. +package sanitizers + +// Kind is the taint-target a candidate plays on a flow. +const ( + KindSanitizer = "sanitizer" + KindSource = "source" + KindSink = "sink" +) + +// SanitizationType mirrors the engine's sanitizer categories (Appendix A uses +// the hyphenated form). Registration maps these onto the Rule Extensions enum +// (FLOWS_THROUGH / IF_TRUE / IF_FALSE / ANY_USAGE) downstream. +const ( + SanitizationFlowsThrough = "flows-through" + SanitizationIfTrue = "if-true" + SanitizationIfFalse = "if-false" + SanitizationAnyUsage = "any-usage" +) + +// Location is a source position for a candidate's definition or a call site. +type Location struct { + File string `json:"file"` + Line int `json:"line,omitempty"` + Column int `json:"column,omitempty"` +} + +// Candidate is one discovered custom-sanitizer (or source/sink) candidate, +// keyed by fully qualified name. +type Candidate struct { + Kind string `json:"kind"` + FQN string `json:"fqn"` + // SanitizationType is set for sanitizer candidates; empty for source/sink. + SanitizationType string `json:"sanitization_type,omitempty"` + // ApplicableRules are / entries, e.g. "java/OpenRedirect". + ApplicableRules []string `json:"applicable_rules,omitempty"` + Confidence float64 `json:"confidence,omitempty"` + Rationale string `json:"rationale,omitempty"` + // Scope is the import availability: "public_library" or "internal". + Scope string `json:"scope,omitempty"` + Definition *Location `json:"definition,omitempty"` + CallSites []Location `json:"call_sites,omitempty"` +} + +// Document is the discovery candidate output for a scan (Appendix A shape). +type Document struct { + ScanID string `json:"scan_id,omitempty"` + Candidates []Candidate `json:"candidates"` +} diff --git a/internal/analysis/sanitizers/render.go b/internal/analysis/sanitizers/render.go new file mode 100644 index 0000000..3510f28 --- /dev/null +++ b/internal/analysis/sanitizers/render.go @@ -0,0 +1,64 @@ +/* + * © 2026 Snyk Limited All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package sanitizers + +import ( + "fmt" + "strings" +) + +// RenderHuman returns a terminal-friendly listing of the candidates. Per +// ADR-002, `--discover-sanitisers` shows candidates only (not vuln findings), +// so this is the whole human output for the flag. +func (d Document) RenderHuman() string { + if len(d.Candidates) == 0 { + return "No custom-sanitizer candidates found." + } + var b strings.Builder + fmt.Fprintf(&b, "Discovered %d custom-sanitizer candidate(s):\n", len(d.Candidates)) + for _, c := range d.Candidates { + fmt.Fprintf(&b, "\n %s\n", c.FQN) + meta := []string{"kind: " + c.Kind} + if c.SanitizationType != "" { + meta = append(meta, "type: "+c.SanitizationType) + } + if c.Confidence > 0 { + meta = append(meta, fmt.Sprintf("confidence: %.0f%%", c.Confidence*100)) + } + if c.Scope != "" { + meta = append(meta, "scope: "+c.Scope) + } + fmt.Fprintf(&b, " %s\n", strings.Join(meta, " · ")) + if len(c.ApplicableRules) > 0 { + fmt.Fprintf(&b, " rules: %s\n", strings.Join(c.ApplicableRules, ", ")) + } + if c.Rationale != "" { + fmt.Fprintf(&b, " rationale: %s\n", c.Rationale) + } + if loc := c.Definition; loc != nil && loc.File != "" { + fmt.Fprintf(&b, " at %s\n", formatLocation(*loc)) + } + } + return b.String() +} + +func formatLocation(l Location) string { + if l.Line > 0 { + return fmt.Sprintf("%s:%d", l.File, l.Line) + } + return l.File +} diff --git a/internal/analysis/sanitizers/render_test.go b/internal/analysis/sanitizers/render_test.go new file mode 100644 index 0000000..b401aaf --- /dev/null +++ b/internal/analysis/sanitizers/render_test.go @@ -0,0 +1,57 @@ +/* + * © 2026 Snyk Limited All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package sanitizers_test + +import ( + "testing" + + "github.com/snyk/code-client-go/internal/analysis/sanitizers" + "github.com/stretchr/testify/assert" +) + +func sampleDoc() sanitizers.Document { + return sanitizers.Document{ + ScanID: "scan-7f2a3c", + Candidates: []sanitizers.Candidate{ + { + Kind: sanitizers.KindSanitizer, + FQN: "io.snyk.demo.rules.UsernameValidator.isValidUsername", + SanitizationType: sanitizers.SanitizationIfTrue, + ApplicableRules: []string{"javascript/OpenRedirect", "java/OpenRedirect"}, + Confidence: 0.91, + Scope: "internal", + Rationale: "Guard before tainted redirects; 14 call-sites.", + Definition: &sanitizers.Location{File: "src/rules/validator.java", Line: 42}, + }, + }, + } +} + +func TestRenderHuman_ListsCandidateDetails(t *testing.T) { + out := sampleDoc().RenderHuman() + assert.Contains(t, out, "Discovered 1 custom-sanitizer candidate(s):") + assert.Contains(t, out, "io.snyk.demo.rules.UsernameValidator.isValidUsername") + assert.Contains(t, out, "type: if-true") + assert.Contains(t, out, "confidence: 91%") + assert.Contains(t, out, "scope: internal") + assert.Contains(t, out, "rules: javascript/OpenRedirect, java/OpenRedirect") + assert.Contains(t, out, "at src/rules/validator.java:42") +} + +func TestRenderHuman_EmptyIsClear(t *testing.T) { + assert.Equal(t, "No custom-sanitizer candidates found.", sanitizers.Document{}.RenderHuman()) +} diff --git a/internal/api/test/2025-04-07/helper.go b/internal/api/test/2025-04-07/helper.go index 1390d90..c677d75 100644 --- a/internal/api/test/2025-04-07/helper.go +++ b/internal/api/test/2025-04-07/helper.go @@ -48,6 +48,18 @@ func WithScanType(t v20250407.ResultType) CreateTestOption { } } +// WithDiscoverScanConfig sets scanners and result_type for custom-sanitizer discovery. +func WithDiscoverScanConfig() CreateTestOption { + return func(body *CreateTestApplicationVndAPIPlusJSONRequestBody) { + scanners := []v20250407.ScanConfigScanners{v20250407.SanitizerDiscovery} + resultType := v20250407.CustomSanitizerDiscovery + body.Data.Attributes.Configuration.Scan = &v20250407.ScanConfig{ + Scanners: &scanners, + ResultType: &resultType, + } + } +} + func ensureOutput(body *CreateTestApplicationVndAPIPlusJSONRequestBody) *v20250407.OutputConfig { if body.Data.Attributes.Configuration.Output == nil { body.Data.Attributes.Configuration.Output = &v20250407.OutputConfig{} diff --git a/internal/api/test/2025-04-07/models/components.go b/internal/api/test/2025-04-07/models/components.go index d92d20d..f21a1ab 100644 --- a/internal/api/test/2025-04-07/models/components.go +++ b/internal/api/test/2025-04-07/models/components.go @@ -10,8 +10,9 @@ import ( // Defines values for ComponentAttributesFindingsDocumentType. const ( - Cyclonedx ComponentAttributesFindingsDocumentType = "cyclonedx" - Sarif ComponentAttributesFindingsDocumentType = "sarif" + Cyclonedx ComponentAttributesFindingsDocumentType = "cyclonedx" + Sarif ComponentAttributesFindingsDocumentType = "sarif" + CustomSanitizerDiscoveryDocument ComponentAttributesFindingsDocumentType = "custom-sanitizer-discovery" ) // Defines values for GetComponentsResponseItemType. diff --git a/internal/api/test/2025-04-07/models/components.yaml b/internal/api/test/2025-04-07/models/components.yaml index aae45a5..12a1f36 100644 --- a/internal/api/test/2025-04-07/models/components.yaml +++ b/internal/api/test/2025-04-07/models/components.yaml @@ -57,6 +57,7 @@ components: enum: - cyclonedx - sarif + - custom-sanitizer-discovery example: cyclonedx findings_document_path: type: string diff --git a/internal/api/test/2025-04-07/models/tests.go b/internal/api/test/2025-04-07/models/tests.go index 1fb2833..c66f546 100644 --- a/internal/api/test/2025-04-07/models/tests.go +++ b/internal/api/test/2025-04-07/models/tests.go @@ -33,16 +33,18 @@ const ( // Defines values for ResultType. const ( - CodeSecurity ResultType = "code_security" - CodeSecurityCodeQuality ResultType = "code_security, code_quality" + CodeSecurity ResultType = "code_security" + CodeSecurityCodeQuality ResultType = "code_security, code_quality" + CustomSanitizerDiscovery ResultType = "custom_sanitizer_discovery" ) // Defines values for ScanConfigScanners. const ( - LegacyScanners ScanConfigScanners = "legacy_scanners" - Sast ScanConfigScanners = "sast" - Sca ScanConfigScanners = "sca" - Secrets ScanConfigScanners = "secrets" + LegacyScanners ScanConfigScanners = "legacy_scanners" + Sast ScanConfigScanners = "sast" + Sca ScanConfigScanners = "sca" + Secrets ScanConfigScanners = "secrets" + SanitizerDiscovery ScanConfigScanners = "sanitizer_discovery" ) // Defines values for TestAcceptedStateStatus. diff --git a/internal/api/test/2025-04-07/models/tests.yaml b/internal/api/test/2025-04-07/models/tests.yaml index 624a179..e7447af 100644 --- a/internal/api/test/2025-04-07/models/tests.yaml +++ b/internal/api/test/2025-04-07/models/tests.yaml @@ -5,6 +5,7 @@ components: enum: - code_security - code_security, code_quality + - custom_sanitizer_discovery ScanConfig: type: object properties: @@ -17,6 +18,7 @@ components: - sca - legacy_scanners - secrets + - sanitizer_discovery result_type: $ref: '#/components/schemas/ResultType' limit_test_to_files: diff --git a/internal/commands/code_workflow/code_client_helper.go b/internal/commands/code_workflow/code_client_helper.go index 2fdbb1f..bd9d3c1 100644 --- a/internal/commands/code_workflow/code_client_helper.go +++ b/internal/commands/code_workflow/code_client_helper.go @@ -78,3 +78,17 @@ func GetReportMode(config configuration.Configuration) (reportType, error) { return localCode, nil } + +func IsDiscoverSanitisers(config configuration.Configuration) (bool, error) { + if !config.GetBool(ConfigurationDiscoverSanitisers) { + return false, nil + } + if config.GetBool(ConfigurationReportFlag) { + return false, errors.New("--discover-sanitisers cannot be used with --report") + } + // Discovery goes through the test service, which Snyk Code Local Engine does not implement. + if config.GetBool(ConfigurationSlceEnabled) { + return false, errors.New("--discover-sanitisers is not supported with Snyk Code Local Engine") + } + return true, nil +} diff --git a/internal/commands/code_workflow/code_client_helper_test.go b/internal/commands/code_workflow/code_client_helper_test.go index b86b40c..082d244 100644 --- a/internal/commands/code_workflow/code_client_helper_test.go +++ b/internal/commands/code_workflow/code_client_helper_test.go @@ -96,3 +96,40 @@ func Test_GetReportType(t *testing.T) { assert.Error(t, err) }) } + +func Test_IsDiscoverSanitisers(t *testing.T) { + t.Run("disabled by default", func(t *testing.T) { + config := configuration.NewWithOpts() + enabled, err := IsDiscoverSanitisers(config) + assert.False(t, enabled) + assert.NoError(t, err) + }) + + t.Run("enabled", func(t *testing.T) { + config := configuration.NewWithOpts() + config.Set(ConfigurationDiscoverSanitisers, true) + enabled, err := IsDiscoverSanitisers(config) + assert.True(t, enabled) + assert.NoError(t, err) + }) + + t.Run("incompatible with report", func(t *testing.T) { + config := configuration.NewWithOpts() + config.Set(ConfigurationDiscoverSanitisers, true) + config.Set(ConfigurationReportFlag, true) + config.Set(ConfigurationProjectName, "hello") + enabled, err := IsDiscoverSanitisers(config) + assert.False(t, enabled) + assert.Error(t, err) + }) + + t.Run("incompatible with SCLE", func(t *testing.T) { + config := configuration.NewWithOpts() + config.Set(ConfigurationDiscoverSanitisers, true) + config.Set(ConfigurationSlceEnabled, true) + enabled, err := IsDiscoverSanitisers(config) + assert.False(t, enabled) + assert.Error(t, err) + assert.Contains(t, err.Error(), "Snyk Code Local Engine") + }) +} diff --git a/internal/commands/code_workflow/native_workflow.go b/internal/commands/code_workflow/native_workflow.go index a9a69a5..ec36d40 100644 --- a/internal/commands/code_workflow/native_workflow.go +++ b/internal/commands/code_workflow/native_workflow.go @@ -15,6 +15,7 @@ import ( codeclient "github.com/snyk/code-client-go" "github.com/snyk/code-client-go/bundle" codeclienthttp "github.com/snyk/code-client-go/http" + "github.com/snyk/code-client-go/internal/analysis/sanitizers" "github.com/snyk/code-client-go/sarif" "github.com/snyk/code-client-go/scan" "github.com/snyk/error-catalog-golang-public/code" @@ -41,6 +42,10 @@ const ( ConfigurationCommitId = "commit-id" ConfigurationSastSettings = "internal_sast_settings" ConfigurationSlceEnabled = "internal_snyk_scle_enabled" + // ConfigurationDiscoverSanitisers is the `--discover-sanitisers` flag: when + // set, `snyk code test` surfaces custom-sanitizer candidates instead of + // vulnerability findings (auto-discovery, epic ML-1952). + ConfigurationDiscoverSanitisers = "discover-sanitisers" MetadataBundleHash = "Snyk-Bundle-Hash" ) @@ -113,6 +118,12 @@ func EntryPointNative(invocationCtx workflow.InvocationContext, opts ...Optional logger := invocationCtx.GetEnhancedLogger() id := invocationCtx.GetWorkflowIdentifier() + // --discover-sanitisers: surface custom-sanitizer candidates instead of a + // vulnerability scan (auto-discovery, epic ML-1952). + if config.GetBool(ConfigurationDiscoverSanitisers) { + return runDiscoverWorkflow(invocationCtx) + } + // track usage based on trackUsage(invocationCtx.GetNetworkAccess(), config) @@ -212,6 +223,14 @@ func defaultAnalyzeFunction(ctx context.Context, path string, httpClientFunc fun return nil, "", nil, errors.New("--report is not supported with Snyk Code Local Engine") } + discover, err := IsDiscoverSanitisers(config) + if err != nil { + return nil, "", nil, err + } + if discover { + return nil, "", nil, errors.New("--discover-sanitisers requires the discover workflow") + } + httpClient := codeclienthttp.NewHTTPClient( httpClientFunc, codeclienthttp.WithLogger(logger), @@ -227,16 +246,12 @@ func defaultAnalyzeFunction(ctx context.Context, path string, httpClientFunc fun analysisOptions := []codeclient.AnalysisOption{} - codeScannerOptions := []codeclient.OptionFunc{ - codeclient.WithLogger(logger), - codeclient.WithTrackerFactory(progressFactory), - codeclient.WithFlow(config.GetString(ConfigurationTestFLowName)), - } - codeScanner := codeclient.NewCodeScanner( codeScannerConfig, httpClient, - codeScannerOptions..., + codeclient.WithLogger(logger), + codeclient.WithTrackerFactory(progressFactory), + codeclient.WithFlow(config.GetString(ConfigurationTestFLowName)), ) logger.Debug().Msgf("Request ID: %s", requestId) @@ -376,7 +391,113 @@ func getFilesForPath(path string, logger *zerolog.Logger, max_threads int) (<-ch return results, nil } -// Create new Workflow data out of the given object and content type +// runDiscoverWorkflow handles `snyk code test --discover-sanitisers`. +func runDiscoverWorkflow(invocationCtx workflow.InvocationContext) ([]workflow.Data, error) { + config := invocationCtx.GetConfiguration() + logger := invocationCtx.GetEnhancedLogger() + id := invocationCtx.GetWorkflowIdentifier() + path := config.GetString(configuration.INPUT_DIRECTORY) + + if _, err := IsDiscoverSanitisers(config); err != nil { + return nil, err + } + + doc, err := defaultDiscoverFunction( + invocationCtx.Context(), + path, + invocationCtx.GetNetworkAccess().GetHttpClient, + logger, + config, + invocationCtx.GetUserInterface(), + ) + if err != nil { + return nil, err + } + + typeID := workflow.NewTypeIdentifier(id, "discover-sanitisers") + output, err := discoverWorkflowData(typeID, config, doc, path, logger) + if err != nil { + return nil, err + } + if len(doc.Candidates) > 0 { + return output, &sanitizers.CandidatesFoundError{Count: len(doc.Candidates)} + } + return output, nil +} + +func defaultDiscoverFunction(ctx context.Context, path string, httpClientFunc func() *http.Client, logger *zerolog.Logger, config configuration.Configuration, userInterface ui.UserInterface) (*sanitizers.Document, error) { + requestId, err := uuid.GenerateUUID() + if err != nil { + return nil, err + } + + codeScanner := newNativeCodeScanner(httpClientFunc, logger, config, userInterface) + + target, files, err := determineAnalyzeInput(path, config, logger) + if err != nil { + return nil, err + } + + doc, _, err := codeScanner.UploadAndDiscover(ctx, requestId, target, files, map[string]bool{}) + isNoFilesErr := bundle.IsNoFilesError(err) + if err != nil && !isNoFilesErr { + return nil, err + } + if doc == nil { + if isNoFilesErr { + return nil, err + } + return nil, errors.New("empty bundle, no custom-sanitizer discovery") + } + return doc, nil +} + +func newNativeCodeScanner(httpClientFunc func() *http.Client, logger *zerolog.Logger, config configuration.Configuration, userInterface ui.UserInterface) codeclient.CodeScanner { + httpClient := codeclienthttp.NewHTTPClient( + httpClientFunc, + codeclienthttp.WithLogger(logger), + ) + return codeclient.NewCodeScanner( + &codeClientConfig{localConfiguration: config}, + httpClient, + codeclient.WithLogger(logger), + codeclient.WithTrackerFactory(ProgressTrackerFactory{ + userInterface: userInterface, + logger: logger, + }), + codeclient.WithFlow(config.GetString(ConfigurationTestFLowName)), + ) +} + +func discoverWorkflowData( + id workflow.Identifier, + config configuration.Configuration, + doc *sanitizers.Document, + path string, + logger *zerolog.Logger, +) ([]workflow.Data, error) { + useJSON := config.GetBool("json") || config.GetString("json-file-output") != "" || + config.GetBool("sarif") || config.GetString("sarif-file-output") != "" + + if useJSON { + data, err := createCodeWorkflowData(id, config, *doc, "application/json", path, logger) + if err != nil { + return nil, err + } + return []workflow.Data{data}, nil + } + + data := workflow.NewData( + id, + "text/plain", + []byte(doc.RenderHuman()), + workflow.WithConfiguration(config), + workflow.WithLogger(logger), + ) + data.SetContentLocation(path) + return []workflow.Data{data}, nil +} + func createCodeWorkflowData(id workflow.Identifier, config configuration.Configuration, obj any, contentType string, path string, logger *zerolog.Logger) (workflow.Data, error) { bytes, err := json.Marshal(obj) if err != nil { diff --git a/pkg/code/code.go b/pkg/code/code.go index b236a76..f6daea2 100644 --- a/pkg/code/code.go +++ b/pkg/code/code.go @@ -49,6 +49,7 @@ func GetCodeFlagSet() *pflag.FlagSet { flagSet.String(code_workflow.ConfigurationTargetName, "", "The name of the target to test.") flagSet.String(code_workflow.ConfigurationTargetReference, "", "The reference that differentiates this project, e.g. a branch name or version.") flagSet.String("target-file", "", "The path to the target file to test.") + flagSet.Bool(code_workflow.ConfigurationDiscoverSanitisers, false, "Discover custom-sanitizer candidates instead of reporting vulnerability findings.") return flagSet } diff --git a/pkg/code/discover_flag_test.go b/pkg/code/discover_flag_test.go new file mode 100644 index 0000000..dfa86a4 --- /dev/null +++ b/pkg/code/discover_flag_test.go @@ -0,0 +1,32 @@ +/* + * © 2026 Snyk Limited All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package code + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/snyk/code-client-go/internal/commands/code_workflow" +) + +func TestGetCodeFlagSet_hasDiscoverSanitisers(t *testing.T) { + flag := GetCodeFlagSet().Lookup(code_workflow.ConfigurationDiscoverSanitisers) + require.NotNil(t, flag, "expected --%s flag on `code test`", code_workflow.ConfigurationDiscoverSanitisers) + assert.Equal(t, "false", flag.DefValue, "--discover-sanitisers should default to false") +} diff --git a/scan.go b/scan.go index 5f1935c..b6f75a0 100644 --- a/scan.go +++ b/scan.go @@ -30,6 +30,7 @@ import ( "github.com/snyk/code-client-go/config" codeClientHTTP "github.com/snyk/code-client-go/http" "github.com/snyk/code-client-go/internal/analysis" + "github.com/snyk/code-client-go/internal/analysis/sanitizers" testModels "github.com/snyk/code-client-go/internal/api/test/2025-04-07/models" "github.com/snyk/code-client-go/internal/deepcode" "github.com/snyk/code-client-go/observability" @@ -77,6 +78,14 @@ type CodeScanner interface { changedFiles map[string]bool, statusChannel chan<- scan.LegacyScanStatus, ) (*sarif.SarifResponse, string, error) + + UploadAndDiscover( + ctx context.Context, + requestId string, + target scan.Target, + files <-chan string, + changedFiles map[string]bool, + ) (*sanitizers.Document, string, error) } var _ CodeScanner = (*codeScanner)(nil) @@ -359,6 +368,28 @@ func (c *codeScanner) UploadAndAnalyzeWithOptions( return response, uploadedBundle.GetBundleHash(), metadata, err } +func (c *codeScanner) UploadAndDiscover( + ctx context.Context, + requestId string, + target scan.Target, + files <-chan string, + changedFiles map[string]bool, +) (*sanitizers.Document, string, error) { + uploadedBundle, err := c.Upload(ctx, requestId, target, files, changedFiles) + if err != nil || uploadedBundle == nil || uploadedBundle.GetBundleHash() == "" { + c.logger.Debug().Msg("empty bundle, no custom-sanitizer discovery") + return nil, "", err + } + + doc, err := c.analysisOrchestrator.RunDiscoverTest(ctx, c.config.Organization(), uploadedBundle, target) + err = c.checkCancellationOrLogError(ctx, target.GetPath(), err, "error running custom-sanitizer discovery...") + if err != nil { + return nil, "", err + } + + return doc, uploadedBundle.GetBundleHash(), nil +} + func (c *codeScanner) AnalyzeRemote(ctx context.Context, options ...AnalysisOption) (*sarif.SarifResponse, *scan.ResultMetaData, error) { cfg := analysis.AnalysisConfig{} for _, opt := range options {