From 222c47142a359624468f1cbb995a3755646415f7 Mon Sep 17 00:00:00 2001 From: peimingming Date: Sun, 11 Aug 2019 00:27:07 +0800 Subject: [PATCH] Add chart and scanning event for webhook Signed-off-by: peimingming --- src/chartserver/reverse_proxy.go | 40 +++- src/common/const.go | 3 +- src/core/api/chart_repository.go | 88 +++++++++ src/core/notifier/event/event.go | 103 +++++++++++ .../handler/notification/chart_handler.go | 107 +++++++++++ .../notification/chart_handler_test.go | 171 ++++++++++++++++++ .../handler/notification/processor.go | 28 +++ .../notification/scan_image_handler.go | 127 +++++++++++++ .../notification/scan_image_handler_test.go | 108 +++++++++++ src/core/notifier/model/event.go | 25 ++- src/core/notifier/topic/topics.go | 13 +- .../service/notifications/jobs/handler.go | 16 ++ 12 files changed, 820 insertions(+), 9 deletions(-) create mode 100644 src/core/notifier/handler/notification/chart_handler.go create mode 100644 src/core/notifier/handler/notification/chart_handler_test.go create mode 100644 src/core/notifier/handler/notification/scan_image_handler.go create mode 100644 src/core/notifier/handler/notification/scan_image_handler_test.go diff --git a/src/chartserver/reverse_proxy.go b/src/chartserver/reverse_proxy.go index c11025c77..b0804d662 100644 --- a/src/chartserver/reverse_proxy.go +++ b/src/chartserver/reverse_proxy.go @@ -15,9 +15,11 @@ import ( "github.com/goharbor/harbor/src/common" hlog "github.com/goharbor/harbor/src/common/utils/log" + n_event "github.com/goharbor/harbor/src/core/notifier/event" "github.com/goharbor/harbor/src/replication" rep_event "github.com/goharbor/harbor/src/replication/event" "github.com/justinas/alice" + "time" ) const ( @@ -110,8 +112,44 @@ func modifyResponse(res *http.Response) error { hlog.Errorf("failed to handle event: %v", err) } }() - } + // Trigger harbor webhook + if e != nil && e.Resource != nil && e.Resource.Metadata != nil && len(e.Resource.Metadata.Vtags) > 0 && + len(e.Resource.ExtendedInfo) > 0 { + event := &n_event.Event{} + metaData := &n_event.ChartUploadMetaData{ + ChartMetaData: n_event.ChartMetaData{ + ProjectName: e.Resource.ExtendedInfo["projectName"].(string), + ChartName: e.Resource.ExtendedInfo["chartName"].(string), + Versions: e.Resource.Metadata.Vtags, + OccurAt: time.Now(), + Operator: e.Resource.ExtendedInfo["operator"].(string), + }, + } + if err := event.Build(metaData); err != nil { + hlog.Errorf("failed to build chart upload event metadata: %v", err) + } + if err := event.Publish(); err != nil { + hlog.Errorf("failed to publish chart upload event: %v", err) + } + } + } + } + + // Process downloading chart success webhook event + if res.StatusCode == http.StatusOK { + chartDownloadEvent := res.Request.Context().Value(common.ChartDownloadCtxKey) + eventMetaData, ok := chartDownloadEvent.(*n_event.ChartDownloadMetaData) + if ok && eventMetaData != nil { + // Trigger harbor webhook + event := &n_event.Event{} + if err := event.Build(eventMetaData); err != nil { + hlog.Errorf("failed to build chart download event metadata: %v", err) + } + if err := event.Publish(); err != nil { + hlog.Errorf("failed to publish chart download event: %v", err) + } + } } // Accept cases diff --git a/src/common/const.go b/src/common/const.go index f2778a48e..b7582439e 100755 --- a/src/common/const.go +++ b/src/common/const.go @@ -142,7 +142,8 @@ const ( OIDCCallbackPath = "/c/oidc/callback" OIDCLoginPath = "/c/oidc/login" - ChartUploadCtxKey = contextKey("chart_upload_event") + ChartUploadCtxKey = contextKey("chart_upload_event") + ChartDownloadCtxKey = contextKey("chart_download_event") // Global notification enable configuration NotificationEnable = "notification_enable" diff --git a/src/core/api/chart_repository.go b/src/core/api/chart_repository.go index dd9be934f..9e4f16d9c 100755 --- a/src/core/api/chart_repository.go +++ b/src/core/api/chart_repository.go @@ -20,13 +20,18 @@ import ( "github.com/goharbor/harbor/src/core/label" "github.com/goharbor/harbor/src/core/middlewares" + n_event "github.com/goharbor/harbor/src/core/notifier/event" rep_event "github.com/goharbor/harbor/src/replication/event" "github.com/goharbor/harbor/src/replication/model" + "path" + "strconv" + "time" ) const ( namespaceParam = ":repo" nameParam = ":name" + filenameParam = ":filename" defaultRepo = "library" rootUploadingEndpoint = "/api/chartrepo/charts" rootIndexEndpoint = "/chartrepo/index.yaml" @@ -42,6 +47,8 @@ const ( formFiledNameForProv = "prov" headerContentType = "Content-Type" contentTypeMultipart = "multipart/form-data" + // chartPackageFileExtension is the file extension used for chart packages + chartPackageFileExtension = "tgz" ) // chartController is a singleton instance @@ -181,6 +188,11 @@ func (cra *ChartRepositoryAPI) DownloadChart() { return } + namespace := cra.GetStringFromPath(namespaceParam) + fileName := cra.GetStringFromPath(filenameParam) + // Add hook event to request context + cra.addDownloadChartEventContext(fileName, namespace, cra.Ctx.Request) + // Directly proxy to the backend chartController.ProxyTraffic(cra.Ctx.ResponseWriter, cra.Ctx.Request) } @@ -278,6 +290,23 @@ func (cra *ChartRepositoryAPI) DeleteChartVersion() { cra.ParseAndHandleError("fail to delete chart version", err) return } + + event := &n_event.Event{} + metaData := &n_event.ChartDeleteMetaData{ + ChartMetaData: n_event.ChartMetaData{ + ProjectName: cra.namespace, + ChartName: chartName, + Versions: []string{version}, + OccurAt: time.Now(), + Operator: cra.SecurityCtx.GetUsername(), + }, + } + if err := event.Build(metaData); err != nil { + hlog.Errorf("failed to build chart delete event metadata: %v", err) + } + if err := event.Publish(); err != nil { + hlog.Errorf("failed to publish chart delete event: %v", err) + } } // UploadChartVersion handles POST /api/:repo/charts @@ -355,13 +384,32 @@ func (cra *ChartRepositoryAPI) DeleteChart() { return } + versions := []string{} for _, chartVersion := range chartVersions { + versions = append(versions, chartVersion.GetVersion()) if err := cra.removeLabelsFromChart(chartName, chartVersion.GetVersion()); err != nil { cra.SendInternalServerError(err) return } } + event := &n_event.Event{} + metaData := &n_event.ChartDeleteMetaData{ + ChartMetaData: n_event.ChartMetaData{ + ProjectName: cra.namespace, + ChartName: chartName, + Versions: versions, + OccurAt: time.Now(), + Operator: cra.SecurityCtx.GetUsername(), + }, + } + if err := event.Build(metaData); err != nil { + hlog.Errorf("failed to build chart delete event metadata: %v", err) + } + if err := event.Publish(); err != nil { + hlog.Errorf("failed to publish chart delete event: %v", err) + } + if err := chartController.DeleteChart(cra.namespace, chartName); err != nil { cra.SendInternalServerError(err) return @@ -446,6 +494,10 @@ func (cra *ChartRepositoryAPI) addEventContext(files []formFile, request *http.R return err } + extInfo := make(map[string]interface{}) + extInfo["operator"] = cra.SecurityCtx.GetUsername() + extInfo["projectName"] = cra.namespace + extInfo["chartName"] = chartDetails.Metadata.Name e := &rep_event.Event{ Type: rep_event.EventTypeChartUpload, Resource: &model.Resource{ @@ -456,6 +508,7 @@ func (cra *ChartRepositoryAPI) addEventContext(files []formFile, request *http.R }, Vtags: []string{chartDetails.Metadata.Version}, }, + ExtendedInfo: extInfo, }, } *request = *(request.WithContext(context.WithValue(request.Context(), common.ChartUploadCtxKey, e))) @@ -466,6 +519,20 @@ func (cra *ChartRepositoryAPI) addEventContext(files []formFile, request *http.R return nil } +func (cra *ChartRepositoryAPI) addDownloadChartEventContext(fileName, namespace string, request *http.Request) { + chartName, version := parseChartVersionFromFilename(fileName) + event := &n_event.ChartDownloadMetaData{ + ChartMetaData: n_event.ChartMetaData{ + ProjectName: namespace, + ChartName: chartName, + Versions: []string{version}, + OccurAt: time.Now(), + Operator: cra.SecurityCtx.GetUsername(), + }, + } + *request = *(request.WithContext(context.WithValue(request.Context(), common.ChartDownloadCtxKey, event))) +} + // If the files are uploaded with multipart/form-data mimetype, beego will extract the data // from the request automatically. Then the request passed to the backend server with proxying // way will have empty content. @@ -555,3 +622,24 @@ func chartFullName(namespace, chartName, version string) string { } return fmt.Sprintf("%s/%s:%s", namespace, chartName, version) } + +// parseChartVersionFromFilename parse chart and version from file name +func parseChartVersionFromFilename(filename string) (string, string) { + noExt := strings.TrimSuffix(path.Base(filename), fmt.Sprintf(".%s", chartPackageFileExtension)) + parts := strings.Split(noExt, "-") + name := parts[0] + version := "" + for idx, part := range parts[1:] { + if _, err := strconv.Atoi(string(part[0])); err == nil { // see if this part looks like a version (starts w int) + version = strings.Join(parts[idx+1:], "-") + break + } + name = fmt.Sprintf("%s-%s", name, part) + } + if version == "" { // no parts looked like a real version, just take everything after last hyphen + lastIndex := len(parts) - 1 + name = strings.Join(parts[:lastIndex], "-") + version = parts[lastIndex] + } + return name, version +} diff --git a/src/core/notifier/event/event.go b/src/core/notifier/event/event.go index 088a8d2e7..fb88328e8 100644 --- a/src/core/notifier/event/event.go +++ b/src/core/notifier/event/event.go @@ -11,6 +11,10 @@ import ( "github.com/pkg/errors" ) +const ( + autoTriggeredOperator = "auto" +) + // Event to publish type Event struct { Topic string @@ -111,6 +115,105 @@ func (i *ImagePullMetaData) Resolve(evt *Event) error { return nil } +// ChartMetaData defines meta data of chart event +type ChartMetaData struct { + ProjectName string + ChartName string + Versions []string + OccurAt time.Time + Operator string +} + +func (cmd *ChartMetaData) convert(evt *model.ChartEvent) { + evt.ProjectName = cmd.ProjectName + evt.OccurAt = cmd.OccurAt + evt.Operator = cmd.Operator + evt.ChartName = cmd.ChartName + evt.Versions = cmd.Versions +} + +// ChartUploadMetaData defines meta data of chart upload event +type ChartUploadMetaData struct { + ChartMetaData +} + +// Resolve chart uploading metadata into common chart event +func (cu *ChartUploadMetaData) Resolve(evt *Event) error { + data := &model.ChartEvent{ + EventType: notifyModel.EventTypeUploadChart, + } + cu.convert(data) + + evt.Topic = model.UploadChartTopic + evt.Data = data + return nil +} + +// ChartDownloadMetaData defines meta data of chart download event +type ChartDownloadMetaData struct { + ChartMetaData +} + +// Resolve chart download metadata into common chart event +func (cd *ChartDownloadMetaData) Resolve(evt *Event) error { + data := &model.ChartEvent{ + EventType: notifyModel.EventTypeDownloadChart, + } + cd.convert(data) + + evt.Topic = model.DownloadChartTopic + evt.Data = data + return nil +} + +// ChartDeleteMetaData defines meta data of chart delete event +type ChartDeleteMetaData struct { + ChartMetaData +} + +// Resolve chart delete metadata into common chart event +func (cd *ChartDeleteMetaData) Resolve(evt *Event) error { + data := &model.ChartEvent{ + EventType: notifyModel.EventTypeDeleteChart, + } + cd.convert(data) + + evt.Topic = model.DeleteChartTopic + evt.Data = data + return nil +} + +// ScanImageMetaData defines meta data of image scanning event +type ScanImageMetaData struct { + JobID int64 + Status string +} + +// Resolve image scanning metadata into common chart event +func (si *ScanImageMetaData) Resolve(evt *Event) error { + var eventType string + var topic string + if si.Status == models.JobFinished { + eventType = notifyModel.EventTypeScanningCompleted + topic = model.ScanningCompletedTopic + } else if si.Status == models.JobError { + eventType = notifyModel.EventTypeScanningFailed + topic = model.ScanningFailedTopic + } else { + return errors.New("not supported scan hook status") + } + data := &model.ScanImageEvent{ + EventType: eventType, + JobID: si.JobID, + OccurAt: time.Now(), + Operator: autoTriggeredOperator, + } + + evt.Topic = topic + evt.Data = data + return nil +} + // HookMetaData defines hook notification related event data type HookMetaData struct { PolicyID int64 diff --git a/src/core/notifier/handler/notification/chart_handler.go b/src/core/notifier/handler/notification/chart_handler.go new file mode 100644 index 000000000..bb549c8a0 --- /dev/null +++ b/src/core/notifier/handler/notification/chart_handler.go @@ -0,0 +1,107 @@ +package notification + +import ( + "errors" + "fmt" + "github.com/goharbor/harbor/src/common/models" + "github.com/goharbor/harbor/src/common/utils/log" + "github.com/goharbor/harbor/src/core/config" + "github.com/goharbor/harbor/src/core/notifier/model" + "github.com/goharbor/harbor/src/pkg/notification" +) + +// ChartPreprocessHandler preprocess chart event data +type ChartPreprocessHandler struct { +} + +// Handle preprocess chart event data and then publish hook event +func (cph *ChartPreprocessHandler) Handle(value interface{}) error { + // if global notification configured disabled, return directly + if !config.NotificationEnable() { + log.Debug("notification feature is not enabled") + return nil + } + + chartEvent, ok := value.(*model.ChartEvent) + if !ok { + return errors.New("invalid chart event type") + } + + if chartEvent == nil || len(chartEvent.Versions) == 0 || len(chartEvent.ProjectName) == 0 || len(chartEvent.ChartName) == 0 { + return fmt.Errorf("data miss in chart event: %v", chartEvent) + } + + project, err := config.GlobalProjectMgr.Get(chartEvent.ProjectName) + if err != nil { + log.Errorf("failed to find project[%s] for chart event: %v", chartEvent.ProjectName, err) + return err + } + if project == nil { + return fmt.Errorf("project not found for chart event: %s", chartEvent.ProjectName) + } + policies, err := notification.PolicyMgr.GetRelatedPolices(project.ProjectID, chartEvent.EventType) + if err != nil { + log.Errorf("failed to find policy for %s event: %v", chartEvent.EventType, err) + return err + } + // if cannot find policy including event type in project, return directly + if len(policies) == 0 { + log.Debugf("cannot find policy for %s event: %v", chartEvent.EventType, chartEvent) + return nil + } + + payload, err := constructChartPayload(chartEvent, project) + if err != nil { + return err + } + + err = sendHookWithPolicies(policies, payload, chartEvent.EventType) + if err != nil { + return err + } + + return nil +} + +// IsStateful ... +func (cph *ChartPreprocessHandler) IsStateful() bool { + return false +} + +func constructChartPayload(event *model.ChartEvent, project *models.Project) (*model.Payload, error) { + repoType := models.ProjectPrivate + if project.IsPublic() { + repoType = models.ProjectPublic + } + + payload := &model.Payload{ + Type: event.EventType, + OccurAt: event.OccurAt.Unix(), + EventData: &model.EventData{ + Repository: &model.Repository{ + Name: event.ChartName, + Namespace: event.ProjectName, + RepoFullName: fmt.Sprintf("%s/%s", event.ProjectName, event.ChartName), + RepoType: repoType, + }, + }, + Operator: event.Operator, + } + + extURL, err := config.ExtEndpoint() + if err != nil { + return nil, fmt.Errorf("get external endpoint failed: %v", err) + } + + resourcePrefix := fmt.Sprintf("%s/chartrepo/%s/charts/%s", extURL, event.ProjectName, event.ChartName) + for _, v := range event.Versions { + resURL := fmt.Sprintf("%s-%s.tgz", resourcePrefix, v) + + resource := &model.Resource{ + Tag: v, + ResourceURL: resURL, + } + payload.EventData.Resources = append(payload.EventData.Resources, resource) + } + return payload, nil +} diff --git a/src/core/notifier/handler/notification/chart_handler_test.go b/src/core/notifier/handler/notification/chart_handler_test.go new file mode 100644 index 000000000..0e27c3d89 --- /dev/null +++ b/src/core/notifier/handler/notification/chart_handler_test.go @@ -0,0 +1,171 @@ +package notification + +import ( + "github.com/goharbor/harbor/src/common/models" + "github.com/goharbor/harbor/src/core/config" + "github.com/goharbor/harbor/src/core/notifier/model" + "github.com/goharbor/harbor/src/pkg/notification" + notificationModel "github.com/goharbor/harbor/src/pkg/notification/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "testing" +) + +type fakedPolicyMgr struct { +} + +func (f *fakedPolicyMgr) Create(*models.NotificationPolicy) (int64, error) { + return 0, nil +} + +func (f *fakedPolicyMgr) List(id int64) ([]*models.NotificationPolicy, error) { + return nil, nil +} + +func (f *fakedPolicyMgr) Get(id int64) (*models.NotificationPolicy, error) { + return nil, nil +} + +func (f *fakedPolicyMgr) GetByNameAndProjectID(string, int64) (*models.NotificationPolicy, error) { + return nil, nil +} + +func (f *fakedPolicyMgr) Update(*models.NotificationPolicy) error { + return nil +} + +func (f *fakedPolicyMgr) Delete(int64) error { + return nil +} + +func (f *fakedPolicyMgr) Test(*models.NotificationPolicy) error { + return nil +} + +func (f *fakedPolicyMgr) GetRelatedPolices(id int64, eventType string) ([]*models.NotificationPolicy, error) { + return []*models.NotificationPolicy{ + { + ID: 1, + EventTypes: []string{ + notificationModel.EventTypeUploadChart, + notificationModel.EventTypeDownloadChart, + notificationModel.EventTypeDeleteChart, + notificationModel.EventTypeScanningCompleted, + notificationModel.EventTypeScanningFailed, + }, + Targets: []models.EventTarget{ + { + Type: "http", + Address: "http://127.0.0.1:8080", + }, + }, + }, + }, nil +} + +func TestChartPreprocessHandler_Handle(t *testing.T) { + PolicyMgr := notification.PolicyMgr + defer func() { + notification.PolicyMgr = PolicyMgr + }() + notification.PolicyMgr = &fakedPolicyMgr{} + + handler := &ChartPreprocessHandler{} + config.Init() + + name := "project_for_test_chart_event_preprocess" + id, _ := config.GlobalProjectMgr.Create(&models.Project{ + Name: name, + OwnerID: 1, + Metadata: map[string]string{ + models.ProMetaEnableContentTrust: "true", + models.ProMetaPreventVul: "true", + models.ProMetaSeverity: "low", + models.ProMetaReuseSysCVEWhitelist: "false", + }, + }) + defer func(id int64) { + if err := config.GlobalProjectMgr.Delete(id); err != nil { + t.Logf("failed to delete project %d: %v", id, err) + } + }(id) + + type args struct { + data interface{} + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "ChartPreprocessHandler Want Error 1", + args: args{ + data: nil, + }, + wantErr: true, + }, + { + name: "ChartPreprocessHandler Want Error 2", + args: args{ + data: &model.ChartEvent{}, + }, + wantErr: true, + }, + { + name: "ChartPreprocessHandler Want Error 3", + args: args{ + data: &model.ChartEvent{ + Versions: []string{ + "v1.2.1", + }, + ProjectName: "project_for_test_chart_event_preprocess", + }, + }, + wantErr: true, + }, + { + name: "ChartPreprocessHandler Want Error 4", + args: args{ + data: &model.ChartEvent{ + Versions: []string{ + "v1.2.1", + }, + ProjectName: "project_for_test_chart_event_preprocess_not_exists", + ChartName: "testChart", + }, + }, + wantErr: true, + }, + { + name: "ChartPreprocessHandler Want Error 5", + args: args{ + data: &model.ChartEvent{ + Versions: []string{ + "v1.2.1", + }, + ProjectName: "project_for_test_chart_event_preprocess", + ChartName: "testChart", + EventType: "uploadChart", + }, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := handler.Handle(tt.args.data) + if tt.wantErr { + require.NotNil(t, err, "Error: %s", err) + return + } + assert.Nil(t, err) + }) + } +} + +func TestChartPreprocessHandler_IsStateful(t *testing.T) { + handler := &ChartPreprocessHandler{} + assert.False(t, handler.IsStateful()) +} diff --git a/src/core/notifier/handler/notification/processor.go b/src/core/notifier/handler/notification/processor.go index 513640fd2..a2b3f5524 100644 --- a/src/core/notifier/handler/notification/processor.go +++ b/src/core/notifier/handler/notification/processor.go @@ -12,6 +12,7 @@ import ( "github.com/goharbor/harbor/src/core/notifier/event" notifyModel "github.com/goharbor/harbor/src/core/notifier/model" "github.com/goharbor/harbor/src/pkg/notification" + pkgNotifyModel "github.com/goharbor/harbor/src/pkg/notification/model" ) // getNameFromImgRepoFullName gets image name from repo full name with format `repoName/imageName` @@ -172,3 +173,30 @@ func preprocessAndSendImageHook(value interface{}) error { return nil } + +// will return nil when it failed to get data +func getScanOverview(digest string, tag string, eventType string) *models.ImgScanOverview { + if len(digest) == 0 { + log.Debug("digest is nil") + return nil + } + data, err := dao.GetImgScanOverview(digest) + if err != nil { + log.Errorf("Failed to get scan result for tag:%s, digest: %s, error: %v", tag, digest, err) + } + if data == nil { + return nil + } + + // Status should set by the eventType but the status from jobData in DB + if eventType == pkgNotifyModel.EventTypeScanningCompleted { + data.Status = models.JobFinished + } else { + log.Debugf("Unsetting vulnerable related historical values, job status: %s", data.Status) + data.Status = models.JobError + data.Sev = 0 + data.CompOverview = nil + data.DetailsKey = "" + } + return data +} diff --git a/src/core/notifier/handler/notification/scan_image_handler.go b/src/core/notifier/handler/notification/scan_image_handler.go new file mode 100644 index 000000000..c3e479e8e --- /dev/null +++ b/src/core/notifier/handler/notification/scan_image_handler.go @@ -0,0 +1,127 @@ +package notification + +import ( + "errors" + "fmt" + "github.com/goharbor/harbor/src/common/dao" + "github.com/goharbor/harbor/src/common/models" + "github.com/goharbor/harbor/src/common/utils/log" + "github.com/goharbor/harbor/src/core/config" + "github.com/goharbor/harbor/src/core/notifier/model" + "github.com/goharbor/harbor/src/pkg/notification" + "strings" +) + +// ScanImagePreprocessHandler preprocess chart event data +type ScanImagePreprocessHandler struct { +} + +// Handle preprocess chart event data and then publish hook event +func (si *ScanImagePreprocessHandler) Handle(value interface{}) error { + // if global notification configured disabled, return directly + if !config.NotificationEnable() { + log.Debug("notification feature is not enabled") + return nil + } + + e, ok := value.(*model.ScanImageEvent) + if !ok { + return errors.New("invalid scan image event type") + } + + if e == nil { + return errors.New("empty scan image event") + } + + job, err := dao.GetScanJob(e.JobID) + if err != nil { + log.Errorf("failed to find scan job[%d] for scanning webhook: %v", e.JobID, err) + return err + } + if job == nil { + log.Errorf("can't find scan job[%d] for scanning webhook", e.JobID) + return fmt.Errorf("scan job for scanning webhook not found: %d", e.JobID) + } + + rs := strings.SplitN(job.Repository, "/", 2) + projectName := rs[0] + repoName := rs[1] + + project, err := config.GlobalProjectMgr.Get(projectName) + if err != nil { + log.Errorf("failed to find project[%s] for scan image event: %v", projectName, err) + return err + } + policies, err := notification.PolicyMgr.GetRelatedPolices(project.ProjectID, e.EventType) + if err != nil { + log.Errorf("failed to find policy for %s event: %v", e.EventType, err) + return err + } + // if cannot find policy including event type in project, return directly + if len(policies) == 0 { + log.Debugf("cannot find policy for %s event: %v", e.EventType, e) + return nil + } + + payload, err := constructScanImagePayload(e, job, project, projectName, repoName) + if err != nil { + return err + } + + err = sendHookWithPolicies(policies, payload, e.EventType) + if err != nil { + return err + } + + return nil +} + +// IsStateful ... +func (si *ScanImagePreprocessHandler) IsStateful() bool { + return false +} + +func constructScanImagePayload(event *model.ScanImageEvent, job *models.ScanJob, project *models.Project, projectName, repoName string) (*model.Payload, error) { + repoType := models.ProjectPrivate + if project.IsPublic() { + repoType = models.ProjectPublic + } + + payload := &model.Payload{ + Type: event.EventType, + OccurAt: event.OccurAt.Unix(), + EventData: &model.EventData{ + Repository: &model.Repository{ + Name: repoName, + Namespace: projectName, + RepoFullName: job.Repository, + RepoType: repoType, + }, + }, + Operator: event.Operator, + } + + extURL, err := config.ExtURL() + if err != nil { + return nil, fmt.Errorf("get external endpoint failed: %v", err) + } + resURL, _ := buildImageResourceURL(extURL, job.Repository, job.Tag) + + // Add scan overview + scanOverview := getScanOverview(job.Digest, job.Tag, event.EventType) + if scanOverview == nil { + scanOverview = &models.ImgScanOverview{ + JobID: job.ID, + Status: job.Status, + CreationTime: job.CreationTime, + } + } + resource := &model.Resource{ + Tag: job.Tag, + Digest: job.Digest, + ResourceURL: resURL, + ScanOverview: scanOverview, + } + payload.EventData.Resources = append(payload.EventData.Resources, resource) + return payload, nil +} diff --git a/src/core/notifier/handler/notification/scan_image_handler_test.go b/src/core/notifier/handler/notification/scan_image_handler_test.go new file mode 100644 index 000000000..9eee2b154 --- /dev/null +++ b/src/core/notifier/handler/notification/scan_image_handler_test.go @@ -0,0 +1,108 @@ +package notification + +import ( + "github.com/goharbor/harbor/src/common/dao" + "github.com/goharbor/harbor/src/common/models" + "github.com/goharbor/harbor/src/core/config" + "github.com/goharbor/harbor/src/core/notifier/model" + "github.com/goharbor/harbor/src/pkg/notification" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "testing" + "time" +) + +func TestScanImagePreprocessHandler_Handle(t *testing.T) { + PolicyMgr := notification.PolicyMgr + defer func() { + notification.PolicyMgr = PolicyMgr + }() + notification.PolicyMgr = &fakedPolicyMgr{} + + handler := &ScanImagePreprocessHandler{} + config.Init() + + name := "project_for_test_scanning_event_preprocess" + id, _ := config.GlobalProjectMgr.Create(&models.Project{ + Name: name, + OwnerID: 1, + Metadata: map[string]string{ + models.ProMetaEnableContentTrust: "true", + models.ProMetaPreventVul: "true", + models.ProMetaSeverity: "low", + models.ProMetaReuseSysCVEWhitelist: "false", + }, + }) + defer func(id int64) { + if err := config.GlobalProjectMgr.Delete(id); err != nil { + t.Logf("failed to delete project %d: %v", id, err) + } + }(id) + + jID, _ := dao.AddScanJob(models.ScanJob{ + Status: "finished", + Repository: "project_for_test_scanning_event_preprocess/testrepo", + Tag: "v1.0.0", + Digest: "sha256:5a539a2c733ca9efcd62d4561b36ea93d55436c5a86825b8e43ce8303a7a0752", + CreationTime: time.Now(), + UpdateTime: time.Now(), + }) + + type args struct { + data interface{} + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "ScanImagePreprocessHandler Want Error 1", + args: args{ + data: nil, + }, + wantErr: true, + }, + { + name: "ScanImagePreprocessHandler Want Error 2", + args: args{ + data: &model.ScanImageEvent{}, + }, + wantErr: true, + }, + { + name: "ScanImagePreprocessHandler Want Error 3", + args: args{ + data: &model.ScanImageEvent{ + JobID: jID + 1000, + }, + }, + wantErr: true, + }, + { + name: "ScanImagePreprocessHandler Want Error 4", + args: args{ + data: &model.ScanImageEvent{ + JobID: jID, + }, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := handler.Handle(tt.args.data) + if tt.wantErr { + require.NotNil(t, err, "Error: %s", err) + return + } + assert.Nil(t, err) + }) + } +} + +func TestScanImagePreprocessHandler_IsStateful(t *testing.T) { + handler := &ScanImagePreprocessHandler{} + assert.False(t, handler.IsStateful()) +} diff --git a/src/core/notifier/model/event.go b/src/core/notifier/model/event.go index 67889e751..53d0ed7b3 100755 --- a/src/core/notifier/model/event.go +++ b/src/core/notifier/model/event.go @@ -22,6 +22,24 @@ type ImgResource struct { Tag string } +// ChartEvent is chart related event data to publish +type ChartEvent struct { + EventType string + ProjectName string + ChartName string + Versions []string + OccurAt time.Time + Operator string +} + +// ScanImageEvent is scanning image related event data to publish +type ScanImageEvent struct { + EventType string + JobID int64 + OccurAt time.Time + Operator string +} + // HookEvent is hook related event data to publish type HookEvent struct { PolicyID int64 @@ -46,9 +64,10 @@ type EventData struct { // Resource describe infos of resource triggered notification type Resource struct { - Digest string `json:"digest,omitempty"` - Tag string `json:"tag"` - ResourceURL string `json:"resource_url,omitempty"` + Digest string `json:"digest,omitempty"` + Tag string `json:"tag"` + ResourceURL string `json:"resource_url,omitempty"` + ScanOverview *models.ImgScanOverview `json:"scan_overview,omitempty"` } // Repository info of notification event diff --git a/src/core/notifier/topic/topics.go b/src/core/notifier/topic/topics.go index 2762da259..6a35386b4 100644 --- a/src/core/notifier/topic/topics.go +++ b/src/core/notifier/topic/topics.go @@ -10,10 +10,15 @@ import ( // Subscribe topics func init() { handlersMap := map[string][]notifier.NotificationHandler{ - model.PushImageTopic: {¬ification.ImagePreprocessHandler{}}, - model.PullImageTopic: {¬ification.ImagePreprocessHandler{}}, - model.DeleteImageTopic: {¬ification.ImagePreprocessHandler{}}, - model.WebhookTopic: {¬ification.HTTPHandler{}}, + model.PushImageTopic: {¬ification.ImagePreprocessHandler{}}, + model.PullImageTopic: {¬ification.ImagePreprocessHandler{}}, + model.DeleteImageTopic: {¬ification.ImagePreprocessHandler{}}, + model.WebhookTopic: {¬ification.HTTPHandler{}}, + model.UploadChartTopic: {¬ification.ChartPreprocessHandler{}}, + model.DownloadChartTopic: {¬ification.ChartPreprocessHandler{}}, + model.DeleteChartTopic: {¬ification.ChartPreprocessHandler{}}, + model.ScanningCompletedTopic: {¬ification.ScanImagePreprocessHandler{}}, + model.ScanningFailedTopic: {¬ification.ScanImagePreprocessHandler{}}, } for t, handlers := range handlersMap { diff --git a/src/core/service/notifications/jobs/handler.go b/src/core/service/notifications/jobs/handler.go index a5b923aba..dbf6d5f11 100755 --- a/src/core/service/notifications/jobs/handler.go +++ b/src/core/service/notifications/jobs/handler.go @@ -24,6 +24,7 @@ import ( "github.com/goharbor/harbor/src/common/models" "github.com/goharbor/harbor/src/common/utils/log" "github.com/goharbor/harbor/src/core/api" + "github.com/goharbor/harbor/src/core/notifier/event" "github.com/goharbor/harbor/src/pkg/notification" "github.com/goharbor/harbor/src/pkg/retention" "github.com/goharbor/harbor/src/replication" @@ -81,6 +82,21 @@ func (h *Handler) Prepare() { // HandleScan handles the webhook of scan job func (h *Handler) HandleScan() { log.Debugf("received san job status update event: job-%d, status-%s", h.id, h.status) + // Trigger image scan webhook event only for JobFinished and JobError status + if h.status == models.JobFinished || h.status == models.JobError { + e := &event.Event{} + metaData := &event.ScanImageMetaData{ + JobID: h.id, + Status: h.status, + } + if err := e.Build(metaData); err != nil { + log.Errorf("failed to build image scanning event metadata: %v", err) + } + if err := e.Publish(); err != nil { + log.Errorf("failed to publish image scanning event: %v", err) + } + } + if err := dao.UpdateScanJobStatus(h.id, h.status); err != nil { log.Errorf("Failed to update job status, id: %d, status: %s", h.id, h.status) h.SendInternalServerError(err)