appview: report a never-scanned image as unscanned, not as a failure

digest_content.go branched on Error == "never-scanned" to pick the
"not scanned yet" copy, but nothing anywhere produced that string: both
FetchVulnDetails and FetchSbomDetails returned the human sentence
"No scan record found" for a missing record. So vulnReason and sbomReason
could never be "not-scanned", the friendly branches in vulns-section.html
and sbom-section.html were dead code, and every unscanned image fell
through to fetch-failed.

Free-tier accounts have scan_on_push off, so this was every image they
push, told "Scan data couldn't be loaded... try refreshing in a minute"
about something that had never been scanned and never would be by
refreshing. The digest page showed the raw internal string instead.

Replace the prose sentinel with a NotScanned bool the 404 path actually
sets, and give other non-200 statuses a distinct message so a 500 from the
hold stops being indistinguishable from an absent record. The detail
templates branch on it before Error, so nothing leaks the internal value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UAqi2hS2dhZoatqcWoYZQk
This commit is contained in:
Evan Jarrett
2026-09-02 12:44:48 -05:00
co-authored by Claude Opus 5
parent 27ce122db0
commit 1253ca15ec
6 changed files with 44 additions and 16 deletions
+2 -2
View File
@@ -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"
+10 -4
View File
@@ -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 {
+16 -5
View File
@@ -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 {
+7 -3
View File
@@ -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")
}
}
@@ -1,5 +1,7 @@
{{ define "sbom-details" }}
{{ if .Error }}
{{ if .NotScanned }}
<p class="text-base-content/70">No SBOM available yet. The scanner generates an SBOM alongside each scan.</p>
{{ else if .Error }}
<div class="alert alert-error" role="alert">
{{ icon "alert-circle" "size-5 shrink-0" }}
<span>{{ .Error }}</span>
@@ -1,5 +1,10 @@
{{ define "vuln-details" }}
{{ if .Error }}
{{ if .NotScanned }}
<div class="py-8 text-sm text-base-content/70 max-w-prose">
<p class="font-medium text-base-content">No vulnerability scan available yet</p>
<p class="mt-1">Scans run automatically shortly after a push. Check back in a few minutes, or push a new tag to trigger a scan.</p>
</div>
{{ else if .Error }}
{{ if gt .Summary.Total 0 }}
<!-- Summary available but no detailed report -->
<div class="space-y-4">