diff --git a/docs/components/DockerHub.mdx b/docs/components/DockerHub.mdx index fae58e8294..cbfc22c5cd 100644 --- a/docs/components/DockerHub.mdx +++ b/docs/components/DockerHub.mdx @@ -10,11 +10,13 @@ import { CardGrid, LinkCard } from "@astrojs/starlight/components"; + ## Actions + @@ -74,6 +76,93 @@ This trigger generates a webhook URL in SuperPlane. Add that URL as a DockerHub } ``` + + +## On Vulnerability Scan + +The On Vulnerability Scan trigger fires when Docker Scout completes a vulnerability scan for an image in a DockerHub repository. + +### Use Cases + +- **Security gating**: Block promotion pipelines when critical vulnerabilities are detected +- **Alerting**: Notify teams when newly pushed images contain high-severity CVEs +- **Remediation workflows**: Automatically open issues or tickets for vulnerable images + +### Configuration + +- **Repository**: DockerHub repository name, in the format of `namespace/name` +- **Minimum Severity**: Optional filter — only fire when the scan contains at least one vulnerability at this severity or higher (`low`, `medium`, `high`, `critical`) + +### Webhook Setup + +This trigger generates a webhook URL in SuperPlane. Register that URL as a Docker Scout notification webhook for the selected repository so Docker Scout can deliver scan completion events. + +### Example Data + +```json +{ + "data": { + "event": { + "created_at": "2026-02-03T12:00:00Z", + "payload": { + "criticalities": { + "critical": 2, + "high": 5, + "low": 8, + "medium": 10, + "unspecified": 1 + }, + "digest": "sha256:abc123def456", + "fixable_count": 7, + "repository": { + "full_name": "superplane/demo", + "name": "demo", + "namespace": "superplane" + }, + "tag": "v1.2.3" + }, + "type": "image_vulnerability" + }, + "stream": "vulnerability" + }, + "timestamp": "2026-02-03T12:00:00Z", + "type": "dockerhub.image.vulnerability_scan" +} +``` + + + +## Delete Tag + +The Delete Tag component permanently removes a tag from a DockerHub repository. + +### Use Cases + +- **Cleanup pipelines**: Remove stale or temporary tags after a deployment succeeds +- **Release workflows**: Delete RC or beta tags once a release is promoted to stable +- **Policy enforcement**: Prune tags that violate naming conventions + +### Configuration + +- **Repository**: DockerHub repository name, in the format of `namespace/name` +- **Tag**: Image tag to delete (for example: `v1.2.3-rc1`) + +> **Warning**: This action is irreversible. The tag cannot be recovered after deletion. + +### Example Output + +```json +{ + "data": { + "namespace": "superplane", + "repository": "demo", + "tag": "v1.2.3-rc1" + }, + "timestamp": "2026-02-03T12:00:00Z", + "type": "dockerhub.deletedTag" +} +``` + ## Get Image Tag diff --git a/pkg/integrations/dockerhub/client.go b/pkg/integrations/dockerhub/client.go index 8c1f1a57a7..8bc2cecd56 100644 --- a/pkg/integrations/dockerhub/client.go +++ b/pkg/integrations/dockerhub/client.go @@ -223,3 +223,9 @@ func (c *Client) GetRepositoryTag(namespace, repository, tag string) (*Tag, erro return &result, nil } + +func (c *Client) DeleteRepositoryTag(namespace, repository, tag string) error { + path := fmt.Sprintf("/v2/namespaces/%s/repositories/%s/tags/%s", namespace, repository, tag) + _, _, err := c.doRequest(http.MethodDelete, path, nil) + return err +} diff --git a/pkg/integrations/dockerhub/delete_tag.go b/pkg/integrations/dockerhub/delete_tag.go new file mode 100644 index 0000000000..3eb946fcc9 --- /dev/null +++ b/pkg/integrations/dockerhub/delete_tag.go @@ -0,0 +1,176 @@ +package dockerhub + +import ( + "fmt" + "net/http" + "strings" + + "github.com/google/uuid" + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +type DeleteTag struct{} + +type DeleteTagConfiguration struct { + Repository string `json:"repository" mapstructure:"repository"` + Tag string `json:"tag" mapstructure:"tag"` +} + +func (c *DeleteTag) Name() string { + return "dockerhub.deleteTag" +} + +func (c *DeleteTag) Label() string { + return "Delete Tag" +} + +func (c *DeleteTag) Description() string { + return "Delete a tag from a DockerHub repository" +} + +func (c *DeleteTag) Documentation() string { + return `The Delete Tag component permanently removes a tag from a DockerHub repository. + +## Use Cases + +- **Cleanup pipelines**: Remove stale or temporary tags after a deployment succeeds +- **Release workflows**: Delete RC or beta tags once a release is promoted to stable +- **Policy enforcement**: Prune tags that violate naming conventions + +## Configuration + +- **Repository**: DockerHub repository name, in the format of ` + "`namespace/name`" + ` +- **Tag**: Image tag to delete (for example: ` + "`v1.2.3-rc1`" + `) + +> **Warning**: This action is irreversible. The tag cannot be recovered after deletion. +` +} + +func (c *DeleteTag) Icon() string { + return "docker" +} + +func (c *DeleteTag) Color() string { + return "gray" +} + +func (c *DeleteTag) ExampleOutput() map[string]any { + return deleteTagExampleOutput() +} + +func (c *DeleteTag) OutputChannels(configuration any) []core.OutputChannel { + return []core.OutputChannel{core.DefaultOutputChannel} +} + +func (c *DeleteTag) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "repository", + Label: "Repository", + Type: configuration.FieldTypeIntegrationResource, + Required: true, + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "dockerhub.repository", + }, + }, + }, + { + Name: "tag", + Label: "Tag", + Type: configuration.FieldTypeString, + Required: true, + Placeholder: "v1.2.3-rc1", + }, + } +} + +func (c *DeleteTag) Setup(ctx core.SetupContext) error { + var config DeleteTagConfiguration + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + + if strings.TrimSpace(config.Repository) == "" { + return fmt.Errorf("repository is required") + } + + if strings.TrimSpace(config.Tag) == "" { + return fmt.Errorf("tag is required") + } + + return nil +} + +func (c *DeleteTag) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { + return ctx.DefaultProcessing() +} + +func (c *DeleteTag) Execute(ctx core.ExecutionContext) error { + var config DeleteTagConfiguration + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + + repository := strings.TrimSpace(config.Repository) + if repository == "" { + return fmt.Errorf("repository is required") + } + + tag := strings.TrimSpace(config.Tag) + if tag == "" { + return fmt.Errorf("tag is required") + } + + parts := strings.Split(repository, "/") + if len(parts) != 2 { + return fmt.Errorf("repository must be in the format of namespace/name") + } + + namespace := strings.TrimSpace(parts[0]) + repositoryName := strings.TrimSpace(parts[1]) + if namespace == "" || repositoryName == "" { + return fmt.Errorf("repository must be in the format of namespace/name") + } + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + if err := client.DeleteRepositoryTag(namespace, repositoryName, tag); err != nil { + return fmt.Errorf("failed to delete tag: %w", err) + } + + return ctx.ExecutionState.Emit( + core.DefaultOutputChannel.Name, + "dockerhub.deletedTag", + []any{map[string]any{ + "namespace": namespace, + "repository": repositoryName, + "tag": tag, + }}, + ) +} + +func (c *DeleteTag) HandleWebhook(ctx core.WebhookRequestContext) (int, *core.WebhookResponseBody, error) { + return http.StatusOK, nil, nil +} + +func (c *DeleteTag) Cancel(ctx core.ExecutionContext) error { + return nil +} + +func (c *DeleteTag) Cleanup(ctx core.SetupContext) error { + return nil +} + +func (c *DeleteTag) Hooks() []core.Hook { + return []core.Hook{} +} + +func (c *DeleteTag) HandleHook(ctx core.ActionHookContext) error { + return nil +} diff --git a/pkg/integrations/dockerhub/delete_tag_test.go b/pkg/integrations/dockerhub/delete_tag_test.go new file mode 100644 index 0000000000..42093ddc5c --- /dev/null +++ b/pkg/integrations/dockerhub/delete_tag_test.go @@ -0,0 +1,94 @@ +package dockerhub + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/test/support/contexts" +) + +func Test__DeleteTag__Setup(t *testing.T) { + component := &DeleteTag{} + + t.Run("invalid configuration -> error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Integration: &contexts.IntegrationContext{}, + Metadata: &contexts.MetadataContext{}, + Configuration: "invalid", + }) + + require.ErrorContains(t, err, "failed to decode configuration") + }) + + t.Run("missing repository -> error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Integration: &contexts.IntegrationContext{}, + Metadata: &contexts.MetadataContext{}, + Configuration: map[string]any{"tag": "v1.0.0"}, + }) + + require.ErrorContains(t, err, "repository is required") + }) + + t.Run("missing tag -> error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Integration: &contexts.IntegrationContext{}, + Metadata: &contexts.MetadataContext{}, + Configuration: map[string]any{"repository": "superplane/demo"}, + }) + + require.ErrorContains(t, err, "tag is required") + }) + + t.Run("valid configuration -> no error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + HTTP: &contexts.HTTPContext{}, + Metadata: &contexts.MetadataContext{}, + Configuration: map[string]any{ + "repository": "superplane/demo", + "tag": "v1.0.0", + }, + }) + + require.NoError(t, err) + }) +} + +func Test__DeleteTag__Execute(t *testing.T) { + component := &DeleteTag{} + + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusNoContent, + Body: io.NopCloser(strings.NewReader("")), + }, + }, + } + + execState := &contexts.ExecutionStateContext{KVs: map[string]string{}} + + err := component.Execute(core.ExecutionContext{ + Integration: &contexts.IntegrationContext{ + CurrentSecrets: map[string]core.IntegrationSecret{ + accessTokenSecretName: {Name: accessTokenSecretName, Value: []byte("token")}, + }, + }, + HTTP: httpCtx, + ExecutionState: execState, + Configuration: map[string]any{ + "repository": "superplane/demo", + "tag": "v1.0.0", + }, + }) + + require.NoError(t, err) + assert.Equal(t, core.DefaultOutputChannel.Name, execState.Channel) + assert.Equal(t, "dockerhub.deletedTag", execState.Type) + require.Len(t, execState.Payloads, 1) +} diff --git a/pkg/integrations/dockerhub/dockerhub.go b/pkg/integrations/dockerhub/dockerhub.go index 2049e529ee..a29463020a 100644 --- a/pkg/integrations/dockerhub/dockerhub.go +++ b/pkg/integrations/dockerhub/dockerhub.go @@ -68,12 +68,14 @@ func (d *DockerHub) Configuration() []configuration.Field { func (d *DockerHub) Actions() []core.Action { return []core.Action{ &GetImageTag{}, + &DeleteTag{}, } } func (d *DockerHub) Triggers() []core.Trigger { return []core.Trigger{ &OnImagePush{}, + &OnVulnerabilityScan{}, } } diff --git a/pkg/integrations/dockerhub/example.go b/pkg/integrations/dockerhub/example.go index e53c724ed1..b8553efd88 100644 --- a/pkg/integrations/dockerhub/example.go +++ b/pkg/integrations/dockerhub/example.go @@ -13,12 +13,24 @@ var exampleOutputGetImageTagBytes []byte //go:embed example_data_on_image_push.json var exampleDataOnImagePushBytes []byte +//go:embed example_data_on_vulnerability_scan.json +var exampleDataOnVulnerabilityScanBytes []byte + +//go:embed example_output_delete_tag.json +var exampleOutputDeleteTagBytes []byte + var exampleOutputGetImageTagOnce sync.Once var exampleOutputGetImageTag map[string]any var exampleDataOnImagePushOnce sync.Once var exampleDataOnImagePush map[string]any +var exampleDataOnVulnerabilityScanOnce sync.Once +var exampleDataOnVulnerabilityScan map[string]any + +var exampleOutputDeleteTagOnce sync.Once +var exampleOutputDeleteTag map[string]any + func getImageTagExampleOutput() map[string]any { return utils.UnmarshalEmbeddedJSON(&exampleOutputGetImageTagOnce, exampleOutputGetImageTagBytes, &exampleOutputGetImageTag) } @@ -26,3 +38,11 @@ func getImageTagExampleOutput() map[string]any { func onImagePushExampleData() map[string]any { return utils.UnmarshalEmbeddedJSON(&exampleDataOnImagePushOnce, exampleDataOnImagePushBytes, &exampleDataOnImagePush) } + +func onVulnerabilityScanExampleData() map[string]any { + return utils.UnmarshalEmbeddedJSON(&exampleDataOnVulnerabilityScanOnce, exampleDataOnVulnerabilityScanBytes, &exampleDataOnVulnerabilityScan) +} + +func deleteTagExampleOutput() map[string]any { + return utils.UnmarshalEmbeddedJSON(&exampleOutputDeleteTagOnce, exampleOutputDeleteTagBytes, &exampleOutputDeleteTag) +} diff --git a/pkg/integrations/dockerhub/example_data_on_vulnerability_scan.json b/pkg/integrations/dockerhub/example_data_on_vulnerability_scan.json new file mode 100644 index 0000000000..97a0acb357 --- /dev/null +++ b/pkg/integrations/dockerhub/example_data_on_vulnerability_scan.json @@ -0,0 +1,28 @@ +{ + "timestamp": "2026-02-03T12:00:00Z", + "type": "dockerhub.image.vulnerability_scan", + "data": { + "stream": "vulnerability", + "event": { + "type": "image_vulnerability", + "created_at": "2026-02-03T12:00:00Z", + "payload": { + "repository": { + "name": "demo", + "namespace": "superplane", + "full_name": "superplane/demo" + }, + "tag": "v1.2.3", + "digest": "sha256:abc123def456", + "criticalities": { + "critical": 2, + "high": 5, + "medium": 10, + "low": 8, + "unspecified": 1 + }, + "fixable_count": 7 + } + } + } +} diff --git a/pkg/integrations/dockerhub/example_output_delete_tag.json b/pkg/integrations/dockerhub/example_output_delete_tag.json new file mode 100644 index 0000000000..16c954dabc --- /dev/null +++ b/pkg/integrations/dockerhub/example_output_delete_tag.json @@ -0,0 +1,9 @@ +{ + "timestamp": "2026-02-03T12:00:00Z", + "type": "dockerhub.deletedTag", + "data": { + "namespace": "superplane", + "repository": "demo", + "tag": "v1.2.3-rc1" + } +} diff --git a/pkg/integrations/dockerhub/on_vulnerability_scan.go b/pkg/integrations/dockerhub/on_vulnerability_scan.go new file mode 100644 index 0000000000..c0f9ecbd79 --- /dev/null +++ b/pkg/integrations/dockerhub/on_vulnerability_scan.go @@ -0,0 +1,288 @@ +package dockerhub + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +type OnVulnerabilityScan struct{} + +type OnVulnerabilityScanConfiguration struct { + Repository string `json:"repository" mapstructure:"repository"` + MinSeverity string `json:"minSeverity" mapstructure:"minSeverity"` +} + +type OnVulnerabilityScanMetadata struct { + Repository *RepositoryMetadata `json:"repository" mapstructure:"repository"` + WebhookURL string `json:"webhookUrl" mapstructure:"webhookUrl"` +} + +type VulnScanPayload struct { + Stream string `json:"stream"` + Event VulnScanEvent `json:"event"` +} + +type VulnScanEvent struct { + Type string `json:"type"` + CreatedAt string `json:"created_at"` + Payload VulnScanDetails `json:"payload"` +} + +type VulnScanDetails struct { + Repository VulnScanRepository `json:"repository"` + Tag string `json:"tag"` + Digest string `json:"digest"` + Criticalities VulnCriticalities `json:"criticalities"` + FixableCount int `json:"fixable_count"` +} + +type VulnScanRepository struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + FullName string `json:"full_name"` +} + +type VulnCriticalities struct { + Critical int `json:"critical"` + High int `json:"high"` + Medium int `json:"medium"` + Low int `json:"low"` + Unspecified int `json:"unspecified"` +} + +var severityOrder = map[string]int{ + "critical": 4, + "high": 3, + "medium": 2, + "low": 1, +} + +func (p *OnVulnerabilityScan) Name() string { + return "dockerhub.onVulnerabilityScan" +} + +func (p *OnVulnerabilityScan) Label() string { + return "On Vulnerability Scan" +} + +func (p *OnVulnerabilityScan) Description() string { + return "Listen to Docker Scout vulnerability scan events for a DockerHub repository" +} + +func (p *OnVulnerabilityScan) Documentation() string { + return `The On Vulnerability Scan trigger fires when Docker Scout completes a vulnerability scan for an image in a DockerHub repository. + +## Use Cases + +- **Security gating**: Block promotion pipelines when critical vulnerabilities are detected +- **Alerting**: Notify teams when newly pushed images contain high-severity CVEs +- **Remediation workflows**: Automatically open issues or tickets for vulnerable images + +## Configuration + +- **Repository**: DockerHub repository name, in the format of ` + "`namespace/name`" + ` +- **Minimum Severity**: Optional filter — only fire when the scan contains at least one vulnerability at this severity or higher (` + "`low`" + `, ` + "`medium`" + `, ` + "`high`" + `, ` + "`critical`" + `) + +## Webhook Setup + +This trigger generates a webhook URL in SuperPlane. Register that URL as a Docker Scout notification webhook for the selected repository so Docker Scout can deliver scan completion events.` +} + +func (p *OnVulnerabilityScan) Icon() string { + return "docker" +} + +func (p *OnVulnerabilityScan) Color() string { + return "gray" +} + +func (p *OnVulnerabilityScan) ExampleData() map[string]any { + return onVulnerabilityScanExampleData() +} + +func (p *OnVulnerabilityScan) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "repository", + Label: "Repository", + Type: configuration.FieldTypeIntegrationResource, + Required: true, + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "dockerhub.repository", + UseNameAsValue: true, + Parameters: []configuration.ParameterRef{ + { + Name: "namespace", + ValueFrom: &configuration.ParameterValueFrom{ + Field: "namespace", + }, + }, + }, + }, + }, + }, + { + Name: "minSeverity", + Label: "Minimum Severity", + Type: configuration.FieldTypeSelect, + Required: false, + Description: "Only trigger when at least one vulnerability at this level or above is found", + TypeOptions: &configuration.TypeOptions{ + Select: &configuration.SelectTypeOptions{ + Options: []configuration.FieldOption{ + {Value: "low", Label: "Low"}, + {Value: "medium", Label: "Medium"}, + {Value: "high", Label: "High"}, + {Value: "critical", Label: "Critical"}, + }, + }, + }, + }, + } +} + +func (p *OnVulnerabilityScan) Setup(ctx core.TriggerContext) error { + metadata := OnVulnerabilityScanMetadata{} + if err := mapstructure.Decode(ctx.Metadata.Get(), &metadata); err != nil { + return fmt.Errorf("failed to decode metadata: %w", err) + } + + config := OnVulnerabilityScanConfiguration{} + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + + repository := strings.TrimSpace(config.Repository) + if repository == "" { + return fmt.Errorf("repository is required") + } + + parts := strings.Split(repository, "/") + if len(parts) != 2 { + return fmt.Errorf("repository must be in the format of namespace/name") + } + + namespace := parts[0] + repositoryName := parts[1] + + if metadata.Repository != nil && + metadata.Repository.Name == repositoryName && + metadata.Repository.Namespace == namespace && + metadata.WebhookURL != "" { + return nil + } + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + repoInfo, err := client.GetRepository(namespace, repositoryName) + if err != nil { + return fmt.Errorf("failed to validate repository %s in namespace %s: %w", repositoryName, namespace, err) + } + + webhookURL := metadata.WebhookURL + if webhookURL == "" { + webhookURL, err = ctx.Webhook.Setup() + if err != nil { + return fmt.Errorf("failed to setup webhook: %w", err) + } + } + + return ctx.Metadata.Set(OnVulnerabilityScanMetadata{ + WebhookURL: webhookURL, + Repository: &RepositoryMetadata{ + Namespace: repoInfo.Namespace, + Name: repoInfo.Name, + }, + }) +} + +func (p *OnVulnerabilityScan) Hooks() []core.Hook { + return []core.Hook{} +} + +func (p *OnVulnerabilityScan) HandleHook(ctx core.TriggerHookContext) (map[string]any, error) { + return nil, nil +} + +func (p *OnVulnerabilityScan) HandleWebhook(ctx core.WebhookRequestContext) (int, *core.WebhookResponseBody, error) { + config := OnVulnerabilityScanConfiguration{} + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return http.StatusInternalServerError, nil, fmt.Errorf("failed to decode configuration: %w", err) + } + + metadata := OnVulnerabilityScanMetadata{} + if err := mapstructure.Decode(ctx.Metadata.Get(), &metadata); err != nil { + return http.StatusInternalServerError, nil, fmt.Errorf("failed to decode metadata: %w", err) + } + + payload := VulnScanPayload{} + if err := json.Unmarshal(ctx.Body, &payload); err != nil { + return http.StatusBadRequest, nil, fmt.Errorf("error parsing request body: %w", err) + } + + if metadata.Repository == nil { + return http.StatusOK, nil, nil + } + + repo := payload.Event.Payload.Repository + if metadata.Repository.Namespace != repo.Namespace { + ctx.Logger.Infof("Ignoring scan event for namespace %s", repo.Namespace) + return http.StatusOK, nil, nil + } + + if metadata.Repository.Name != repo.Name { + ctx.Logger.Infof("Ignoring scan event for repository %s", repo.Name) + return http.StatusOK, nil, nil + } + + if config.MinSeverity != "" { + if !meetsMinSeverity(payload.Event.Payload.Criticalities, config.MinSeverity) { + ctx.Logger.Infof("Ignoring scan event: no vulnerabilities at or above %s", config.MinSeverity) + return http.StatusOK, nil, nil + } + } + + if err := ctx.Events.Emit("dockerhub.image.vulnerability_scan", payload); err != nil { + return http.StatusInternalServerError, nil, fmt.Errorf("error emitting event: %w", err) + } + + return http.StatusOK, nil, nil +} + +func (p *OnVulnerabilityScan) Cleanup(ctx core.TriggerContext) error { + return nil +} + +// meetsMinSeverity returns true if the scan contains at least one vulnerability +// at or above the given minimum severity level. +func meetsMinSeverity(c VulnCriticalities, minSeverity string) bool { + threshold, ok := severityOrder[strings.ToLower(minSeverity)] + if !ok { + return true + } + + counts := map[string]int{ + "critical": c.Critical, + "high": c.High, + "medium": c.Medium, + "low": c.Low, + } + + for sev, count := range counts { + if count > 0 && severityOrder[sev] >= threshold { + return true + } + } + + return false +} diff --git a/pkg/integrations/dockerhub/on_vulnerability_scan_test.go b/pkg/integrations/dockerhub/on_vulnerability_scan_test.go new file mode 100644 index 0000000000..72fdc6f84e --- /dev/null +++ b/pkg/integrations/dockerhub/on_vulnerability_scan_test.go @@ -0,0 +1,202 @@ +package dockerhub + +import ( + "io" + "net/http" + "strings" + "testing" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/test/support/contexts" +) + +func Test__OnVulnerabilityScan__Setup(t *testing.T) { + trigger := &OnVulnerabilityScan{} + + t.Run("repository is required", func(t *testing.T) { + err := trigger.Setup(core.TriggerContext{ + Integration: &contexts.IntegrationContext{}, + Metadata: &contexts.MetadataContext{}, + Configuration: map[string]any{"repository": ""}, + }) + + require.ErrorContains(t, err, "repository is required") + }) + + t.Run("valid configuration -> stores metadata and generates webhook URL", func(t *testing.T) { + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"name":"demo","namespace":"superplane"}`)), + }, + }, + } + + metadata := &contexts.MetadataContext{} + integrationCtx := &contexts.IntegrationContext{ + CurrentSecrets: map[string]core.IntegrationSecret{ + accessTokenSecretName: {Name: accessTokenSecretName, Value: []byte("token")}, + }, + } + + err := trigger.Setup(core.TriggerContext{ + HTTP: httpCtx, + Integration: integrationCtx, + Metadata: metadata, + Webhook: &contexts.NodeWebhookContext{}, + Configuration: map[string]any{ + "repository": "superplane/demo", + }, + }) + + require.NoError(t, err) + stored, ok := metadata.Metadata.(OnVulnerabilityScanMetadata) + require.True(t, ok) + assert.Equal(t, "demo", stored.Repository.Name) + assert.NotEmpty(t, stored.WebhookURL) + }) +} + +func Test__OnVulnerabilityScan__HandleWebhook(t *testing.T) { + trigger := &OnVulnerabilityScan{} + + repoMetadata := &contexts.MetadataContext{ + Metadata: OnVulnerabilityScanMetadata{ + Repository: &RepositoryMetadata{ + Namespace: "superplane", + Name: "demo", + }, + }, + } + + t.Run("invalid JSON -> 400", func(t *testing.T) { + code, _, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: []byte(`invalid`), + Events: &contexts.EventContext{}, + Configuration: map[string]any{"repository": "superplane/demo"}, + Metadata: repoMetadata, + Logger: log.NewEntry(log.New()), + }) + + assert.Equal(t, http.StatusBadRequest, code) + assert.ErrorContains(t, err, "error parsing request body") + }) + + t.Run("namespace mismatch -> ignored", func(t *testing.T) { + body := []byte(`{"stream":"vulnerability","event":{"payload":{"repository":{"name":"demo","namespace":"other"}}}}`) + events := &contexts.EventContext{} + code, _, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: body, + Events: events, + Configuration: map[string]any{"repository": "superplane/demo"}, + Metadata: repoMetadata, + Logger: log.NewEntry(log.New()), + }) + + assert.Equal(t, http.StatusOK, code) + require.NoError(t, err) + assert.Equal(t, 0, events.Count()) + }) + + t.Run("repository mismatch -> ignored", func(t *testing.T) { + body := []byte(`{"stream":"vulnerability","event":{"payload":{"repository":{"name":"other","namespace":"superplane"}}}}`) + events := &contexts.EventContext{} + code, _, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: body, + Events: events, + Configuration: map[string]any{"repository": "superplane/demo"}, + Metadata: repoMetadata, + Logger: log.NewEntry(log.New()), + }) + + assert.Equal(t, http.StatusOK, code) + require.NoError(t, err) + assert.Equal(t, 0, events.Count()) + }) + + t.Run("severity filter not met -> ignored", func(t *testing.T) { + body := []byte(`{"stream":"vulnerability","event":{"payload":{"repository":{"name":"demo","namespace":"superplane"},"criticalities":{"critical":0,"high":0,"medium":3,"low":5}}}}`) + events := &contexts.EventContext{} + code, _, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: body, + Events: events, + Logger: log.NewEntry(log.New()), + Configuration: map[string]any{ + "repository": "superplane/demo", + "minSeverity": "critical", + }, + Metadata: repoMetadata, + }) + + assert.Equal(t, http.StatusOK, code) + require.NoError(t, err) + assert.Equal(t, 0, events.Count()) + }) + + t.Run("match with no severity filter -> event emitted", func(t *testing.T) { + body := []byte(`{"stream":"vulnerability","event":{"payload":{"repository":{"name":"demo","namespace":"superplane"},"tag":"v1.2.3","criticalities":{"critical":2,"high":5}}}}`) + events := &contexts.EventContext{} + code, _, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: body, + Events: events, + Logger: log.NewEntry(log.New()), + Configuration: map[string]any{"repository": "superplane/demo"}, + Metadata: repoMetadata, + }) + + assert.Equal(t, http.StatusOK, code) + require.NoError(t, err) + require.Equal(t, 1, events.Count()) + assert.Equal(t, "dockerhub.image.vulnerability_scan", events.Payloads[0].Type) + }) + + t.Run("match with severity filter met -> event emitted", func(t *testing.T) { + body := []byte(`{"stream":"vulnerability","event":{"payload":{"repository":{"name":"demo","namespace":"superplane"},"tag":"v1.2.3","criticalities":{"critical":1,"high":0,"medium":0,"low":0}}}}`) + events := &contexts.EventContext{} + code, _, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: body, + Events: events, + Logger: log.NewEntry(log.New()), + Configuration: map[string]any{ + "repository": "superplane/demo", + "minSeverity": "high", + }, + Metadata: repoMetadata, + }) + + assert.Equal(t, http.StatusOK, code) + require.NoError(t, err) + require.Equal(t, 1, events.Count()) + assert.Equal(t, "dockerhub.image.vulnerability_scan", events.Payloads[0].Type) + }) +} + +func Test__meetsMinSeverity(t *testing.T) { + t.Run("unknown severity -> always passes", func(t *testing.T) { + assert.True(t, meetsMinSeverity(VulnCriticalities{}, "unknown")) + }) + + t.Run("critical threshold, no critical vulns -> false", func(t *testing.T) { + c := VulnCriticalities{Critical: 0, High: 5, Medium: 10, Low: 3} + assert.False(t, meetsMinSeverity(c, "critical")) + }) + + t.Run("high threshold, has critical vulns -> true", func(t *testing.T) { + c := VulnCriticalities{Critical: 2, High: 0, Medium: 0, Low: 0} + assert.True(t, meetsMinSeverity(c, "high")) + }) + + t.Run("low threshold, has any vulns -> true", func(t *testing.T) { + c := VulnCriticalities{Critical: 0, High: 0, Medium: 0, Low: 1} + assert.True(t, meetsMinSeverity(c, "low")) + }) + + t.Run("medium threshold, only low vulns -> false", func(t *testing.T) { + c := VulnCriticalities{Critical: 0, High: 0, Medium: 0, Low: 5} + assert.False(t, meetsMinSeverity(c, "medium")) + }) +} diff --git a/web_src/src/pages/workflowv2/mappers/dockerhub/delete_tag.spec.ts b/web_src/src/pages/workflowv2/mappers/dockerhub/delete_tag.spec.ts new file mode 100644 index 0000000000..92ba306619 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/dockerhub/delete_tag.spec.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from "vitest"; + +import type { + ComponentBaseContext, + ComponentDefinition, + ExecutionDetailsContext, + ExecutionInfo, + NodeInfo, + OutputPayload, +} from "../types"; +import { deleteTagMapper } from "./delete_tag"; + +function buildNode(overrides?: Partial): NodeInfo { + return { + id: "node-1", + name: "Delete Tag", + componentName: "dockerhub.deleteTag", + isCollapsed: false, + configuration: {}, + metadata: {}, + ...overrides, + }; +} + +function buildOutput(data: unknown): OutputPayload { + return { + type: "dockerhub.deletedTag", + timestamp: new Date().toISOString(), + data, + }; +} + +function buildExecution(overrides?: Partial): ExecutionInfo { + return { + id: "exec-1", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + state: "STATE_FINISHED", + result: "RESULT_PASSED", + resultReason: "RESULT_REASON_OK", + resultMessage: "", + metadata: {}, + configuration: {}, + rootEvent: undefined, + ...overrides, + }; +} + +function buildDetailsCtx(overrides?: { + node?: Partial; + execution?: Partial; +}): ExecutionDetailsContext { + const node = buildNode(overrides?.node); + return { nodes: [node], node, execution: buildExecution(overrides?.execution) }; +} + +const defaultDefinition: ComponentDefinition = { + name: "dockerhub.deleteTag", + label: "Delete Tag", + description: "", + icon: "docker", + color: "gray", +}; + +function buildPropsContext(overrides?: Partial): ComponentBaseContext { + return { + nodes: [], + node: buildNode(), + componentDefinition: defaultDefinition, + lastExecutions: [], + currentUser: undefined, + actions: { invokeNodeExecutionHook: async () => {} }, + ...overrides, + }; +} + +describe("deleteTagMapper.getExecutionDetails", () => { + it("does not throw when outputs is undefined", () => { + const ctx = buildDetailsCtx({ execution: { outputs: undefined } }); + expect(() => deleteTagMapper.getExecutionDetails(ctx)).not.toThrow(); + }); + + it("does not throw when default array is empty", () => { + const ctx = buildDetailsCtx({ execution: { outputs: { default: [] } } }); + expect(() => deleteTagMapper.getExecutionDetails(ctx)).not.toThrow(); + }); + + it("returns empty object when result is missing", () => { + const ctx = buildDetailsCtx({ execution: { outputs: undefined } }); + expect(deleteTagMapper.getExecutionDetails(ctx)).toEqual({}); + }); + + it("returns namespace, repository and tag from output", () => { + const ctx = buildDetailsCtx({ + execution: { + outputs: { + default: [ + buildOutput({ + namespace: "superplane", + repository: "demo", + tag: "v1.2.3-rc1", + }), + ], + }, + }, + }); + const details = deleteTagMapper.getExecutionDetails(ctx); + expect(details["Namespace"]).toBe("superplane"); + expect(details["Repository"]).toBe("demo"); + expect(details["Tag"]).toBe("v1.2.3-rc1"); + }); + + it("returns dashes for missing fields", () => { + const ctx = buildDetailsCtx({ + execution: { + outputs: { + default: [buildOutput({})], + }, + }, + }); + const details = deleteTagMapper.getExecutionDetails(ctx); + expect(details["Namespace"]).toBe("-"); + expect(details["Repository"]).toBe("-"); + expect(details["Tag"]).toBe("-"); + }); +}); + +describe("deleteTagMapper.props", () => { + it("does not throw with minimal context", () => { + const ctx = buildPropsContext(); + expect(() => deleteTagMapper.props!(ctx)).not.toThrow(); + }); + + it("includes repository and tag in metadata when configured", () => { + const ctx = buildPropsContext({ + node: buildNode({ + configuration: { repository: "superplane/demo", tag: "v1.2.3-rc1" }, + }), + }); + const props = deleteTagMapper.props!(ctx); + expect(props.metadata?.some((m) => m.label === "superplane/demo")).toBe(true); + expect(props.metadata?.some((m) => m.label === "v1.2.3-rc1")).toBe(true); + }); +}); diff --git a/web_src/src/pages/workflowv2/mappers/dockerhub/delete_tag.ts b/web_src/src/pages/workflowv2/mappers/dockerhub/delete_tag.ts new file mode 100644 index 0000000000..c9adfc9b0b --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/dockerhub/delete_tag.ts @@ -0,0 +1,100 @@ +import type { + ComponentBaseContext, + ComponentBaseMapper, + ExecutionDetailsContext, + ExecutionInfo, + NodeInfo, + OutputPayload, + SubtitleContext, +} from "../types"; +import type { ComponentBaseProps, EventSection } from "@/ui/componentBase"; +import type React from "react"; +import { getBackgroundColorClass, getColorClass } from "@/lib/colors"; +import { getState, getStateMap, getTriggerRenderer } from ".."; +import dockerIcon from "@/assets/icons/integrations/docker.svg"; +import { renderTimeAgo } from "@/components/TimeAgo"; +import type { MetadataItem } from "@/ui/metadataList"; +import { stringOrDash } from "../utils"; + +interface DeleteTagConfiguration { + repository?: string; + tag?: string; +} + +interface DeletedTag { + namespace?: string; + repository?: string; + tag?: string; +} + +export const deleteTagMapper: ComponentBaseMapper = { + props(context: ComponentBaseContext): ComponentBaseProps { + const lastExecution = context.lastExecutions.length > 0 ? context.lastExecutions[0] : null; + const componentName = context.componentDefinition.name || "unknown"; + + return { + title: context.node.name || context.componentDefinition.label || "Unnamed component", + iconSrc: dockerIcon, + iconColor: getColorClass(context.componentDefinition.color), + collapsedBackground: getBackgroundColorClass(context.componentDefinition.color), + collapsed: context.node.isCollapsed, + eventSections: lastExecution ? getDeleteTagEventSections(context.nodes, lastExecution, componentName) : undefined, + includeEmptyState: !lastExecution, + metadata: getDeleteTagMetadataList(context.node), + eventStateMap: getStateMap(componentName), + }; + }, + + getExecutionDetails(context: ExecutionDetailsContext): Record { + const outputs = context.execution.outputs as { default?: OutputPayload[] } | undefined; + const result = outputs?.default?.[0]?.data as DeletedTag | undefined; + + if (!result) { + return {}; + } + + return { + Namespace: stringOrDash(result.namespace), + Repository: stringOrDash(result.repository), + Tag: stringOrDash(result.tag), + }; + }, + + subtitle(context: SubtitleContext): string | React.ReactNode { + if (!context.execution.createdAt) { + return ""; + } + return renderTimeAgo(new Date(context.execution.createdAt)); + }, +}; + +function getDeleteTagMetadataList(node: NodeInfo): MetadataItem[] { + const metadata: MetadataItem[] = []; + const configuration = node.configuration as DeleteTagConfiguration | undefined; + + if (configuration?.repository) { + metadata.push({ icon: "package", label: configuration.repository }); + } + + if (configuration?.tag) { + metadata.push({ icon: "tag", label: configuration.tag }); + } + + return metadata; +} + +function getDeleteTagEventSections(nodes: NodeInfo[], execution: ExecutionInfo, componentName: string): EventSection[] { + const rootTriggerNode = nodes.find((n) => n.id === execution.rootEvent?.nodeId); + const rootTriggerRenderer = getTriggerRenderer(rootTriggerNode?.componentName ?? ""); + const { title } = rootTriggerRenderer.getTitleAndSubtitle({ event: execution.rootEvent }); + + return [ + { + receivedAt: new Date(execution.createdAt!), + eventTitle: title, + eventSubtitle: renderTimeAgo(new Date(execution.createdAt!)), + eventState: getState(componentName)(execution), + eventId: execution.rootEvent!.id!, + }, + ]; +} diff --git a/web_src/src/pages/workflowv2/mappers/dockerhub/index.ts b/web_src/src/pages/workflowv2/mappers/dockerhub/index.ts index 1834da1f8e..463368c22d 100644 --- a/web_src/src/pages/workflowv2/mappers/dockerhub/index.ts +++ b/web_src/src/pages/workflowv2/mappers/dockerhub/index.ts @@ -1,20 +1,26 @@ import type { ComponentBaseMapper, CustomFieldRenderer, EventStateRegistry, TriggerRenderer } from "../types"; import { getImageTagMapper } from "./get_image_tag"; +import { deleteTagMapper } from "./delete_tag"; import { onImagePushCustomFieldRenderer, onImagePushTriggerRenderer } from "./on_image_push"; +import { onVulnerabilityScanCustomFieldRenderer, onVulnerabilityScanTriggerRenderer } from "./on_vulnerability_scan"; import { buildActionStateRegistry } from "../utils"; export const componentMappers: Record = { getImageTag: getImageTagMapper, + deleteTag: deleteTagMapper, }; export const triggerRenderers: Record = { onImagePush: onImagePushTriggerRenderer, + onVulnerabilityScan: onVulnerabilityScanTriggerRenderer, }; export const customFieldRenderers: Record = { onImagePush: onImagePushCustomFieldRenderer, + onVulnerabilityScan: onVulnerabilityScanCustomFieldRenderer, }; export const eventStateRegistry: Record = { getImageTag: buildActionStateRegistry("retrieved"), + deleteTag: buildActionStateRegistry("deleted"), }; diff --git a/web_src/src/pages/workflowv2/mappers/dockerhub/on_vulnerability_scan.tsx b/web_src/src/pages/workflowv2/mappers/dockerhub/on_vulnerability_scan.tsx new file mode 100644 index 0000000000..b1791004bf --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/dockerhub/on_vulnerability_scan.tsx @@ -0,0 +1,191 @@ +import { getBackgroundColorClass } from "@/lib/colors"; +import React from "react"; +import type { + CustomFieldRenderer, + NodeInfo, + TriggerEventContext, + TriggerRenderer, + TriggerRendererContext, +} from "../types"; +import type { TriggerProps } from "@/ui/trigger"; +import dockerIcon from "@/assets/icons/integrations/docker.svg"; +import type { RepositoryMetadata } from "./types"; +import { renderTimeAgo } from "@/components/TimeAgo"; +import { formatTimestampInUserTimezone } from "@/lib/timezone"; +import { stringOrDash } from "../utils"; +import type { MetadataItem } from "@/ui/metadataList"; + +export interface OnVulnerabilityScanMetadata { + repository?: RepositoryMetadata; + webhookUrl?: string; +} + +export interface OnVulnerabilityScanConfiguration { + repository?: string; + minSeverity?: string; +} + +interface VulnCriticalities { + critical?: number; + high?: number; + medium?: number; + low?: number; + unspecified?: number; +} + +interface VulnScanDetails { + repository?: { + name?: string; + namespace?: string; + full_name?: string; + }; + tag?: string; + digest?: string; + criticalities?: VulnCriticalities; + fixable_count?: number; +} + +interface VulnScanEvent { + type?: string; + created_at?: string; + payload?: VulnScanDetails; +} + +interface VulnScanPayload { + stream?: string; + event?: VulnScanEvent; +} + +const SEVERITY_LABELS: Record = { + critical: "Critical", + high: "High", + medium: "Medium", + low: "Low", +}; + +function criticalitiesRecord(c?: VulnCriticalities): Record { + return { + Critical: stringOrDash(c?.critical), + High: stringOrDash(c?.high), + Medium: stringOrDash(c?.medium), + Low: stringOrDash(c?.low), + }; +} + +export const onVulnerabilityScanTriggerRenderer: TriggerRenderer = { + getTitleAndSubtitle: (context: TriggerEventContext): { title: string; subtitle: string | React.ReactNode } => { + const eventData = context.event?.data as VulnScanPayload; + const details = eventData?.event?.payload; + const repo = details?.repository?.full_name ?? details?.repository?.name; + const tag = details?.tag; + + const title = repo ? `${repo}${tag ? `:${tag}` : ""}` : "Vulnerability scan"; + const subtitle = context.event?.createdAt ? renderTimeAgo(new Date(context.event.createdAt)) : ""; + + return { title, subtitle }; + }, + + getRootEventValues: (context: TriggerEventContext): Record => { + const eventData = context.event?.data as VulnScanPayload; + const details = eventData?.event?.payload; + const repo = details?.repository; + const c = details?.criticalities; + const scannedAt = eventData?.event?.created_at; + + return { + Repository: stringOrDash(repo?.full_name ?? repo?.name), + Tag: stringOrDash(details?.tag), + Digest: stringOrDash(details?.digest), + ...criticalitiesRecord(c), + Fixable: stringOrDash(details?.fixable_count), + "Scanned At": scannedAt ? formatTimestampInUserTimezone(scannedAt) : "-", + }; + }, + + getTriggerProps: (context: TriggerRendererContext): TriggerProps => { + const { node, definition, lastEvent } = context; + const metadata = node.metadata as OnVulnerabilityScanMetadata | undefined; + const configuration = node.configuration as OnVulnerabilityScanConfiguration | undefined; + const metadataItems: MetadataItem[] = []; + + if (metadata?.repository) { + metadataItems.push({ + icon: "package", + label: getRepositoryLabel(metadata), + }); + } + + if (configuration?.minSeverity) { + metadataItems.push({ + icon: "shield", + label: `Min: ${SEVERITY_LABELS[configuration.minSeverity] ?? configuration.minSeverity}`, + }); + } + + const props: TriggerProps = { + title: node.name || definition.label || "Unnamed trigger", + iconSrc: dockerIcon, + collapsedBackground: getBackgroundColorClass(definition.color), + metadata: metadataItems, + }; + + if (lastEvent) { + const { title, subtitle } = onVulnerabilityScanTriggerRenderer.getTitleAndSubtitle({ event: lastEvent }); + props.lastEventData = { + title, + subtitle, + receivedAt: new Date(lastEvent.createdAt), + state: "triggered", + eventId: lastEvent.id, + }; + } + + return props; + }, +}; + +export const onVulnerabilityScanCustomFieldRenderer: CustomFieldRenderer = { + render: (node: NodeInfo) => { + const metadata = node.metadata as OnVulnerabilityScanMetadata | undefined; + const repositoryLabel = getRepositoryLabel(metadata); + const webhookUrl = metadata?.webhookUrl ?? "[URL GENERATED ONCE THE CANVAS IS SAVED]"; + + return ( +
+
+
+ Docker Scout Webhook Setup +
+
    +
  1. + Go to Docker Scout settings for {repositoryLabel ?? "your repository"} +
  2. +
  3. + Create a new notification webhook for the vulnerability stream +
  4. +
  5. Set the webhook URL below and save
  6. +
+
+ Webhook URL +
+
+                    {webhookUrl}
+                  
+
+
+

+ Docker Scout will deliver scan completion events to SuperPlane once the webhook is configured. +

+
+
+
+
+ ); + }, +}; + +function getRepositoryLabel(metadata?: OnVulnerabilityScanMetadata): string | undefined { + return metadata?.repository?.namespace + ? `${metadata.repository.namespace}/${metadata.repository.name}` + : metadata?.repository?.name; +}