diff --git a/pkg/scanner/backend_adapter_test.go b/pkg/scanner/backend_adapter_test.go index d805c60..3e90fe4 100644 --- a/pkg/scanner/backend_adapter_test.go +++ b/pkg/scanner/backend_adapter_test.go @@ -111,7 +111,12 @@ func vulnerabilityReport() harbor.VulnerabilityReport { Vendor: "Sysdig", Version: secure.BackendVersion, }, - Artifact: nil, + Artifact: &harbor.Artifact{ + Repository: "sysdig/agent", + Digest: imageDigest, + Tag: "9.7", + MimeType: harbor.DockerDistributionManifestMimeType, + }, Vulnerabilities: []harbor.VulnerabilityItem{ { ID: "CVE-2019-9948", diff --git a/pkg/scanner/base_adapter.go b/pkg/scanner/base_adapter.go index ae92cce..b97dc64 100644 --- a/pkg/scanner/base_adapter.go +++ b/pkg/scanner/base_adapter.go @@ -95,18 +95,16 @@ func (b *BaseAdapter) ToHarborVulnerabilityReport(repository string, shaDigest s scanResponse, _ := b.secureClient.GetImage(shaDigest) for _, imageDetail := range scanResponse.Data { - parts := strings.Split(imageDetail.MainAssetName, "@") - repoWithTag := parts[0] - hash := parts[1] - firstSlash := strings.Index(repoWithTag, "/") - lastColon := strings.LastIndex(repoWithTag, ":") - repo := repoWithTag[firstSlash+1 : lastColon] - tag := repoWithTag[lastColon+1:] + repo, tag, digest, ok := parseMainAssetName(imageDetail.MainAssetName) + if !ok { + slog.Warn("invalid main asset name format", "main_asset_name", imageDetail.MainAssetName) + continue + } if repo == repository { result.GeneratedAt = imageDetail.CreatedAt result.Artifact = &harbor.Artifact{ Repository: repo, - Digest: hash, + Digest: digest, Tag: tag, MimeType: harbor.DockerDistributionManifestMimeType, } @@ -117,6 +115,56 @@ func (b *BaseAdapter) ToHarborVulnerabilityReport(repository string, shaDigest s return result, nil } +// parseMainAssetName splits a Sysdig MainAssetName into its repository, tag and +// digest components. It accepts the reference forms produced by Sysdig Secure: +// +// repo@sha256:... (digest-only, no tag) +// repo:tag@sha256:... (tagged) +// registry/repo[:tag]@sha256:... (registry-prefixed) +// registry:port/repo[:tag]@sha256:... (registry with port) +// +// The leading path segment is treated as a registry host and stripped only when +// it looks like one (contains "." or ":", or equals "localhost"), following the +// canonical Docker reference convention. Malformed values (missing digest, +// missing name, or an empty tag such as "repo:@sha256:...") return ok=false so +// the caller can log and skip them instead of panicking. +func parseMainAssetName(mainAssetName string) (repo string, tag string, digest string, ok bool) { + parts := strings.SplitN(mainAssetName, "@", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", "", false + } + + namePart := parts[0] + digest = parts[1] + + lastSlash := strings.LastIndex(namePart, "/") + lastColon := strings.LastIndex(namePart, ":") + if lastColon > lastSlash { + tag = namePart[lastColon+1:] + namePart = namePart[:lastColon] + // A tag separator with an empty tag (e.g. "repo:@sha256:...") is not a + // valid reference; treat it as malformed so the caller logs and skips it. + if tag == "" { + return "", "", "", false + } + } + + repo = namePart + firstSlash := strings.Index(namePart, "/") + if firstSlash != -1 { + firstPart := namePart[:firstSlash] + if strings.Contains(firstPart, ".") || strings.Contains(firstPart, ":") || firstPart == "localhost" { + repo = namePart[firstSlash+1:] + } + } + + if repo == "" { + return "", "", "", false + } + + return repo, tag, digest, true +} + func (b *BaseAdapter) getVulnerabilitiesDescriptionFrom(vulnerabilities []*secure.Vulnerability) (map[string]string, error) { result := make(map[string]string) diff --git a/pkg/scanner/base_adapter_test.go b/pkg/scanner/base_adapter_test.go new file mode 100644 index 0000000..1578b4c --- /dev/null +++ b/pkg/scanner/base_adapter_test.go @@ -0,0 +1,108 @@ +package scanner + +import ( + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("parseMainAssetName", func() { + It("parses tagged repository without registry", func() { + repo, tag, hash, ok := parseMainAssetName("sysdig/agent:9.7.0@sha256:abc") + + Expect(ok).To(BeTrue()) + Expect(repo).To(Equal("sysdig/agent")) + Expect(tag).To(Equal("9.7.0")) + Expect(hash).To(Equal("sha256:abc")) + }) + + It("parses digest-only repository without registry", func() { + repo, tag, hash, ok := parseMainAssetName("sysdig/agent@sha256:abc") + + Expect(ok).To(BeTrue()) + Expect(repo).To(Equal("sysdig/agent")) + Expect(tag).To(BeEmpty()) + Expect(hash).To(Equal("sha256:abc")) + }) + + It("parses tagged repository with registry and strips registry host", func() { + repo, tag, hash, ok := parseMainAssetName("registry.example.com/sysdig/agent:latest@sha256:abc") + + Expect(ok).To(BeTrue()) + Expect(repo).To(Equal("sysdig/agent")) + Expect(tag).To(Equal("latest")) + Expect(hash).To(Equal("sha256:abc")) + }) + + It("parses digest-only repository with registry and strips registry host", func() { + repo, tag, hash, ok := parseMainAssetName("registry.example.com/sysdig/agent@sha256:abc") + + Expect(ok).To(BeTrue()) + Expect(repo).To(Equal("sysdig/agent")) + Expect(tag).To(BeEmpty()) + Expect(hash).To(Equal("sha256:abc")) + }) + + It("keeps repository untouched when first path segment is not a registry host", func() { + repo, tag, hash, ok := parseMainAssetName("library/debian@sha256:abc") + + Expect(ok).To(BeTrue()) + Expect(repo).To(Equal("library/debian")) + Expect(tag).To(BeEmpty()) + Expect(hash).To(Equal("sha256:abc")) + }) + + It("strips registry host with port and does not mistake the port for a tag", func() { + repo, tag, hash, ok := parseMainAssetName("localhost:5000/sysdig/agent@sha256:abc") + + Expect(ok).To(BeTrue()) + Expect(repo).To(Equal("sysdig/agent")) + Expect(tag).To(BeEmpty()) + Expect(hash).To(Equal("sha256:abc")) + }) + + It("strips registry host with port while keeping the tag", func() { + repo, tag, hash, ok := parseMainAssetName("localhost:5000/sysdig/agent:9.7@sha256:abc") + + Expect(ok).To(BeTrue()) + Expect(repo).To(Equal("sysdig/agent")) + Expect(tag).To(Equal("9.7")) + Expect(hash).To(Equal("sha256:abc")) + }) + + It("parses a single-name official image without tag", func() { + repo, tag, hash, ok := parseMainAssetName("nginx@sha256:abc") + + Expect(ok).To(BeTrue()) + Expect(repo).To(Equal("nginx")) + Expect(tag).To(BeEmpty()) + Expect(hash).To(Equal("sha256:abc")) + }) + + It("parses a single-name official image with tag", func() { + repo, tag, hash, ok := parseMainAssetName("nginx:latest@sha256:abc") + + Expect(ok).To(BeTrue()) + Expect(repo).To(Equal("nginx")) + Expect(tag).To(Equal("latest")) + Expect(hash).To(Equal("sha256:abc")) + }) + + It("returns not ok for malformed values", func() { + for _, tc := range []string{ + "", + "@sha256:abc", + "sysdig/agent@", + "sysdig/agent", + "sysdig/agent:1.0", + "sysdig/agent:@sha256:abc", + } { + repo, tag, hash, ok := parseMainAssetName(tc) + Expect(ok).To(BeFalse(), fmt.Sprintf("mainAssetName=%q", tc)) + Expect(repo).To(BeEmpty()) + Expect(tag).To(BeEmpty()) + Expect(hash).To(BeEmpty()) + } + }) +})