diff --git a/connectors/defectdojo/const.go b/connectors/defectdojo/const.go index 7c563d1..2dc0101 100644 --- a/connectors/defectdojo/const.go +++ b/connectors/defectdojo/const.go @@ -7,6 +7,8 @@ const ( CreateEngagementPath = "/engagements/" UpdateEngagementPath = "/engagements/%d/" ImportScanPath = "/import-scan/" + ReimportScanPath = "/reimport-scan/" + GetTestsPath = "/tests/?engagement=%d&scan_type=%s" GetFindingsPath = "/findings/?test__engagement=%d&active=true&offset=%d&limit=%d" ) @@ -36,7 +38,9 @@ const ( logErrorUnknownType = "Unknow type [%s]" logErrorAddFile = "Cannot attach file to request" logErrorCreateMultipartRequest = "Cannot create multipart request" - logErrorImportScan = "Cannot import scan via %s (HTTP %d)" + logErrorImportScan = "Cannot import scan via %s (HTTP %d)" + logErrorReimportScan = "Cannot reimport scan via %s (HTTP %d)" + logErrorRetrieveTests = "Cannot retrieve tests for engagement ID %d" logErrorRetrieveFindings = "Cannot retrieve findings for engagement ID %d" ) @@ -51,5 +55,7 @@ const ( errUpdateEngagementEndDate = "cannot update engagement end date" errWritingFile = "cannot write file to request" errImportScan = "cannot import scan" + errReimportScan = "cannot reimport scan" + errRetrieveTests = "cannot retrieve tests" errRetrieveFindings = "cannot retrieve findings" ) diff --git a/connectors/defectdojo/dto.go b/connectors/defectdojo/dto.go index 36ad20c..66b8ec6 100644 --- a/connectors/defectdojo/dto.go +++ b/connectors/defectdojo/dto.go @@ -65,6 +65,16 @@ type GetFindingsResponse struct { Results []Finding `json:"results"` } +type Test struct { + Id int `json:"id"` + ScanType string `json:"scan_type"` +} + +type GetTestsResponse struct { + Count int `json:"count"` + Results []Test `json:"results"` +} + type ScanPayload struct { Timestamp string `json:"scan_date" form:"scan_date"` SeverityThreshold string `json:"minimum_severity" form:"minimum_severity"` @@ -77,4 +87,5 @@ type ScanPayload struct { ScanType string `json:"scan_type" form:"scan_type"` EngagementId int `json:"engagement" form:"engagement"` CloseOldFinding bool `json:"close_old_findings_product_scope" form:"close_old_findings_product_scope"` + TestId int `json:"test,omitempty" form:"test"` } diff --git a/connectors/defectdojo/service.go b/connectors/defectdojo/service.go index 3e951f3..22221ff 100644 --- a/connectors/defectdojo/service.go +++ b/connectors/defectdojo/service.go @@ -7,6 +7,7 @@ import ( "fmt" "mime/multipart" "net/http" + "net/url" "reflect" "ScopeGuardian/connectors/defectdojo/client" "ScopeGuardian/logger" @@ -21,6 +22,8 @@ type DefectDojoService interface { GetEngagements(productId uint, offset int, limit int, engagements []Engagement) ([]Engagement, error) UpdateEngagementEndDate(engagementId, productId int, protected bool) (bool, error) ImportScan(payload ScanPayload, filename string) (bool, error) + ReimportScan(payload ScanPayload, filename string) (bool, error) + GetTests(engagementId int, scanType string) ([]Test, error) GetFindings(engagementId int, offset int, limit int, findings []Finding) ([]Finding, error) SetAccessToken(token string) SetURL(url string) @@ -221,6 +224,51 @@ func (s *DefectDojoServiceImpl) ImportScan(payload ScanPayload, filename string) return true, nil } +// ReimportScan reimports a scan result file into an existing test in DefectDojo using the +// given ScanPayload. It finds the matching test by scan_type and engagement automatically. +// filename is used as the multipart form file name. Returns true on success. +func (s *DefectDojoServiceImpl) ReimportScan(payload ScanPayload, filename string) (bool, error) { + body, boundary, err := createMultipartFromScanPayload(payload, filename) + if err != nil { + logger.Error(logErrorCreateMultipartRequest) + return false, err + } + + headers := s.client.GetHeaders(s.accessToken) + headers.Set(client.ContentTypeKey, boundary) + + _, code := s.client.Post(fmt.Sprintf( + "%s%s%s", s.url, APIPrefix, ReimportScanPath), body, headers) + + if code < http.StatusOK || code >= http.StatusMultipleChoices { + logger.Error(fmt.Sprintf(logErrorReimportScan, ReimportScanPath, code)) + return false, errors.New(errReimportScan) + } + + return true, nil +} + +// GetTests retrieves all tests for the given engagement and scan type from DefectDojo. +// It returns the list of matching tests or an error if the request fails. +func (s *DefectDojoServiceImpl) GetTests(engagementId int, scanType string) ([]Test, error) { + var res GetTestsResponse + + body, code := s.client.Get(fmt.Sprintf( + "%s%s%s", s.url, APIPrefix, fmt.Sprintf(GetTestsPath, engagementId, url.QueryEscape(scanType))), s.client.GetHeaders(s.accessToken)) + if code != http.StatusOK { + logger.Error(fmt.Sprintf(logErrorRetrieveTests, engagementId)) + return []Test{}, errors.New(errRetrieveTests) + } + + err := json.Unmarshal(body, &res) + if err != nil { + logger.Error(fmt.Sprintf(logErrorDecodingToken, err.Error())) + return []Test{}, errors.New(errUnmarshal) + } + + return res.Results, nil +} + // createMultipartFromScanPayload serialises a ScanPayload into a multipart/form-data // body. Struct fields are mapped to form keys via the "form" struct tag. The raw // scan file is appended under the "file" key using filename. It returns the body @@ -252,6 +300,9 @@ func createMultipartFromScanPayload(payload ScanPayload, filename string) ([]byt logger.Error(fmt.Sprintf(logErrorReflection, formKey)) } case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + if value.Int() == 0 { + continue + } valStr := strconv.FormatInt(value.Int(), 10) if err := writer.WriteField(formKey, valStr); err != nil { logger.Error(fmt.Sprintf(logErrorReflection, formKey)) diff --git a/connectors/defectdojo/service_mock.go b/connectors/defectdojo/service_mock.go index 56a0006..e319b88 100644 --- a/connectors/defectdojo/service_mock.go +++ b/connectors/defectdojo/service_mock.go @@ -108,6 +108,36 @@ func (mr *MockDefectDojoServiceMockRecorder) ImportScan(payload, filename interf return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ImportScan", reflect.TypeOf((*MockDefectDojoService)(nil).ImportScan), payload, filename) } +// ReimportScan mocks base method. +func (m *MockDefectDojoService) ReimportScan(payload ScanPayload, filename string) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ReimportScan", payload, filename) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ReimportScan indicates an expected call of ReimportScan. +func (mr *MockDefectDojoServiceMockRecorder) ReimportScan(payload, filename interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReimportScan", reflect.TypeOf((*MockDefectDojoService)(nil).ReimportScan), payload, filename) +} + +// GetTests mocks base method. +func (m *MockDefectDojoService) GetTests(engagementId int, scanType string) ([]Test, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTests", engagementId, scanType) + ret0, _ := ret[0].([]Test) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetTests indicates an expected call of GetTests. +func (mr *MockDefectDojoServiceMockRecorder) GetTests(engagementId, scanType interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTests", reflect.TypeOf((*MockDefectDojoService)(nil).GetTests), engagementId, scanType) +} + // SetAccessToken mocks base method. func (m *MockDefectDojoService) SetAccessToken(token string) { m.ctrl.T.Helper() diff --git a/connectors/defectdojo/service_mock_test.go b/connectors/defectdojo/service_mock_test.go index eaef0cf..b0dd685 100644 --- a/connectors/defectdojo/service_mock_test.go +++ b/connectors/defectdojo/service_mock_test.go @@ -121,6 +121,52 @@ func TestMockDefectDojoService_ImportScan_ReturnsError(t *testing.T) { assert.False(t, ok) } +func TestMockDefectDojoService_ReimportScan_Succeeds(t *testing.T) { + ctrl := gomock.NewController(t) + mock := NewMockDefectDojoService(ctrl) + + mock.EXPECT().ReimportScan(gomock.Any(), "results.json").Return(true, nil) + + ok, err := mock.ReimportScan(ScanPayload{}, "results.json") + assert.Nil(t, err) + assert.True(t, ok) +} + +func TestMockDefectDojoService_ReimportScan_ReturnsError(t *testing.T) { + ctrl := gomock.NewController(t) + mock := NewMockDefectDojoService(ctrl) + + mock.EXPECT().ReimportScan(gomock.Any(), gomock.Any()).Return(false, errors.New(errReimportScan)) + + ok, err := mock.ReimportScan(ScanPayload{}, "results.json") + assert.NotNil(t, err) + assert.False(t, ok) +} + +func TestMockDefectDojoService_GetTests_Succeeds(t *testing.T) { + ctrl := gomock.NewController(t) + mock := NewMockDefectDojoService(ctrl) + + expected := []Test{{Id: 5, ScanType: "KICS Scan"}} + mock.EXPECT().GetTests(42, "KICS Scan").Return(expected, nil) + + tests, err := mock.GetTests(42, "KICS Scan") + assert.Nil(t, err) + assert.Len(t, tests, 1) + assert.Equal(t, 5, tests[0].Id) +} + +func TestMockDefectDojoService_GetTests_ReturnsError(t *testing.T) { + ctrl := gomock.NewController(t) + mock := NewMockDefectDojoService(ctrl) + + mock.EXPECT().GetTests(gomock.Any(), gomock.Any()).Return([]Test{}, errors.New(errRetrieveTests)) + + tests, err := mock.GetTests(42, "KICS Scan") + assert.NotNil(t, err) + assert.Empty(t, tests) +} + func TestMockDefectDojoService_SetAccessToken(t *testing.T) { ctrl := gomock.NewController(t) mock := NewMockDefectDojoService(ctrl) diff --git a/connectors/defectdojo/service_test.go b/connectors/defectdojo/service_test.go index 9ab5418..e81e8f6 100644 --- a/connectors/defectdojo/service_test.go +++ b/connectors/defectdojo/service_test.go @@ -491,6 +491,30 @@ func TestCreateMultipartFromScanPayload(t *testing.T) { assert.Nil(t, err) }) + + t.Run("Should omit test field when TestId is zero", func(t *testing.T) { + var payload ScanPayload + payload.EngagementId = 12 + payload.File, _ = os.ReadFile("../../features/scans/kics/mocks/working_results/results/kics-results.json") + // TestId left at zero + + body, _, err := createMultipartFromScanPayload(payload, "test.json") + + assert.Nil(t, err) + assert.NotContains(t, string(body), "name=\"test\"") + }) + + t.Run("Should include test field when TestId is non-zero", func(t *testing.T) { + var payload ScanPayload + payload.EngagementId = 12 + payload.TestId = 42 + payload.File, _ = os.ReadFile("../../features/scans/kics/mocks/working_results/results/kics-results.json") + + body, _, err := createMultipartFromScanPayload(payload, "test.json") + + assert.Nil(t, err) + assert.Contains(t, string(body), "name=\"test\"") + }) } func TestSetURL(t *testing.T) { @@ -509,6 +533,145 @@ func TestSetAccessToken(t *testing.T) { assert.Equal(t, "new-token-xyz", impl.accessToken) } +func TestReimportScan(t *testing.T) { + gomockController := gomock.NewController(t) + + t.Run("Should reimport scan with 201 Created", func(t *testing.T) { + clientMock := client.NewMockClient(gomockController) + + clientMock.EXPECT().Post(gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte(""), 201).AnyTimes() + clientMock.EXPECT().GetHeaders(gomock.Any()).Return(http.Header{}).AnyTimes() + + service := newDefectDojoService(clientMock, URL, TOKEN) + + ok, err := service.ReimportScan(ScanPayload{}, "../../features/scans/kics/mocks/working_results/results/kics-results.json") + + assert.Nil(t, err) + assert.True(t, ok) + }) + + t.Run("Should reimport scan with 200 OK", func(t *testing.T) { + clientMock := client.NewMockClient(gomockController) + + clientMock.EXPECT().Post(gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte(""), 200).AnyTimes() + clientMock.EXPECT().GetHeaders(gomock.Any()).Return(http.Header{}).AnyTimes() + + service := newDefectDojoService(clientMock, URL, TOKEN) + + ok, err := service.ReimportScan(ScanPayload{}, "../../features/scans/kics/mocks/working_results/results/kics-results.json") + + assert.Nil(t, err) + assert.True(t, ok) + }) + + t.Run("Should not reimport scan", func(t *testing.T) { + clientMock := client.NewMockClient(gomockController) + + clientMock.EXPECT().Post(gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte(""), 403).AnyTimes() + clientMock.EXPECT().GetHeaders(gomock.Any()).Return(http.Header{}).AnyTimes() + + service := newDefectDojoService(clientMock, URL, TOKEN) + + ok, err := service.ReimportScan(ScanPayload{}, "../../features/scans/kics/mocks/working_results/results/kics-results.json") + + assert.NotNil(t, err) + assert.EqualValues(t, errReimportScan, err.Error()) + assert.False(t, ok) + }) +} + +func TestGetTests(t *testing.T) { + gomockController := gomock.NewController(t) + + t.Run("Should retrieve tests for an engagement and scan type", func(t *testing.T) { + clientMock := client.NewMockClient(gomockController) + + responseReturnMock := []byte(` + { + "count": 1, + "results": [ + { + "id": 5, + "scan_type": "KICS Scan" + } + ] + } + `) + + clientMock.EXPECT().GetHeaders(gomock.Any()).Return(http.Header{}).AnyTimes() + clientMock.EXPECT().Get(gomock.Any(), gomock.Any()).Return(responseReturnMock, 200) + + service := newDefectDojoService(clientMock, URL, TOKEN) + + tests, err := service.GetTests(42, "KICS Scan") + + assert.Nil(t, err) + assert.EqualValues(t, 1, len(tests)) + assert.EqualValues(t, 5, tests[0].Id) + assert.EqualValues(t, "KICS Scan", tests[0].ScanType) + }) + + t.Run("Should return empty slice when no tests exist", func(t *testing.T) { + clientMock := client.NewMockClient(gomockController) + + responseReturnMock := []byte(` + { + "count": 0, + "results": [] + } + `) + + clientMock.EXPECT().GetHeaders(gomock.Any()).Return(http.Header{}).AnyTimes() + clientMock.EXPECT().Get(gomock.Any(), gomock.Any()).Return(responseReturnMock, 200) + + service := newDefectDojoService(clientMock, URL, TOKEN) + + tests, err := service.GetTests(42, "KICS Scan") + + assert.Nil(t, err) + assert.EqualValues(t, 0, len(tests)) + }) + + t.Run("Should not retrieve tests due to wrong HTTP status code", func(t *testing.T) { + clientMock := client.NewMockClient(gomockController) + + clientMock.EXPECT().GetHeaders(gomock.Any()).Return(http.Header{}).AnyTimes() + clientMock.EXPECT().Get(gomock.Any(), gomock.Any()).Return([]byte(`{}`), 403) + + service := newDefectDojoService(clientMock, URL, TOKEN) + + tests, err := service.GetTests(42, "KICS Scan") + + assert.NotNil(t, err) + assert.Equal(t, errRetrieveTests, err.Error()) + assert.EqualValues(t, 0, len(tests)) + }) + + t.Run("Should not retrieve tests due to wrong JSON object", func(t *testing.T) { + clientMock := client.NewMockClient(gomockController) + + responseReturnMock := []byte(` + { + "count": 1, + "results": [ + { + "id": 1 + } + `) + + clientMock.EXPECT().GetHeaders(gomock.Any()).Return(http.Header{}).AnyTimes() + clientMock.EXPECT().Get(gomock.Any(), gomock.Any()).Return(responseReturnMock, 200) + + service := newDefectDojoService(clientMock, URL, TOKEN) + + tests, err := service.GetTests(42, "KICS Scan") + + assert.NotNil(t, err) + assert.Equal(t, errUnmarshal, err.Error()) + assert.EqualValues(t, 0, len(tests)) + }) +} + func TestGetFindings(t *testing.T) { gomockController := gomock.NewController(t) diff --git a/features/scans/grype/const.go b/features/scans/grype/const.go index 0a80dc6..05619d0 100644 --- a/features/scans/grype/const.go +++ b/features/scans/grype/const.go @@ -39,6 +39,8 @@ const ( logErrorFileNotFound = "Cannot find file [%s]" logErrorParseResults = "Cannot parse grype results" logErrorImportScan = "Cannot import scan into engagement [%d]" + logErrorReimportScan = "Cannot reimport scan into engagement [%d]" + logErrorGetTests = "Cannot get tests for engagement [%d]" ) const ( diff --git a/features/scans/grype/service.go b/features/scans/grype/service.go index 3e7a69c..93e2f9b 100644 --- a/features/scans/grype/service.go +++ b/features/scans/grype/service.go @@ -116,7 +116,8 @@ func (s *GrypeServiceImpl) LoadFindings() ([]models.Finding, error) { // Sync uploads the Grype scan output to DefectDojo via the given service. // It constructs a ScanPayload from the stored output file and the provided -// engagement ID and branch, then calls ImportScan. +// engagement ID and branch, then calls ReimportScan if a test with this scan +// type already exists for the engagement, or ImportScan otherwise. func (s *GrypeServiceImpl) Sync(engagementId int, branch string, service defectdojo.DefectDojoService) error { var payload defectdojo.ScanPayload @@ -137,10 +138,24 @@ func (s *GrypeServiceImpl) Sync(engagementId int, branch string, service defectd } payload.File = fileContent - if ok, err := service.ImportScan(payload, s.output); !ok || err != nil { - logger.Error(fmt.Sprintf(logErrorImportScan, engagementId)) + tests, err := service.GetTests(engagementId, scanType) + if err != nil { + logger.Error(fmt.Sprintf(logErrorGetTests, engagementId)) return err } + if len(tests) > 0 { + payload.TestId = tests[0].Id + if ok, err := service.ReimportScan(payload, s.output); !ok || err != nil { + logger.Error(fmt.Sprintf(logErrorReimportScan, engagementId)) + return err + } + } else { + if ok, err := service.ImportScan(payload, s.output); !ok || err != nil { + logger.Error(fmt.Sprintf(logErrorImportScan, engagementId)) + return err + } + } + return nil } diff --git a/features/scans/grype/service_test.go b/features/scans/grype/service_test.go index fd07b84..2097ed9 100644 --- a/features/scans/grype/service_test.go +++ b/features/scans/grype/service_test.go @@ -13,8 +13,13 @@ import ( ) type mockDefectDojoService struct { - importScanOk bool - importScanErr error + importScanOk bool + importScanErr error + reimportScanOk bool + reimportScanErr error + reimportedPayload defectdojo.ScanPayload + testsToReturn []defectdojo.Test + getTestsErr error } func (m *mockDefectDojoService) GetProductByName(_ string) (defectdojo.Product, error) { @@ -37,6 +42,15 @@ func (m *mockDefectDojoService) ImportScan(_ defectdojo.ScanPayload, _ string) ( return m.importScanOk, m.importScanErr } +func (m *mockDefectDojoService) ReimportScan(payload defectdojo.ScanPayload, _ string) (bool, error) { + m.reimportedPayload = payload + return m.reimportScanOk, m.reimportScanErr +} + +func (m *mockDefectDojoService) GetTests(_ int, _ string) ([]defectdojo.Test, error) { + return m.testsToReturn, m.getTestsErr +} + func (m *mockDefectDojoService) SetAccessToken(_ string) {} func (m *mockDefectDojoService) SetURL(_ string) {} @@ -119,7 +133,7 @@ func TestLoadFindings(t *testing.T) { } func TestSync(t *testing.T) { - t.Run("Should sync successfully", func(t *testing.T) { + t.Run("Should sync successfully using import when no tests exist", func(t *testing.T) { _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "./mocks/working_results")) environment_variable.ReloadEnv() @@ -131,6 +145,22 @@ func TestSync(t *testing.T) { assert.Nil(t, err) }) + t.Run("Should sync successfully using reimport when tests exist", func(t *testing.T) { + _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "./mocks/working_results")) + environment_variable.ReloadEnv() + + service := newGrypeService(loader.Grype{}) + ddMock := &mockDefectDojoService{ + testsToReturn: []defectdojo.Test{{Id: 3, ScanType: "Anchore Grype"}}, + reimportScanOk: true, + } + + err := service.Sync(1, "main", ddMock) + + assert.Nil(t, err) + assert.EqualValues(t, 3, ddMock.reimportedPayload.TestId) + }) + t.Run("Should return error when import scan fails", func(t *testing.T) { _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "./mocks/working_results")) environment_variable.ReloadEnv() @@ -143,6 +173,34 @@ func TestSync(t *testing.T) { assert.NotNil(t, err) }) + t.Run("Should return error when reimport scan fails", func(t *testing.T) { + _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "./mocks/working_results")) + environment_variable.ReloadEnv() + + service := newGrypeService(loader.Grype{}) + ddMock := &mockDefectDojoService{ + testsToReturn: []defectdojo.Test{{Id: 3, ScanType: "Anchore Grype"}}, + reimportScanOk: false, + reimportScanErr: fmt.Errorf("reimport failed"), + } + + err := service.Sync(1, "main", ddMock) + + assert.NotNil(t, err) + }) + + t.Run("Should return error when GetTests fails", func(t *testing.T) { + _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "./mocks/working_results")) + environment_variable.ReloadEnv() + + service := newGrypeService(loader.Grype{}) + ddMock := &mockDefectDojoService{getTestsErr: fmt.Errorf("cannot retrieve tests")} + + err := service.Sync(1, "main", ddMock) + + assert.NotNil(t, err) + }) + t.Run("Should return error when output file not found", func(t *testing.T) { _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "")) environment_variable.ReloadEnv() diff --git a/features/scans/kics/const.go b/features/scans/kics/const.go index 4a903b8..f52b189 100644 --- a/features/scans/kics/const.go +++ b/features/scans/kics/const.go @@ -42,6 +42,8 @@ const ( logErrorFileNotFound = "Cannot find file [%s]" logErrorParseResults = "Cannot parse kics results" logErrorImportScan = "Cannot import scan into engagement [%d]" + logErrorReimportScan = "Cannot reimport scan into engagement [%d]" + logErrorGetTests = "Cannot get tests for engagement [%d]" ) const ( diff --git a/features/scans/kics/service.go b/features/scans/kics/service.go index 7b9045f..a946c1d 100644 --- a/features/scans/kics/service.go +++ b/features/scans/kics/service.go @@ -126,7 +126,8 @@ func (s *KicsServiceImpl) LoadFindings() ([]models.Finding, error) { // Sync uploads the KICS scan output to DefectDojo via the given service. // It constructs a ScanPayload from the stored output file and the provided -// engagement ID and branch, then calls ImportScan. +// engagement ID and branch, then calls ReimportScan if a test with this scan +// type already exists for the engagement, or ImportScan otherwise. func (s *KicsServiceImpl) Sync(engagementId int, branch string, service defectdojo.DefectDojoService) error { var payload defectdojo.ScanPayload @@ -147,10 +148,24 @@ func (s *KicsServiceImpl) Sync(engagementId int, branch string, service defectdo } payload.File = fileContent - if ok, err := service.ImportScan(payload, s.output); !ok || err != nil { - logger.Error(fmt.Sprintf(logErrorImportScan, engagementId)) + tests, err := service.GetTests(engagementId, scanType) + if err != nil { + logger.Error(fmt.Sprintf(logErrorGetTests, engagementId)) return err } + if len(tests) > 0 { + payload.TestId = tests[0].Id + if ok, err := service.ReimportScan(payload, s.output); !ok || err != nil { + logger.Error(fmt.Sprintf(logErrorReimportScan, engagementId)) + return err + } + } else { + if ok, err := service.ImportScan(payload, s.output); !ok || err != nil { + logger.Error(fmt.Sprintf(logErrorImportScan, engagementId)) + return err + } + } + return nil } diff --git a/features/scans/kics/service_test.go b/features/scans/kics/service_test.go index 851cf12..3c43454 100644 --- a/features/scans/kics/service_test.go +++ b/features/scans/kics/service_test.go @@ -13,8 +13,13 @@ import ( ) type mockDefectDojoService struct { - importScanOk bool - importScanErr error + importScanOk bool + importScanErr error + reimportScanOk bool + reimportScanErr error + reimportedPayload defectdojo.ScanPayload + testsToReturn []defectdojo.Test + getTestsErr error } func (m *mockDefectDojoService) GetProductByName(_ string) (defectdojo.Product, error) { @@ -37,6 +42,15 @@ func (m *mockDefectDojoService) ImportScan(_ defectdojo.ScanPayload, _ string) ( return m.importScanOk, m.importScanErr } +func (m *mockDefectDojoService) ReimportScan(payload defectdojo.ScanPayload, _ string) (bool, error) { + m.reimportedPayload = payload + return m.reimportScanOk, m.reimportScanErr +} + +func (m *mockDefectDojoService) GetTests(_ int, _ string) ([]defectdojo.Test, error) { + return m.testsToReturn, m.getTestsErr +} + func (m *mockDefectDojoService) SetAccessToken(_ string) {} func (m *mockDefectDojoService) SetURL(_ string) {} @@ -124,7 +138,7 @@ func TestLoadFinding(t *testing.T) { // } func TestSync(t *testing.T) { - t.Run("Should sync successfully", func(t *testing.T) { + t.Run("Should sync successfully using import when no tests exist", func(t *testing.T) { _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "./mocks/working_results")) environment_variable.ReloadEnv() @@ -136,6 +150,22 @@ func TestSync(t *testing.T) { assert.Nil(t, err) }) + t.Run("Should sync successfully using reimport when tests exist", func(t *testing.T) { + _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "./mocks/working_results")) + environment_variable.ReloadEnv() + + service := newKicsService("./test", loader.Kics{}) + ddMock := &mockDefectDojoService{ + testsToReturn: []defectdojo.Test{{Id: 5, ScanType: "KICS Scan"}}, + reimportScanOk: true, + } + + err := service.Sync(1, "main", ddMock) + + assert.Nil(t, err) + assert.EqualValues(t, 5, ddMock.reimportedPayload.TestId) + }) + t.Run("Should return error when import scan fails", func(t *testing.T) { _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "./mocks/working_results")) environment_variable.ReloadEnv() @@ -148,6 +178,34 @@ func TestSync(t *testing.T) { assert.NotNil(t, err) }) + t.Run("Should return error when reimport scan fails", func(t *testing.T) { + _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "./mocks/working_results")) + environment_variable.ReloadEnv() + + service := newKicsService("./test", loader.Kics{}) + ddMock := &mockDefectDojoService{ + testsToReturn: []defectdojo.Test{{Id: 5, ScanType: "KICS Scan"}}, + reimportScanOk: false, + reimportScanErr: fmt.Errorf("reimport failed"), + } + + err := service.Sync(1, "main", ddMock) + + assert.NotNil(t, err) + }) + + t.Run("Should return error when GetTests fails", func(t *testing.T) { + _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "./mocks/working_results")) + environment_variable.ReloadEnv() + + service := newKicsService("./test", loader.Kics{}) + ddMock := &mockDefectDojoService{getTestsErr: fmt.Errorf("cannot retrieve tests")} + + err := service.Sync(1, "main", ddMock) + + assert.NotNil(t, err) + }) + t.Run("Should return error when output file not found", func(t *testing.T) { _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "")) environment_variable.ReloadEnv() diff --git a/features/scans/opengrep/const.go b/features/scans/opengrep/const.go index a767617..a5624ed 100644 --- a/features/scans/opengrep/const.go +++ b/features/scans/opengrep/const.go @@ -35,6 +35,8 @@ const ( logErrorFileNotFound = "Cannot find file [%s]" logErrorParseResults = "Cannot parse opengrep results" logErrorImportScan = "Cannot import scan into engagement [%d]" + logErrorReimportScan = "Cannot reimport scan into engagement [%d]" + logErrorGetTests = "Cannot get tests for engagement [%d]" ) const ( diff --git a/features/scans/opengrep/service.go b/features/scans/opengrep/service.go index 4c971a5..714ba04 100644 --- a/features/scans/opengrep/service.go +++ b/features/scans/opengrep/service.go @@ -199,7 +199,8 @@ func enrichOpenGrepResults(data []byte) []byte { // Sync uploads the OpenGrep scan output to DefectDojo via the given service. // It constructs a ScanPayload from the stored output file and the provided -// engagement ID and branch, then calls ImportScan. +// engagement ID and branch, then calls ReimportScan if a test with this scan +// type already exists for the engagement, or ImportScan otherwise. func (s *OpenGrepServiceImpl) Sync(engagementId int, branch string, service defectdojo.DefectDojoService) error { var payload defectdojo.ScanPayload @@ -221,10 +222,24 @@ func (s *OpenGrepServiceImpl) Sync(engagementId int, branch string, service defe } payload.File = enrichOpenGrepResults(fileContent) - if ok, err := service.ImportScan(payload, s.output); !ok || err != nil { - logger.Error(fmt.Sprintf(logErrorImportScan, engagementId)) + tests, err := service.GetTests(engagementId, scanType) + if err != nil { + logger.Error(fmt.Sprintf(logErrorGetTests, engagementId)) return err } + if len(tests) > 0 { + payload.TestId = tests[0].Id + if ok, err := service.ReimportScan(payload, s.output); !ok || err != nil { + logger.Error(fmt.Sprintf(logErrorReimportScan, engagementId)) + return err + } + } else { + if ok, err := service.ImportScan(payload, s.output); !ok || err != nil { + logger.Error(fmt.Sprintf(logErrorImportScan, engagementId)) + return err + } + } + return nil } diff --git a/features/scans/opengrep/service_test.go b/features/scans/opengrep/service_test.go index 773f0e8..e22b030 100644 --- a/features/scans/opengrep/service_test.go +++ b/features/scans/opengrep/service_test.go @@ -15,8 +15,13 @@ import ( ) type mockDefectDojoService struct { - importScanOk bool - importScanErr error + importScanOk bool + importScanErr error + reimportScanOk bool + reimportScanErr error + reimportedPayload defectdojo.ScanPayload + testsToReturn []defectdojo.Test + getTestsErr error } func (m *mockDefectDojoService) GetProductByName(_ string) (defectdojo.Product, error) { @@ -39,6 +44,15 @@ func (m *mockDefectDojoService) ImportScan(_ defectdojo.ScanPayload, _ string) ( return m.importScanOk, m.importScanErr } +func (m *mockDefectDojoService) ReimportScan(payload defectdojo.ScanPayload, _ string) (bool, error) { + m.reimportedPayload = payload + return m.reimportScanOk, m.reimportScanErr +} + +func (m *mockDefectDojoService) GetTests(_ int, _ string) ([]defectdojo.Test, error) { + return m.testsToReturn, m.getTestsErr +} + func (m *mockDefectDojoService) SetAccessToken(_ string) {} func (m *mockDefectDojoService) SetURL(_ string) {} @@ -244,7 +258,7 @@ func TestEnrichOpenGrepResults(t *testing.T) { } func TestOpenGrepSync(t *testing.T) { - t.Run("Should sync successfully", func(t *testing.T) { + t.Run("Should sync successfully using import when no tests exist", func(t *testing.T) { _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "./mocks/working_results")) environment_variable.ReloadEnv() @@ -256,6 +270,22 @@ func TestOpenGrepSync(t *testing.T) { assert.Nil(t, err) }) + t.Run("Should sync successfully using reimport when tests exist", func(t *testing.T) { + _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "./mocks/working_results")) + environment_variable.ReloadEnv() + + service := newOpenGrepService("./test", loader.Opengrep{}) + ddMock := &mockDefectDojoService{ + testsToReturn: []defectdojo.Test{{Id: 7, ScanType: "Semgrep JSON Report"}}, + reimportScanOk: true, + } + + err := service.Sync(1, "main", ddMock) + + assert.Nil(t, err) + assert.EqualValues(t, 7, ddMock.reimportedPayload.TestId) + }) + t.Run("Should return error when import scan fails", func(t *testing.T) { _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "./mocks/working_results")) environment_variable.ReloadEnv() @@ -268,6 +298,34 @@ func TestOpenGrepSync(t *testing.T) { assert.NotNil(t, err) }) + t.Run("Should return error when reimport scan fails", func(t *testing.T) { + _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "./mocks/working_results")) + environment_variable.ReloadEnv() + + service := newOpenGrepService("./test", loader.Opengrep{}) + ddMock := &mockDefectDojoService{ + testsToReturn: []defectdojo.Test{{Id: 7, ScanType: "Semgrep JSON Report"}}, + reimportScanOk: false, + reimportScanErr: fmt.Errorf("reimport failed"), + } + + err := service.Sync(1, "main", ddMock) + + assert.NotNil(t, err) + }) + + t.Run("Should return error when GetTests fails", func(t *testing.T) { + _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "./mocks/working_results")) + environment_variable.ReloadEnv() + + service := newOpenGrepService("./test", loader.Opengrep{}) + ddMock := &mockDefectDojoService{getTestsErr: fmt.Errorf("cannot retrieve tests")} + + err := service.Sync(1, "main", ddMock) + + assert.NotNil(t, err) + }) + t.Run("Should return error when output file not found", func(t *testing.T) { _ = os.Setenv("SCAN_DIR", fmt.Sprintf("%s/%s", environment_variable.EnvironmentVariable["PWD"], "")) environment_variable.ReloadEnv()