diff --git a/pkg/appview/handlers/digest_content.go b/pkg/appview/handlers/digest_content.go index 55abd72..2f26ca1 100644 --- a/pkg/appview/handlers/digest_content.go +++ b/pkg/appview/handlers/digest_content.go @@ -160,7 +160,7 @@ func (h *DigestContentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) vulnReason := "ok" if !holdReachable { vulnReason = "hold-unreachable" - } else if vulnData == nil || vulnData.Error == "never-scanned" { + } else if vulnData == nil || vulnData.NotScanned { vulnReason = "not-scanned" } else if vulnData.Status == atproto.ScanStatusSkipped { vulnReason = "not-applicable" @@ -171,7 +171,7 @@ func (h *DigestContentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) sbomReason := "ok" if !holdReachable { sbomReason = "hold-unreachable" - } else if sbomData == nil || sbomData.Error == "never-scanned" { + } else if sbomData == nil || sbomData.NotScanned { sbomReason = "not-scanned" } else if sbomData.Status == atproto.ScanStatusSkipped { sbomReason = "not-applicable" diff --git a/pkg/appview/handlers/sbom_details.go b/pkg/appview/handlers/sbom_details.go index f335c93..a52b9d4 100644 --- a/pkg/appview/handlers/sbom_details.go +++ b/pkg/appview/handlers/sbom_details.go @@ -43,9 +43,12 @@ type spdxExternalRef struct { // sbomDetailsData is the template data for the sbom-details partial. type sbomDetailsData struct { - Packages []sbomPackage - Total int - Error string + Packages []sbomPackage + Total int + Error string + // NotScanned means the hold answered but holds no scan record for this + // manifest. See the note on vulnDetailsData.NotScanned. + NotScanned bool Status string // scan record's status field (ok | failed | skipped); empty for legacy records Reason string // scan record's reason field (only meaningful when Status != ok) ScannedAt string @@ -125,7 +128,10 @@ func FetchSbomDetails(ctx context.Context, holdEndpoint, digest string) sbomDeta defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return sbomDetailsData{Error: "No scan record found"} + if resp.StatusCode == http.StatusNotFound { + return sbomDetailsData{NotScanned: true} + } + return sbomDetailsData{Error: fmt.Sprintf("Scan lookup failed (HTTP %d)", resp.StatusCode)} } var envelope struct { diff --git a/pkg/appview/handlers/vuln_details.go b/pkg/appview/handlers/vuln_details.go index d186ea8..20b51d2 100644 --- a/pkg/appview/handlers/vuln_details.go +++ b/pkg/appview/handlers/vuln_details.go @@ -54,9 +54,13 @@ type grypePackage struct { // vulnDetailsData is the template data for the vuln-details partial. type vulnDetailsData struct { - Matches []vulnMatch - Summary vulnSummary - Error string // non-empty if something went wrong + Matches []vulnMatch + Summary vulnSummary + Error string // non-empty if something went wrong + // NotScanned means the hold answered but holds no scan record for this + // manifest. Distinct from Error: nothing failed, the image was simply + // never scanned, and the UI must not present it as a failure. + NotScanned bool Status string // scan record's status field (ok | failed | skipped); empty for legacy records Reason string // scan record's reason field (only meaningful when Status != ok) ScannedAt string @@ -137,7 +141,11 @@ func (h *VulnDetailsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - h.renderDetails(w, vulnDetailsData{Error: "No scan record found"}) + if resp.StatusCode == http.StatusNotFound { + h.renderDetails(w, vulnDetailsData{NotScanned: true}) + return + } + h.renderDetails(w, vulnDetailsData{Error: fmt.Sprintf("Scan lookup failed (HTTP %d)", resp.StatusCode)}) return } @@ -296,7 +304,10 @@ func FetchVulnDetails(ctx context.Context, holdEndpoint, digest string) vulnDeta defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return vulnDetailsData{Error: "No scan record found"} + if resp.StatusCode == http.StatusNotFound { + return vulnDetailsData{NotScanned: true} + } + return vulnDetailsData{Error: fmt.Sprintf("Scan lookup failed (HTTP %d)", resp.StatusCode)} } var envelope struct { diff --git a/pkg/appview/handlers/vuln_details_test.go b/pkg/appview/handlers/vuln_details_test.go index a3eed09..e2d6086 100644 --- a/pkg/appview/handlers/vuln_details_test.go +++ b/pkg/appview/handlers/vuln_details_test.go @@ -289,9 +289,13 @@ func TestVulnDetails_NotFound(t *testing.T) { body := rr.Body.String() - // Should show an error message (modal still needs content) - if !strings.Contains(body, "No scan record found") { - t.Error("Expected body to contain error message for 404") + // A 404 from the hold means the manifest was never scanned, which is not a + // failure: the panel must say so rather than reporting an error. + if !strings.Contains(body, "No vulnerability scan available yet") { + t.Errorf("Expected the not-scanned copy for a 404, got: %s", body) + } + if strings.Contains(body, "Scan lookup failed") { + t.Error("A 404 must not be reported as a lookup failure") } } diff --git a/pkg/appview/templates/partials/sbom-details.html b/pkg/appview/templates/partials/sbom-details.html index 05725e3..c5de174 100644 --- a/pkg/appview/templates/partials/sbom-details.html +++ b/pkg/appview/templates/partials/sbom-details.html @@ -1,5 +1,7 @@ {{ define "sbom-details" }} -{{ if .Error }} +{{ if .NotScanned }} +

No SBOM available yet. The scanner generates an SBOM alongside each scan.

+{{ else if .Error }}