diff --git a/go.mod b/go.mod index a41ffb6f..088f1efd 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 cc27d7e4..7a1dcc1c 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 0449c203..edda8e33 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 00000000..8edb170a --- /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 1100be14..197c3a8c 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 9f7a796c..54653a70 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 00000000..a5a72807 --- /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 00000000..4f5e0a98 --- /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 00000000..f96b2fae --- /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 00000000..3510f283 --- /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 00000000..b401aaf9 --- /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 1390d90f..c677d752 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 d92d20db..f21a1abd 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 aae45a50..12a1f365 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 1fb2833d..c66f5467 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 624a1797..e7447afd 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 d3f043a7..bd9d3c19 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) } @@ -58,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 4aca5289..082d2448 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() @@ -52,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 82c0b22c..ec36d405 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" @@ -39,6 +40,12 @@ const ( ConfigurationTargetReference = "target-reference" ConfigurationProjectId = "project-id" 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" ) @@ -111,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) @@ -204,6 +217,20 @@ 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") + } + + 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), @@ -219,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) @@ -276,11 +299,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 @@ -331,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/internal/commands/code_workflow/native_workflow_test.go b/internal/commands/code_workflow/native_workflow_test.go index 91d73f3a..05da5a75 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 04f7dae1..f6daea25 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" ) @@ -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 } @@ -166,7 +167,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 +228,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 +238,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 c2d59883..578781ef 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 diff --git a/pkg/code/discover_flag_test.go b/pkg/code/discover_flag_test.go new file mode 100644 index 00000000..dfa86a40 --- /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 5f1935ce..b6f75a00 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 {