From 1bf9886b3e2e52e567d12fc5d9cca1e414c906c8 Mon Sep 17 00:00:00 2001 From: WashingtonKK Date: Wed, 20 May 2026 19:46:38 +0000 Subject: [PATCH 1/4] feat(dockerhub): add On Vulnerability Scan trigger Adds a new trigger that fires when Docker Scout completes a vulnerability scan for a DockerHub repository. Supports optional minimum-severity filtering so pipelines only activate when scans surface CVEs at or above a configured threshold (low / medium / high / critical). Includes backend trigger, webhook handler, severity logic, tests, embedded example data, and frontend renderer with webhook setup UI. Signed-off-by: WashingtonKK --- pkg/integrations/dockerhub/dockerhub.go | 1 + pkg/integrations/dockerhub/example.go | 10 + .../example_data_on_vulnerability_scan.json | 28 ++ .../dockerhub/on_vulnerability_scan.go | 288 ++++++++++++++++++ .../dockerhub/on_vulnerability_scan_test.go | 202 ++++++++++++ .../workflowv2/mappers/dockerhub/index.ts | 3 + .../dockerhub/on_vulnerability_scan.tsx | 190 ++++++++++++ 7 files changed, 722 insertions(+) create mode 100644 pkg/integrations/dockerhub/example_data_on_vulnerability_scan.json create mode 100644 pkg/integrations/dockerhub/on_vulnerability_scan.go create mode 100644 pkg/integrations/dockerhub/on_vulnerability_scan_test.go create mode 100644 web_src/src/pages/workflowv2/mappers/dockerhub/on_vulnerability_scan.tsx diff --git a/pkg/integrations/dockerhub/dockerhub.go b/pkg/integrations/dockerhub/dockerhub.go index 2049e529ee..9bec976522 100644 --- a/pkg/integrations/dockerhub/dockerhub.go +++ b/pkg/integrations/dockerhub/dockerhub.go @@ -74,6 +74,7 @@ func (d *DockerHub) Actions() []core.Action { 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..3c2cba7291 100644 --- a/pkg/integrations/dockerhub/example.go +++ b/pkg/integrations/dockerhub/example.go @@ -13,12 +13,18 @@ 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 + 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 + func getImageTagExampleOutput() map[string]any { return utils.UnmarshalEmbeddedJSON(&exampleOutputGetImageTagOnce, exampleOutputGetImageTagBytes, &exampleOutputGetImageTag) } @@ -26,3 +32,7 @@ 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) +} 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/on_vulnerability_scan.go b/pkg/integrations/dockerhub/on_vulnerability_scan.go new file mode 100644 index 0000000000..f76aa7285a --- /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/index.ts b/web_src/src/pages/workflowv2/mappers/dockerhub/index.ts index 1834da1f8e..4536462701 100644 --- a/web_src/src/pages/workflowv2/mappers/dockerhub/index.ts +++ b/web_src/src/pages/workflowv2/mappers/dockerhub/index.ts @@ -1,6 +1,7 @@ import type { ComponentBaseMapper, CustomFieldRenderer, EventStateRegistry, TriggerRenderer } from "../types"; import { getImageTagMapper } from "./get_image_tag"; import { onImagePushCustomFieldRenderer, onImagePushTriggerRenderer } from "./on_image_push"; +import { onVulnerabilityScanCustomFieldRenderer, onVulnerabilityScanTriggerRenderer } from "./on_vulnerability_scan"; import { buildActionStateRegistry } from "../utils"; export const componentMappers: Record = { @@ -9,10 +10,12 @@ export const componentMappers: Record = { export const triggerRenderers: Record = { onImagePush: onImagePushTriggerRenderer, + onVulnerabilityScan: onVulnerabilityScanTriggerRenderer, }; export const customFieldRenderers: Record = { onImagePush: onImagePushCustomFieldRenderer, + onVulnerabilityScan: onVulnerabilityScanCustomFieldRenderer, }; export const eventStateRegistry: Record = { 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..f7dab77487 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/dockerhub/on_vulnerability_scan.tsx @@ -0,0 +1,190 @@ +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 formatSeveritySummary(c?: VulnCriticalities): string { + if (!c) return "-"; + const parts: string[] = []; + if (c.critical) parts.push(`${c.critical} critical`); + if (c.high) parts.push(`${c.high} high`); + if (c.medium) parts.push(`${c.medium} medium`); + if (c.low) parts.push(`${c.low} low`); + return parts.length > 0 ? parts.join(", ") : "0 vulnerabilities"; +} + +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; + + return { + Repository: stringOrDash(repo?.full_name ?? repo?.name), + Tag: stringOrDash(details?.tag), + Digest: stringOrDash(details?.digest), + Critical: stringOrDash(c?.critical), + High: stringOrDash(c?.high), + Medium: stringOrDash(c?.medium), + Low: stringOrDash(c?.low), + Fixable: stringOrDash(details?.fixable_count), + "Scanned At": eventData?.event?.created_at + ? formatTimestampInUserTimezone(eventData.event.created_at) + : "-", + }; + }, + + 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; +} From 4a2adb86dff9f0bdf12d9b199c3512ae3ab7a13f Mon Sep 17 00:00:00 2001 From: WashingtonKK Date: Thu, 21 May 2026 17:12:58 +0300 Subject: [PATCH 2/4] feat(dockerhub): add Delete Tag action Adds a Delete Tag action that permanently removes a tag from a DockerHub repository via the Hub API. Includes backend action, client method, tests, embedded example output, frontend mapper with execution details, and vitest spec. Also fixes a complexity regression in the On Vulnerability Scan trigger renderer by extracting criticalities formatting to a helper function, and removes an unused formatSeveritySummary function. Regenerates DockerHub component docs. Signed-off-by: WashingtonKK --- docs/components/DockerHub.mdx | 89 +++++++++ pkg/integrations/dockerhub/client.go | 6 + pkg/integrations/dockerhub/delete_tag.go | 176 ++++++++++++++++++ pkg/integrations/dockerhub/delete_tag_test.go | 94 ++++++++++ pkg/integrations/dockerhub/dockerhub.go | 1 + pkg/integrations/dockerhub/example.go | 10 + .../dockerhub/example_output_delete_tag.json | 9 + .../mappers/dockerhub/delete_tag.spec.ts | 144 ++++++++++++++ .../mappers/dockerhub/delete_tag.ts | 100 ++++++++++ .../workflowv2/mappers/dockerhub/index.ts | 3 + .../dockerhub/on_vulnerability_scan.tsx | 25 +-- 11 files changed, 642 insertions(+), 15 deletions(-) create mode 100644 pkg/integrations/dockerhub/delete_tag.go create mode 100644 pkg/integrations/dockerhub/delete_tag_test.go create mode 100644 pkg/integrations/dockerhub/example_output_delete_tag.json create mode 100644 web_src/src/pages/workflowv2/mappers/dockerhub/delete_tag.spec.ts create mode 100644 web_src/src/pages/workflowv2/mappers/dockerhub/delete_tag.ts 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 9bec976522..a29463020a 100644 --- a/pkg/integrations/dockerhub/dockerhub.go +++ b/pkg/integrations/dockerhub/dockerhub.go @@ -68,6 +68,7 @@ func (d *DockerHub) Configuration() []configuration.Field { func (d *DockerHub) Actions() []core.Action { return []core.Action{ &GetImageTag{}, + &DeleteTag{}, } } diff --git a/pkg/integrations/dockerhub/example.go b/pkg/integrations/dockerhub/example.go index 3c2cba7291..b8553efd88 100644 --- a/pkg/integrations/dockerhub/example.go +++ b/pkg/integrations/dockerhub/example.go @@ -16,6 +16,9 @@ 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 @@ -25,6 +28,9 @@ 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) } @@ -36,3 +42,7 @@ func onImagePushExampleData() map[string]any { 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_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/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 4536462701..463368c22d 100644 --- a/web_src/src/pages/workflowv2/mappers/dockerhub/index.ts +++ b/web_src/src/pages/workflowv2/mappers/dockerhub/index.ts @@ -1,11 +1,13 @@ 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 = { @@ -20,4 +22,5 @@ export const customFieldRenderers: Record = { 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 index f7dab77487..fb8cae2e93 100644 --- a/web_src/src/pages/workflowv2/mappers/dockerhub/on_vulnerability_scan.tsx +++ b/web_src/src/pages/workflowv2/mappers/dockerhub/on_vulnerability_scan.tsx @@ -63,14 +63,13 @@ const SEVERITY_LABELS: Record = { low: "Low", }; -function formatSeveritySummary(c?: VulnCriticalities): string { - if (!c) return "-"; - const parts: string[] = []; - if (c.critical) parts.push(`${c.critical} critical`); - if (c.high) parts.push(`${c.high} high`); - if (c.medium) parts.push(`${c.medium} medium`); - if (c.low) parts.push(`${c.low} low`); - return parts.length > 0 ? parts.join(", ") : "0 vulnerabilities"; +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 = { @@ -91,19 +90,15 @@ export const onVulnerabilityScanTriggerRenderer: TriggerRenderer = { 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), - Critical: stringOrDash(c?.critical), - High: stringOrDash(c?.high), - Medium: stringOrDash(c?.medium), - Low: stringOrDash(c?.low), + ...criticalitiesRecord(c), Fixable: stringOrDash(details?.fixable_count), - "Scanned At": eventData?.event?.created_at - ? formatTimestampInUserTimezone(eventData.event.created_at) - : "-", + "Scanned At": scannedAt ? formatTimestampInUserTimezone(scannedAt) : "-", }; }, From d9a90a3174c5e5c91b013cd05e40c2ab887ec7fc Mon Sep 17 00:00:00 2001 From: WashingtonKK Date: Thu, 21 May 2026 22:12:26 +0300 Subject: [PATCH 3/4] refactor: fix format Signed-off-by: WashingtonKK --- pkg/integrations/dockerhub/on_vulnerability_scan.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/integrations/dockerhub/on_vulnerability_scan.go b/pkg/integrations/dockerhub/on_vulnerability_scan.go index f76aa7285a..c0f9ecbd79 100644 --- a/pkg/integrations/dockerhub/on_vulnerability_scan.go +++ b/pkg/integrations/dockerhub/on_vulnerability_scan.go @@ -35,11 +35,11 @@ type VulnScanEvent struct { } type VulnScanDetails struct { - Repository VulnScanRepository `json:"repository"` - Tag string `json:"tag"` - Digest string `json:"digest"` + Repository VulnScanRepository `json:"repository"` + Tag string `json:"tag"` + Digest string `json:"digest"` Criticalities VulnCriticalities `json:"criticalities"` - FixableCount int `json:"fixable_count"` + FixableCount int `json:"fixable_count"` } type VulnScanRepository struct { From aaac3f24a51ac23869d58162fdb5790c39270184 Mon Sep 17 00:00:00 2001 From: WashingtonKK Date: Fri, 22 May 2026 08:51:13 +0300 Subject: [PATCH 4/4] refactor: improve formatting in Docker Scout webhook setup instructions Updated the formatting of the Docker Scout webhook setup instructions for better readability by adding line breaks to list items and paragraph text. Signed-off-by: WashingtonKK --- .../mappers/dockerhub/on_vulnerability_scan.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) 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 index fb8cae2e93..b1791004bf 100644 --- a/web_src/src/pages/workflowv2/mappers/dockerhub/on_vulnerability_scan.tsx +++ b/web_src/src/pages/workflowv2/mappers/dockerhub/on_vulnerability_scan.tsx @@ -157,8 +157,12 @@ export const onVulnerabilityScanCustomFieldRenderer: CustomFieldRenderer = { 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. + Go to Docker Scout settings for {repositoryLabel ?? "your repository"} +
  6. +
  7. + Create a new notification webhook for the vulnerability stream +
  8. Set the webhook URL below and save
@@ -169,7 +173,9 @@ export const onVulnerabilityScanCustomFieldRenderer: CustomFieldRenderer = {
-

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

+

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