diff --git a/http/http.go b/http/http.go index cf08ef0..7b6d549 100644 --- a/http/http.go +++ b/http/http.go @@ -158,3 +158,15 @@ func NewDefaultClientFactory() HTTPClientFactory { clientFunc := func() *http.Client { return http.DefaultClient } return clientFunc } + +func AddDefaultHeaders(req *http.Request, requestId string, orgId string) { + // if requestId is empty it will be enriched from the Gateway + if len(requestId) > 0 { + req.Header.Set("snyk-request-id", requestId) + } + if len(orgId) > 0 { + req.Header.Set("snyk-org-name", orgId) + } + req.Header.Set("Cache-Control", "private, max-age=0, no-cache") + req.Header.Set("Content-Type", "application/json") +} diff --git a/internal/analysis/analysis.go b/internal/analysis/analysis.go index 8cb4cc4..a86bc9a 100644 --- a/internal/analysis/analysis.go +++ b/internal/analysis/analysis.go @@ -46,6 +46,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) + RunLegacyTest(ctx context.Context, bundleHash string, shardKey string, limitToFiles []string, severity int) (*sarif.SarifResponse, scan.LegacyScanStatus, error) } type AnalysisConfig struct { @@ -56,6 +57,7 @@ type AnalysisConfig struct { ProjectId *uuid.UUID CommitId *string } + type analysisOrchestrator struct { httpClient codeClientHTTP.HTTPClient instrumentor observability.Instrumentor diff --git a/internal/analysis/analysis_legacy.go b/internal/analysis/analysis_legacy.go new file mode 100644 index 0000000..4fa4ee6 --- /dev/null +++ b/internal/analysis/analysis_legacy.go @@ -0,0 +1,233 @@ +/* + * © 2025 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. + */ + +//nolint:lll // Some of the lines in this file are going to be long for now. +package analysis + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "github.com/snyk/code-client-go/scan" + "io" + "math" + "net/http" + "net/url" + "strings" + + codeClientHTTP "github.com/snyk/code-client-go/http" + "github.com/snyk/code-client-go/sarif" +) + +// Legacy analysis types and constants +const ( + StatusComplete = "COMPLETE" + StatusFailed = "FAILED" + StatusAnalyzing = "ANALYZING" +) + +type RequestKey struct { + Type string `json:"type"` + Hash string `json:"hash"` + LimitToFiles []string `json:"limitToFiles,omitempty"` + Shard string `json:"shard"` +} + +type requestContextOrg struct { + Name string `json:"name"` + DisplayName string `json:"displayName"` + PublicId string `json:"publicId"` + Flags map[string]bool `json:"flags"` +} + +type requestContext struct { + Initiator string `json:"initiator"` + Flow string `json:"flow,omitempty"` + Org requestContextOrg `json:"org,omitempty"` +} + +type Request struct { + Key RequestKey `json:"key"` + Severity int `json:"severity,omitempty"` + Prioritized bool `json:"prioritized,omitempty"` + Legacy bool `json:"legacy"` + AnalysisContext requestContext `json:"analysisContext"` +} + +type FailedError struct { + Msg string +} + +func (e FailedError) Error() string { return e.Msg } + +// Legacy analysis helper functions +func (a *analysisOrchestrator) newRequestContext() requestContext { + unknown := "unknown" + orgId := unknown + if a.config.Organization() != "" { + orgId = a.config.Organization() + } + + return requestContext{ + Initiator: "IDE", + Flow: "language-server", + Org: requestContextOrg{ + Name: unknown, + DisplayName: unknown, + PublicId: orgId, + }, + } +} + +func (a *analysisOrchestrator) createRequestBody(bundleHash, shardKey string, limitToFiles []string, severity int) ([]byte, error) { + request := Request{ + Key: RequestKey{ + Type: "file", + Hash: bundleHash, + LimitToFiles: limitToFiles, + }, + Legacy: false, + AnalysisContext: a.newRequestContext(), + } + if len(shardKey) > 0 { + request.Key.Shard = shardKey + } + if severity > 0 { + request.Severity = severity + } + + requestBody, err := json.Marshal(request) + return requestBody, err +} + +func (a *analysisOrchestrator) getCodeApiUrl() (string, error) { + // Use the same logic as the original SnykCodeHTTPClient + if !a.config.IsFedramp() { + return a.config.SnykCodeApi(), nil + } + u, err := url.Parse(a.config.SnykCodeApi()) + if err != nil { + return "", err + } + + // Apply fedramp transformation (this might need adjustment based on the actual requirements) + u.Host = strings.Replace(u.Host, "deeproxy", "api", 1) + + if a.config.Organization() == "" { + return "", errors.New("organization is required in a fedramp environment") + } + + u.Path = "/hidden/orgs/" + a.config.Organization() + "/code" + return u.String(), nil +} + +// TODO combine? +func (a *analysisOrchestrator) logSarifResponse(method string, sarifResponse sarif.SarifResponse) { + a.logger.Debug(). + Str("method", method). + Str("status", sarifResponse.Status). + Float64("progress", sarifResponse.Progress). + Int("fetchingCodeTime", sarifResponse.Timing.FetchingCode). + Int("analysisTime", sarifResponse.Timing.Analysis). + Int("filesAnalyzed", len(sarifResponse.Coverage)). + Msg("Received response summary") +} + +func (a *analysisOrchestrator) RunLegacyTest(ctx context.Context, bundleHash string, shardKey string, limitToFiles []string, severity int) (*sarif.SarifResponse, scan.LegacyScanStatus, error) { + method := "analysis.RunLegacyTest" + span := a.instrumentor.StartSpan(ctx, method) + defer a.instrumentor.Finish(span) + + a.logger.Debug().Str("method", method).Str("bundleHash", bundleHash).Msg("API: Retrieving analysis for bundle") + defer a.logger.Debug().Str("method", method).Str("bundleHash", bundleHash).Msg("API: Retrieving analysis done") + + requestBody, err := a.createRequestBody(bundleHash, shardKey, limitToFiles, severity) + if err != nil { + a.logger.Err(err).Str("method", method).Str("requestBody", string(requestBody)).Msg("error creating request body") + return nil, scan.LegacyScanStatus{}, err + } + + // Get the legacy code API URL + baseUrl, err := a.getCodeApiUrl() + if err != nil { + return nil, scan.LegacyScanStatus{}, err + } + + // Create HTTP request + analysisUrl := baseUrl + "/analysis" + req, err := http.NewRequestWithContext(span.Context(), http.MethodPost, analysisUrl, bytes.NewBuffer(requestBody)) + if err != nil { + a.logger.Err(err).Str("method", method).Msg("error creating HTTP request") + return nil, scan.LegacyScanStatus{}, err + } + codeClientHTTP.AddDefaultHeaders(req, span.GetTraceId(), a.config.Organization()) + + // Make HTTP call + resp, err := a.httpClient.Do(req) + failed := scan.LegacyScanStatus{Message: StatusFailed} + if err != nil { + a.logger.Err(err).Str("method", method).Msg("error response from analysis") + return nil, failed, err + } + defer func() { + closeErr := resp.Body.Close() + if closeErr != nil { + a.logger.Err(closeErr).Msg("failed to close response body") + } + }() + + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + a.logger.Err(err).Str("method", method).Msg("error reading response body") + return nil, failed, err + } + + // Check response status + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + a.logger.Err(err).Str("method", method).Str("responseBody", string(responseBody)).Int("statusCode", resp.StatusCode).Msg("error response from analysis") + return nil, failed, FailedError{Msg: string(responseBody)} + } + + var response sarif.SarifResponse + err = json.Unmarshal(responseBody, &response) + if err != nil { + a.logger.Err(err).Str("method", method).Str("responseBody", string(responseBody)).Msg("error unmarshalling") + return nil, failed, err + } else { + a.logSarifResponse(method, response) + } + + a.logger.Debug().Str("method", method).Str("bundleHash", bundleHash).Float64("progress", + response.Progress).Msgf("LegacyScanStatus: %s", response.Status) + + if response.Status == failed.Message { + a.logger.Err(err).Str("method", method).Str("responseStatus", response.Status).Msg("analysis failed") + return nil, failed, FailedError{Msg: string(responseBody)} + } + + if response.Status == "" { + a.logger.Err(err).Str("method", method).Str("responseStatus", response.Status).Msg("unknown response status (empty)") + return nil, failed, FailedError{Msg: string(responseBody)} + } + + status := scan.LegacyScanStatus{Message: response.Status, Percentage: int(math.RoundToEven(response.Progress * 100))} + if response.Status != StatusComplete { + return nil, status, nil + } + + return &response, status, nil +} diff --git a/internal/analysis/analysis_legacy_test.go b/internal/analysis/analysis_legacy_test.go new file mode 100644 index 0000000..096dafe --- /dev/null +++ b/internal/analysis/analysis_legacy_test.go @@ -0,0 +1,722 @@ +/* + * © 2025 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/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + 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" + "github.com/snyk/code-client-go/observability/mocks" + "github.com/snyk/code-client-go/sarif" + "github.com/snyk/code-client-go/scan" + trackerMocks "github.com/snyk/code-client-go/scan/mocks" +) + +func mockLegacyAnalysisResponse(t *testing.T, mockHTTPClient *httpmocks.MockHTTPClient, sarifResponse sarif.SarifResponse, bundleHash string, orgId string, responseCode int) { + t.Helper() + responseBodyBytes, err := json.Marshal(sarifResponse) + assert.NoError(t, err) + expectedAnalysisUrl := "http://localhost/analysis" + mockHTTPClient.EXPECT().Do(mock.MatchedBy(func(i interface{}) bool { + req := i.(*http.Request) + return req.URL.String() == expectedAnalysisUrl && req.Method == http.MethodPost + })).Times(1).Return(&http.Response{ + StatusCode: responseCode, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + Body: io.NopCloser(bytes.NewReader(responseBodyBytes)), + }, mockDeriveErrorFromStatusCode(responseCode)) +} + +func setupLegacy(t *testing.T, timeout *time.Duration, isFedramp bool, snykCodeApi string, orgId string) (*confMocks.MockConfig, *httpmocks.MockHTTPClient, *mocks.MockInstrumentor, *mocks.MockErrorReporter, *trackerMocks.MockTracker, *trackerMocks.MockTrackerFactory, zerolog.Logger) { + t.Helper() + ctrl := gomock.NewController(t) + mockSpan := mocks.NewMockSpan(ctrl) + mockSpan.EXPECT().GetTraceId().AnyTimes().Return("test-trace-id") + mockSpan.EXPECT().Context().AnyTimes().Return(context.Background()) + mockConfig := confMocks.NewMockConfig(ctrl) + mockConfig.EXPECT().Organization().AnyTimes().Return(orgId) + if snykCodeApi == "" { + snykCodeApi = "http://localhost" + } + mockConfig.EXPECT().SnykCodeApi().AnyTimes().Return(snykCodeApi) + mockConfig.EXPECT().IsFedramp().AnyTimes().Return(isFedramp) + if timeout == nil { + defaultTimeout := 120 * time.Second + timeout = &defaultTimeout + } + mockConfig.EXPECT().SnykCodeAnalysisTimeout().AnyTimes().Return(*timeout) + + mockHTTPClient := httpmocks.NewMockHTTPClient(ctrl) + + mockInstrumentor := mocks.NewMockInstrumentor(ctrl) + mockInstrumentor.EXPECT().StartSpan(gomock.Any(), gomock.Any()).Return(mockSpan).AnyTimes() + mockInstrumentor.EXPECT().Finish(gomock.Any()).AnyTimes() + mockErrorReporter := mocks.NewMockErrorReporter(ctrl) + mockTracker := trackerMocks.NewMockTracker(ctrl) + mockTrackerFactory := trackerMocks.NewMockTrackerFactory(ctrl) + mockTrackerFactory.EXPECT().GenerateTracker().Return(mockTracker).AnyTimes() + + logger := zerolog.Nop() + return mockConfig, mockHTTPClient, mockInstrumentor, mockErrorReporter, mockTracker, mockTrackerFactory, logger +} + +func TestAnalysis_RunLegacyTest_Success(t *testing.T) { + bundleHash := "test-bundle-hash" + shardKey := "test-shard-key" + limitToFiles := []string{"file1.js", "file2.js"} + severity := 2 + orgId := "test-org-id" + + mockConfig, mockHTTPClient, mockInstrumentor, mockErrorReporter, _, mockTrackerFactory, logger := setupLegacy(t, nil, false, "", orgId) + + // Create expected sarif response + sarifResponse := sarif.SarifResponse{ + Type: "sarif", + Progress: 1.0, + Status: analysis.StatusComplete, + Sarif: sarif.SarifDocument{ + Version: "2.1.0", + Runs: []sarif.Run{ + { + Tool: sarif.Tool{ + Driver: sarif.Driver{ + Name: "SnykCode", + Version: "1.0.0", + }, + }, + Results: []sarif.Result{}, + }, + }, + }, + Coverage: []sarif.SarifCoverage{ + { + Files: 5, + IsSupported: true, + Lang: "javascript", + }, + }, + Timing: struct { + FetchingCode int `json:"fetchingCode"` + Queue int `json:"queue"` + Analysis int `json:"analysis"` + }{ + FetchingCode: 100, + Analysis: 500, + }, + } + + mockLegacyAnalysisResponse(t, mockHTTPClient, sarifResponse, bundleHash, orgId, http.StatusOK) + + analysisOrchestrator := analysis.NewAnalysisOrchestrator( + mockConfig, + mockHTTPClient, + analysis.WithLogger(&logger), + analysis.WithInstrumentor(mockInstrumentor), + analysis.WithTrackerFactory(mockTrackerFactory), + analysis.WithErrorReporter(mockErrorReporter), + ) + + // run method under test + result, status, err := analysisOrchestrator.RunLegacyTest( + context.Background(), + bundleHash, + shardKey, + limitToFiles, + severity, + ) + + require.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, analysis.StatusComplete, status.Message) + assert.Equal(t, 100, status.Percentage) + assert.Equal(t, sarifResponse.Sarif.Version, result.Sarif.Version) + assert.Equal(t, sarifResponse.Status, result.Status) + assert.Equal(t, sarifResponse.Progress, result.Progress) +} + +func TestAnalysis_RunLegacyTest_InProgress(t *testing.T) { + bundleHash := "test-bundle-hash" + shardKey := "test-shard-key" + limitToFiles := []string{"file1.js"} + severity := 1 + orgId := "test-org-id" + + mockConfig, mockHTTPClient, mockInstrumentor, mockErrorReporter, _, mockTrackerFactory, logger := setupLegacy(t, nil, false, "", orgId) + + // Create expected sarif response for in-progress status + sarifResponse := sarif.SarifResponse{ + Type: "sarif", + Progress: 0.6, + Status: analysis.StatusAnalyzing, + Coverage: []sarif.SarifCoverage{}, + Timing: struct { + FetchingCode int `json:"fetchingCode"` + Queue int `json:"queue"` + Analysis int `json:"analysis"` + }{ + FetchingCode: 50, + Analysis: 200, + }, + } + + mockLegacyAnalysisResponse(t, mockHTTPClient, sarifResponse, bundleHash, orgId, http.StatusOK) + + analysisOrchestrator := analysis.NewAnalysisOrchestrator( + mockConfig, + mockHTTPClient, + analysis.WithLogger(&logger), + analysis.WithInstrumentor(mockInstrumentor), + analysis.WithTrackerFactory(mockTrackerFactory), + analysis.WithErrorReporter(mockErrorReporter), + ) + + // run method under test + result, status, err := analysisOrchestrator.RunLegacyTest( + context.Background(), + bundleHash, + shardKey, + limitToFiles, + severity, + ) + + require.NoError(t, err) + assert.Nil(t, result) // No result when not complete + assert.Equal(t, analysis.StatusAnalyzing, status.Message) + assert.Equal(t, 60, status.Percentage) // 0.6 * 100 +} + +func TestAnalysis_RunLegacyTest_Failed(t *testing.T) { + bundleHash := "test-bundle-hash" + shardKey := "" + limitToFiles := []string{} + severity := 0 + orgId := "test-org-id" + + mockConfig, mockHTTPClient, mockInstrumentor, mockErrorReporter, _, mockTrackerFactory, logger := setupLegacy(t, nil, false, "", orgId) + + // Create expected sarif response for failed status + sarifResponse := sarif.SarifResponse{ + Type: "sarif", + Progress: 0.0, + Status: analysis.StatusFailed, + } + + mockLegacyAnalysisResponse(t, mockHTTPClient, sarifResponse, bundleHash, orgId, http.StatusOK) + + analysisOrchestrator := analysis.NewAnalysisOrchestrator( + mockConfig, + mockHTTPClient, + analysis.WithLogger(&logger), + analysis.WithInstrumentor(mockInstrumentor), + analysis.WithTrackerFactory(mockTrackerFactory), + analysis.WithErrorReporter(mockErrorReporter), + ) + + // run method under test + result, status, err := analysisOrchestrator.RunLegacyTest( + context.Background(), + bundleHash, + shardKey, + limitToFiles, + severity, + ) + + require.Error(t, err) + assert.IsType(t, analysis.FailedError{}, err) + assert.Nil(t, result) + assert.Equal(t, analysis.StatusFailed, status.Message) +} + +func TestAnalysis_RunLegacyTest_EmptyStatus(t *testing.T) { + bundleHash := "test-bundle-hash" + shardKey := "" + limitToFiles := []string{} + severity := 0 + orgId := "test-org-id" + + mockConfig, mockHTTPClient, mockInstrumentor, mockErrorReporter, _, mockTrackerFactory, logger := setupLegacy(t, nil, false, "", orgId) + + // Create expected sarif response with empty status + sarifResponse := sarif.SarifResponse{ + Type: "sarif", + Progress: 0.0, + Status: "", // Empty status should be treated as error + } + + mockLegacyAnalysisResponse(t, mockHTTPClient, sarifResponse, bundleHash, orgId, http.StatusOK) + + analysisOrchestrator := analysis.NewAnalysisOrchestrator( + mockConfig, + mockHTTPClient, + analysis.WithLogger(&logger), + analysis.WithInstrumentor(mockInstrumentor), + analysis.WithTrackerFactory(mockTrackerFactory), + analysis.WithErrorReporter(mockErrorReporter), + ) + + // run method under test + result, status, err := analysisOrchestrator.RunLegacyTest( + context.Background(), + bundleHash, + shardKey, + limitToFiles, + severity, + ) + + require.Error(t, err) + assert.IsType(t, analysis.FailedError{}, err) + assert.Nil(t, result) + assert.Equal(t, analysis.StatusFailed, status.Message) +} + +func TestAnalysis_RunLegacyTest_HTTPError(t *testing.T) { + bundleHash := "test-bundle-hash" + shardKey := "" + limitToFiles := []string{} + severity := 0 + orgId := "test-org-id" + + mockConfig, mockHTTPClient, mockInstrumentor, mockErrorReporter, _, mockTrackerFactory, logger := setupLegacy(t, nil, false, "", orgId) + + // Mock HTTP error response - need to check if the mock gives an error first + expectedAnalysisUrl := "http://localhost/analysis" + mockHTTPClient.EXPECT().Do(mock.MatchedBy(func(i interface{}) bool { + req := i.(*http.Request) + return req.URL.String() == expectedAnalysisUrl && req.Method == http.MethodPost + })).Times(1).Return(&http.Response{ + StatusCode: http.StatusInternalServerError, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + Body: io.NopCloser(bytes.NewReader([]byte("Internal Server Error"))), + }, nil) // No error from HTTP client, but bad status code + + analysisOrchestrator := analysis.NewAnalysisOrchestrator( + mockConfig, + mockHTTPClient, + analysis.WithLogger(&logger), + analysis.WithInstrumentor(mockInstrumentor), + analysis.WithTrackerFactory(mockTrackerFactory), + analysis.WithErrorReporter(mockErrorReporter), + ) + + // run method under test + result, status, err := analysisOrchestrator.RunLegacyTest( + context.Background(), + bundleHash, + shardKey, + limitToFiles, + severity, + ) + + require.Error(t, err) + assert.IsType(t, analysis.FailedError{}, err) + assert.Nil(t, result) + assert.Equal(t, analysis.StatusFailed, status.Message) +} + +func TestAnalysis_RunLegacyTest_Fedramp(t *testing.T) { + bundleHash := "test-bundle-hash" + shardKey := "test-shard-key" + limitToFiles := []string{"file1.js"} + severity := 2 + orgId := "test-org-id" + + // Test with fedramp configuration + mockConfig, mockHTTPClient, mockInstrumentor, mockErrorReporter, _, mockTrackerFactory, logger := setupLegacy(t, nil, true, "https://deeproxy.snyk.io", orgId) + + // Create expected sarif response + sarifResponse := sarif.SarifResponse{ + Type: "sarif", + Progress: 1.0, + Status: analysis.StatusComplete, + Sarif: sarif.SarifDocument{ + Version: "2.1.0", + }, + } + + // Expect the fedramp URL transformation + expectedAnalysisUrl := fmt.Sprintf("https://api.snyk.io/hidden/orgs/%s/code/analysis", orgId) + mockHTTPClient.EXPECT().Do(mock.MatchedBy(func(i interface{}) bool { + req := i.(*http.Request) + return req.URL.String() == expectedAnalysisUrl && req.Method == http.MethodPost + })).Times(1).Return(&http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + Body: io.NopCloser(bytes.NewReader(func() []byte { + responseBodyBytes, _ := json.Marshal(sarifResponse) + return responseBodyBytes + }())), + }, nil) + + analysisOrchestrator := analysis.NewAnalysisOrchestrator( + mockConfig, + mockHTTPClient, + analysis.WithLogger(&logger), + analysis.WithInstrumentor(mockInstrumentor), + analysis.WithTrackerFactory(mockTrackerFactory), + analysis.WithErrorReporter(mockErrorReporter), + ) + + // run method under test + result, status, err := analysisOrchestrator.RunLegacyTest( + context.Background(), + bundleHash, + shardKey, + limitToFiles, + severity, + ) + + require.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, analysis.StatusComplete, status.Message) + assert.Equal(t, 100, status.Percentage) +} + +func TestAnalysis_RunLegacyTest_FedrampNoOrg(t *testing.T) { + bundleHash := "test-bundle-hash" + shardKey := "" + limitToFiles := []string{} + severity := 0 + orgId := "" // Empty org ID should cause error in fedramp + + mockConfig, mockHTTPClient, mockInstrumentor, mockErrorReporter, _, mockTrackerFactory, logger := setupLegacy(t, nil, true, "https://deeproxy.snyk.io", orgId) + + analysisOrchestrator := analysis.NewAnalysisOrchestrator( + mockConfig, + mockHTTPClient, + analysis.WithLogger(&logger), + analysis.WithInstrumentor(mockInstrumentor), + analysis.WithTrackerFactory(mockTrackerFactory), + analysis.WithErrorReporter(mockErrorReporter), + ) + + // run method under test + result, status, err := analysisOrchestrator.RunLegacyTest( + context.Background(), + bundleHash, + shardKey, + limitToFiles, + severity, + ) + + require.Error(t, err) + assert.Contains(t, err.Error(), "organization is required in a fedramp environment") + assert.Nil(t, result) + assert.Equal(t, scan.LegacyScanStatus{}, status) +} + +func TestAnalysis_RunLegacyTest_MalformedJSON(t *testing.T) { + bundleHash := "test-bundle-hash" + shardKey := "" + limitToFiles := []string{} + severity := 0 + orgId := "test-org-id" + + mockConfig, mockHTTPClient, mockInstrumentor, mockErrorReporter, _, mockTrackerFactory, logger := setupLegacy(t, nil, false, "", orgId) + + // Mock response with malformed JSON + expectedAnalysisUrl := "http://localhost/analysis" + mockHTTPClient.EXPECT().Do(mock.MatchedBy(func(i interface{}) bool { + req := i.(*http.Request) + return req.URL.String() == expectedAnalysisUrl && req.Method == http.MethodPost + })).Times(1).Return(&http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + Body: io.NopCloser(bytes.NewReader([]byte("invalid json{}"))), + }, nil) + + analysisOrchestrator := analysis.NewAnalysisOrchestrator( + mockConfig, + mockHTTPClient, + analysis.WithLogger(&logger), + analysis.WithInstrumentor(mockInstrumentor), + analysis.WithTrackerFactory(mockTrackerFactory), + analysis.WithErrorReporter(mockErrorReporter), + ) + + // run method under test + result, status, err := analysisOrchestrator.RunLegacyTest( + context.Background(), + bundleHash, + shardKey, + limitToFiles, + severity, + ) + + require.Error(t, err) + assert.Nil(t, result) + assert.Equal(t, analysis.StatusFailed, status.Message) +} + +func TestFailedError_Error(t *testing.T) { + err := analysis.FailedError{Msg: "test error message"} + assert.Equal(t, "test error message", err.Error()) +} + +// TestHelperFunctions tests the unexported helper functions through reflection or by testing them indirectly +func TestAnalysis_CreateRequestBody(t *testing.T) { + // Since createRequestBody is unexported, we test it indirectly by examining the request made in RunLegacyTest + bundleHash := "test-bundle-hash" + shardKey := "test-shard-key" + limitToFiles := []string{"file1.js", "file2.js"} + severity := 2 + orgId := "test-org-id" + + mockConfig, mockHTTPClient, mockInstrumentor, mockErrorReporter, _, mockTrackerFactory, logger := setupLegacy(t, nil, false, "", orgId) + + var capturedRequestBody []byte + expectedAnalysisUrl := "http://localhost/analysis" + mockHTTPClient.EXPECT().Do(mock.MatchedBy(func(i interface{}) bool { + req := i.(*http.Request) + if req.URL.String() == expectedAnalysisUrl && req.Method == http.MethodPost { + // Capture the request body for validation + body, _ := io.ReadAll(req.Body) + capturedRequestBody = body + // Reset the body for the actual request + req.Body = io.NopCloser(bytes.NewReader(body)) + return true + } + return false + })).Times(1).Return(&http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + Body: io.NopCloser(bytes.NewReader([]byte(`{"type":"sarif","progress":1.0,"status":"COMPLETE"}`))), + }, nil) + + analysisOrchestrator := analysis.NewAnalysisOrchestrator( + mockConfig, + mockHTTPClient, + analysis.WithLogger(&logger), + analysis.WithInstrumentor(mockInstrumentor), + analysis.WithTrackerFactory(mockTrackerFactory), + analysis.WithErrorReporter(mockErrorReporter), + ) + + // run method under test + _, _, err := analysisOrchestrator.RunLegacyTest( + context.Background(), + bundleHash, + shardKey, + limitToFiles, + severity, + ) + + require.NoError(t, err) + assert.NotEmpty(t, capturedRequestBody) + + // Parse and validate the request body structure + var request map[string]interface{} + err = json.Unmarshal(capturedRequestBody, &request) + require.NoError(t, err) + + // Validate request structure + assert.Equal(t, false, request["legacy"]) + assert.Contains(t, request, "key") + assert.Contains(t, request, "severity") + assert.Contains(t, request, "analysisContext") + + // Validate key structure + key := request["key"].(map[string]interface{}) + assert.Equal(t, "file", key["type"]) + assert.Equal(t, bundleHash, key["hash"]) + assert.Equal(t, shardKey, key["shard"]) + + // Validate limitToFiles + limitToFilesInterface := key["limitToFiles"].([]interface{}) + assert.Len(t, limitToFilesInterface, 2) + assert.Equal(t, "file1.js", limitToFilesInterface[0]) + assert.Equal(t, "file2.js", limitToFilesInterface[1]) + + // Validate severity + assert.Equal(t, float64(severity), request["severity"]) + + // Validate analysisContext + analysisContext := request["analysisContext"].(map[string]interface{}) + assert.Equal(t, "IDE", analysisContext["initiator"]) + assert.Equal(t, "language-server", analysisContext["flow"]) + + org := analysisContext["org"].(map[string]interface{}) + assert.Equal(t, orgId, org["publicId"]) + assert.Equal(t, "unknown", org["name"]) + assert.Equal(t, "unknown", org["displayName"]) +} + +func TestAnalysis_CreateRequestBody_NoShardKey(t *testing.T) { + bundleHash := "test-bundle-hash" + shardKey := "" // Empty shard key + limitToFiles := []string{} + severity := 0 + orgId := "test-org-id" + + mockConfig, mockHTTPClient, mockInstrumentor, mockErrorReporter, _, mockTrackerFactory, logger := setupLegacy(t, nil, false, "", orgId) + + var capturedRequestBody []byte + expectedAnalysisUrl := "http://localhost/analysis" + mockHTTPClient.EXPECT().Do(mock.MatchedBy(func(i interface{}) bool { + req := i.(*http.Request) + if req.URL.String() == expectedAnalysisUrl && req.Method == http.MethodPost { + body, _ := io.ReadAll(req.Body) + capturedRequestBody = body + req.Body = io.NopCloser(bytes.NewReader(body)) + return true + } + return false + })).Times(1).Return(&http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + Body: io.NopCloser(bytes.NewReader([]byte(`{"type":"sarif","progress":1.0,"status":"COMPLETE"}`))), + }, nil) + + analysisOrchestrator := analysis.NewAnalysisOrchestrator( + mockConfig, + mockHTTPClient, + analysis.WithLogger(&logger), + analysis.WithInstrumentor(mockInstrumentor), + analysis.WithTrackerFactory(mockTrackerFactory), + analysis.WithErrorReporter(mockErrorReporter), + ) + + // run method under test + _, _, err := analysisOrchestrator.RunLegacyTest( + context.Background(), + bundleHash, + shardKey, + limitToFiles, + severity, + ) + + require.NoError(t, err) + + // Parse and validate the request body + var request map[string]interface{} + err = json.Unmarshal(capturedRequestBody, &request) + require.NoError(t, err) + + key := request["key"].(map[string]interface{}) + // When shardKey is empty, it should still be included but as empty string (since shard field doesn't have omitempty) + assert.Contains(t, key, "shard") + assert.Equal(t, "", key["shard"]) + + // When severity is 0, it should not be included in the request + assert.NotContains(t, request, "severity") +} + +func TestAnalysis_GetCodeApiUrl_Regular(t *testing.T) { + // Test regular (non-fedramp) URL + orgId := "test-org-id" + mockConfig, mockHTTPClient, mockInstrumentor, mockErrorReporter, _, mockTrackerFactory, logger := setupLegacy(t, nil, false, "https://api.snyk.io", orgId) + + // We can't directly test getCodeApiUrl since it's unexported, but we can verify the URL used in requests + expectedAnalysisUrl := "https://api.snyk.io/analysis" + mockHTTPClient.EXPECT().Do(mock.MatchedBy(func(i interface{}) bool { + req := i.(*http.Request) + return req.URL.String() == expectedAnalysisUrl + })).Times(1).Return(&http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + Body: io.NopCloser(bytes.NewReader([]byte(`{"type":"sarif","progress":1.0,"status":"COMPLETE"}`))), + }, nil) + + analysisOrchestrator := analysis.NewAnalysisOrchestrator( + mockConfig, + mockHTTPClient, + analysis.WithLogger(&logger), + analysis.WithInstrumentor(mockInstrumentor), + analysis.WithTrackerFactory(mockTrackerFactory), + analysis.WithErrorReporter(mockErrorReporter), + ) + + _, _, err := analysisOrchestrator.RunLegacyTest(context.Background(), "hash", "", []string{}, 0) + require.NoError(t, err) +} + +func TestAnalysis_GetCodeApiUrl_Fedramp(t *testing.T) { + // Test fedramp URL transformation + orgId := "test-org-id" + mockConfig, mockHTTPClient, mockInstrumentor, mockErrorReporter, _, mockTrackerFactory, logger := setupLegacy(t, nil, true, "https://deeproxy.snyk.io", orgId) + + // Verify the fedramp URL transformation: deeproxy -> api and adds org path + expectedAnalysisUrl := fmt.Sprintf("https://api.snyk.io/hidden/orgs/%s/code/analysis", orgId) + mockHTTPClient.EXPECT().Do(mock.MatchedBy(func(i interface{}) bool { + req := i.(*http.Request) + return req.URL.String() == expectedAnalysisUrl + })).Times(1).Return(&http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + Body: io.NopCloser(bytes.NewReader([]byte(`{"type":"sarif","progress":1.0,"status":"COMPLETE"}`))), + }, nil) + + analysisOrchestrator := analysis.NewAnalysisOrchestrator( + mockConfig, + mockHTTPClient, + analysis.WithLogger(&logger), + analysis.WithInstrumentor(mockInstrumentor), + analysis.WithTrackerFactory(mockTrackerFactory), + analysis.WithErrorReporter(mockErrorReporter), + ) + + _, _, err := analysisOrchestrator.RunLegacyTest(context.Background(), "hash", "", []string{}, 0) + require.NoError(t, err) +} + +func TestAnalysis_GetCodeApiUrl_InvalidURL(t *testing.T) { + // Test with malformed URL in fedramp mode + orgId := "test-org-id" + mockConfig, mockHTTPClient, mockInstrumentor, mockErrorReporter, _, mockTrackerFactory, logger := setupLegacy(t, nil, true, ":::invalid-url", orgId) + + analysisOrchestrator := analysis.NewAnalysisOrchestrator( + mockConfig, + mockHTTPClient, + analysis.WithLogger(&logger), + analysis.WithInstrumentor(mockInstrumentor), + analysis.WithTrackerFactory(mockTrackerFactory), + analysis.WithErrorReporter(mockErrorReporter), + ) + + _, _, err := analysisOrchestrator.RunLegacyTest(context.Background(), "hash", "", []string{}, 0) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid") +} diff --git a/internal/analysis/mocks/analysis.go b/internal/analysis/mocks/analysis.go index 05f487a..9f7a796 100644 --- a/internal/analysis/mocks/analysis.go +++ b/internal/analysis/mocks/analysis.go @@ -38,6 +38,22 @@ func (m *MockAnalysisOrchestrator) EXPECT() *MockAnalysisOrchestratorMockRecorde return m.recorder } +// 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() + ret := m.ctrl.Call(m, "RunLegacyTest", ctx, bundleHash, shardKey, limitToFiles, severity) + ret0, _ := ret[0].(*sarif.SarifResponse) + ret1, _ := ret[1].(scan.LegacyScanStatus) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// RunLegacyTest indicates an expected call of RunLegacyTest. +func (mr *MockAnalysisOrchestratorMockRecorder) RunLegacyTest(ctx, bundleHash, shardKey, limitToFiles, severity interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RunLegacyTest", reflect.TypeOf((*MockAnalysisOrchestrator)(nil).RunLegacyTest), ctx, bundleHash, shardKey, limitToFiles, severity) +} + // RunTest mocks base method. func (m *MockAnalysisOrchestrator) RunTest(ctx context.Context, orgId string, b bundle.Bundle, target scan.Target, reportingOptions analysis.AnalysisConfig) (*sarif.SarifResponse, *scan.ResultMetaData, error) { m.ctrl.T.Helper() diff --git a/internal/deepcode/client.go b/internal/deepcode/client.go index 2a63564..f5d4982 100644 --- a/internal/deepcode/client.go +++ b/internal/deepcode/client.go @@ -28,11 +28,10 @@ import ( "regexp" "strconv" + "github.com/rs/zerolog" "github.com/snyk/code-client-go/config" "github.com/snyk/code-client-go/internal/util/encoding" - "github.com/rs/zerolog" - codeClientHTTP "github.com/snyk/code-client-go/http" "github.com/snyk/code-client-go/observability" ) diff --git a/llm/api_client.go b/llm/api_client.go index 465127d..09e750d 100644 --- a/llm/api_client.go +++ b/llm/api_client.go @@ -11,6 +11,8 @@ import ( "net/http" "net/url" "strings" + + codeClientHTTP "github.com/snyk/code-client-go/http" ) var ( @@ -74,7 +76,7 @@ func (d *DeepCodeLLMBindingImpl) submitRequest(ctx context.Context, url *url.URL return nil, err } - d.addDefaultHeaders(req, span.GetTraceId(), orgId) + codeClientHTTP.AddDefaultHeaders(req, span.GetTraceId(), orgId) resp, err := d.httpClientFunc().Do(req) //nolint:bodyclose // this seems to be a false positive if err != nil { @@ -258,15 +260,3 @@ func prepareDiffs(diffs []string) []string { } return encodedDiffs } - -func (d *DeepCodeLLMBindingImpl) addDefaultHeaders(req *http.Request, requestId string, orgId string) { - // if requestId is empty it will be enriched from the Gateway - if len(requestId) > 0 { - req.Header.Set("snyk-request-id", requestId) - } - if len(orgId) > 0 { - req.Header.Set("snyk-org-name", orgId) - } - req.Header.Set("Cache-Control", "private, max-age=0, no-cache") - req.Header.Set("Content-Type", "application/json") -} diff --git a/llm/api_client_test.go b/llm/api_client_test.go index 704c8b1..e46270e 100644 --- a/llm/api_client_test.go +++ b/llm/api_client_test.go @@ -14,6 +14,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + codeClientHTTP "github.com/snyk/code-client-go/http" "github.com/snyk/code-client-go/observability" ) @@ -262,10 +263,9 @@ func testLogger(t *testing.T) *zerolog.Logger { // Test with existing headers func TestAddDefaultHeadersWithExistingHeaders(t *testing.T) { - d := &DeepCodeLLMBindingImpl{} // Initialize your struct if needed req := &http.Request{Header: http.Header{"Existing-Header": {"existing-value"}}} - d.addDefaultHeaders(req, "", "") + codeClientHTTP.AddDefaultHeaders(req, "", "") cacheControl := req.Header.Get("Cache-Control") contentType := req.Header.Get("Content-Type") diff --git a/scan.go b/scan.go index 50d5e98..bf762b3 100644 --- a/scan.go +++ b/scan.go @@ -19,6 +19,8 @@ package codeclient import ( "context" + "fmt" + "time" "github.com/google/uuid" "github.com/pkg/errors" @@ -63,6 +65,16 @@ type CodeScanner interface { files <-chan string, changedFiles map[string]bool, ) (*sarif.SarifResponse, string, error) + + UploadAndAnalyzeLegacy( + ctx context.Context, + requestId string, + target scan.Target, + shardKey string, + files <-chan string, + changedFiles map[string]bool, + statusChannel chan<- scan.LegacyScanStatus, + ) (*sarif.SarifResponse, string, error) } var _ CodeScanner = (*codeScanner)(nil) @@ -235,7 +247,6 @@ func (c *codeScanner) checkCancellationOrLogError(ctx context.Context, targetPat return returnError } -// UploadAndAnalyze returns a fake SARIF response for testing. Use target-service to run analysis on. func (c *codeScanner) UploadAndAnalyze( ctx context.Context, requestId string, @@ -247,7 +258,69 @@ func (c *codeScanner) UploadAndAnalyze( return response, bundleHash, err } -// UploadAndAnalyzeWithOptions returns a fake SARIF response for testing. Use target-service to run analysis on. +func (c *codeScanner) UploadAndAnalyzeLegacy( + ctx context.Context, + requestId string, + target scan.Target, + shardKey string, + files <-chan string, + changedFiles map[string]bool, + statusChannel chan<- scan.LegacyScanStatus, +) (*sarif.SarifResponse, string, error) { + defer close(statusChannel) + uploadedBundle, err := c.Upload(ctx, requestId, target, files, changedFiles) + if err != nil || uploadedBundle == nil || uploadedBundle.GetBundleHash() == "" { + c.logger.Debug().Msg("empty bundle, no Snyk Code analysis") + return nil, "", err + } + + response, bundleHash, err := c.analyzeLegacy(ctx, uploadedBundle, shardKey, statusChannel) + return response, bundleHash, err +} + +func (c *codeScanner) analyzeLegacy( + ctx context.Context, + bundle bundle.Bundle, + shardKey string, + statusChannel chan<- scan.LegacyScanStatus, +) (*sarif.SarifResponse, string, error) { + bundleHash := bundle.GetBundleHash() + limitToFiles := bundle.GetLimitToFiles() + severity := 0 + + start := time.Now() + for { + response, status, err := c.analysisOrchestrator.RunLegacyTest(ctx, bundleHash, shardKey, limitToFiles, severity) + + if err != nil { + c.logger.Error().Err(err). + Int("fileCount", len(bundle.GetFiles())). + Msg("error retrieving diagnostics...") + statusChannel <- scan.NewLegacyScanDoneStatus(fmt.Sprintf("Analysis failed: %v", err)) + return nil, "", err + } + + if status.Message == analysis.StatusComplete { + c.logger.Trace().Msg("sending diagnostics...") + statusChannel <- scan.NewLegacyScanDoneStatus("Analysis complete") + return response, bundleHash, err + } else if status.Message == analysis.StatusAnalyzing { + c.logger.Trace().Msg("\"Analyzing\" message received") + } + + if time.Since(start) > c.config.SnykCodeAnalysisTimeout() { + err := errors.New("analysis call timed out") + c.logger.Error().Err(err).Msg("timeout...") + statusChannel <- scan.NewLegacyScanDoneStatus("Snyk Code Analysis timed out") + return nil, "", err + } + + time.Sleep(1 * time.Second) + c.logger.Trace().Msg("sending In-Progress message to client") + statusChannel <- status + } +} + func (c *codeScanner) UploadAndAnalyzeWithOptions( ctx context.Context, requestId string, diff --git a/scan/tracker.go b/scan/tracker.go index a96eb13..acaa41d 100644 --- a/scan/tracker.go +++ b/scan/tracker.go @@ -24,3 +24,15 @@ type Tracker interface { Begin(title, message string) End(message string) } + +type LegacyScanStatus struct { + Message string + Percentage int +} + +func NewLegacyScanDoneStatus(message string) LegacyScanStatus { + return LegacyScanStatus{ + Percentage: 90, + Message: message, + } +}