Files
Evan JarrettandClaude Opus 5 1253ca15ec 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
2026-09-02 12:44:48 -05:00

226 lines
7.1 KiB
Go

package handlers
import (
"fmt"
"log/slog"
"net/http"
"strings"
"sync"
"atcr.io/pkg/appview/db"
"atcr.io/pkg/appview/holdclient"
"atcr.io/pkg/appview/middleware"
"atcr.io/pkg/atproto"
"github.com/go-chi/chi/v5"
)
// DigestContentHandler returns the layers + vulnerabilities HTML fragment
// for a specific platform manifest. Used by the arch dropdown via HTMX.
type DigestContentHandler struct {
BaseUIHandler
}
func (h *DigestContentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
identifier := chi.URLParam(r, "handle")
wildcard := strings.TrimPrefix(chi.URLParam(r, "*"), "/")
repository := wildcard
digest := r.URL.Query().Get("digest")
if digest == "" || repository == "" {
http.Error(w, "missing parameters", http.StatusBadRequest)
return
}
did, _, _, err := atproto.ResolveIdentity(r.Context(), identifier)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
manifest, err := db.GetManifestDetail(h.ReadOnlyDB, did, repository, digest)
if err != nil {
http.Error(w, "manifest not found", http.StatusNotFound)
return
}
dbLayers, err := db.GetLayersForManifest(h.ReadOnlyDB, manifest.Key)
if err != nil {
slog.Warn("Failed to fetch layers", "error", err)
}
hold, holdErr := ResolveHold(r.Context(), h.ReadOnlyDB, manifest.HoldEndpoint)
holdReachable := holdErr == nil
// Helm charts have no scannable layers / vulns / SBOM. Render helm-aware
// content for the default + "chart" sections, and a not-applicable
// placeholder for the legacy layers / vulns / sbom sections (which
// shouldn't be requested for helm but might be if a stale tab fires).
if manifest.ArtifactType == db.ArtifactTypeHelmChart {
holdURL := ""
if holdReachable {
holdURL = hold.URL
}
helm := buildHelmContent(r.Context(), holdURL, digest, dbLayers)
if !holdReachable {
helm.HoldUnreachable = true
}
helmData := struct {
Manifest *db.ManifestWithMetadata
HelmContent *HelmChartContent
RegistryURL string
OwnerHandle string
RepoName string
OciClient string
IsLoggedIn bool
}{
Manifest: manifest,
HelmContent: helm,
RegistryURL: h.RegistryURL,
OwnerHandle: identifier,
RepoName: repository,
OciClient: "", // helm switcher ignores this field
IsLoggedIn: middleware.GetUser(r) != nil,
}
w.Header().Set("Content-Type", "text/html")
section := r.URL.Query().Get("section")
switch section {
case "chart":
// Used by the repo page's chart tab — no install card here
// because repo-tag-section already renders one at the top.
if err := h.Templates.ExecuteTemplate(w, "helm-chart-info", helmData); err != nil {
slog.Warn("Failed to render helm chart info", "error", err)
RenderHTMXError(w, r, http.StatusInternalServerError, "Could not render helm chart", err)
}
case "layers", "vulns", "sbom":
// Defensive fallback if a stale tab somehow fires. The repo page
// hides these tabs for helm; this should be unreachable.
fmt.Fprint(w, `<p class="text-base-content/70 py-8">Helm charts don't have layers, vulnerabilities, or SBOMs.</p>`)
default:
// Digest detail page (full helm view, with install card).
if err := h.Templates.ExecuteTemplate(w, "helm-digest-content", helmData); err != nil {
slog.Warn("Failed to render helm digest content", "error", err)
RenderHTMXError(w, r, http.StatusInternalServerError, "Could not render helm chart", err)
}
}
return
}
// Parallelize the three hold fetches. They're independent and each
// takes a network round-trip; serial runs add up on slow links.
var (
layers []LayerDetail
vulnData *vulnDetailsData
sbomData *sbomDetailsData
configFetchError bool
)
if holdReachable {
var wg sync.WaitGroup
wg.Add(3)
go func() {
defer wg.Done()
config, err := holdclient.FetchImageConfig(r.Context(), hold.URL, digest)
if err == nil {
layers = buildLayerDetails(config.History, dbLayers)
} else {
slog.Warn("Failed to fetch image config", "error", err,
"holdEndpoint", manifest.HoldEndpoint, "manifestDigest", digest)
layers = buildLayerDetails(nil, dbLayers)
configFetchError = true
}
}()
go func() {
defer wg.Done()
vd := FetchVulnDetails(r.Context(), hold.DID, digest)
vulnData = &vd
}()
go func() {
defer wg.Done()
sd := FetchSbomDetails(r.Context(), hold.DID, digest)
sbomData = &sd
}()
wg.Wait()
} else {
layers = buildLayerDetails(nil, dbLayers)
}
// VulnReason / SbomReason let the template branch distinctly on why
// data is missing instead of collapsing causes into a generic message.
// ok — data is present
// hold-unreachable — we couldn't reach the hold
// not-scanned — hold is up but no scan record exists
// not-applicable — scan record exists with status="skipped" (artifact
// type isn't scanned, e.g. in-toto, DSSE — helm
// charts go through a separate code path)
// fetch-failed — scan record fetch failed on the hold
vulnReason := "ok"
if !holdReachable {
vulnReason = "hold-unreachable"
} else if vulnData == nil || vulnData.NotScanned {
vulnReason = "not-scanned"
} else if vulnData.Status == atproto.ScanStatusSkipped {
vulnReason = "not-applicable"
} else if vulnData.Error != "" {
vulnReason = "fetch-failed"
}
sbomReason := "ok"
if !holdReachable {
sbomReason = "hold-unreachable"
} else if sbomData == nil || sbomData.NotScanned {
sbomReason = "not-scanned"
} else if sbomData.Status == atproto.ScanStatusSkipped {
sbomReason = "not-applicable"
} else if sbomData.Error != "" {
sbomReason = "fetch-failed"
}
data := struct {
Layers []LayerDetail
VulnData *vulnDetailsData
SbomData *sbomDetailsData
HoldReachable bool
ConfigFetchError bool
VulnReason string
SbomReason string
}{
Layers: layers,
VulnData: vulnData,
SbomData: sbomData,
HoldReachable: holdReachable,
ConfigFetchError: configFetchError,
VulnReason: vulnReason,
SbomReason: sbomReason,
}
w.Header().Set("Content-Type", "text/html")
section := r.URL.Query().Get("section")
switch section {
case "layers":
if err := h.Templates.ExecuteTemplate(w, "layers-section", data); err != nil {
slog.Warn("Failed to render layers section", "error", err)
RenderHTMXError(w, r, http.StatusInternalServerError, "Could not render layers", err)
}
case "vulns":
if err := h.Templates.ExecuteTemplate(w, "vulns-section", data); err != nil {
slog.Warn("Failed to render vulns section", "error", err)
RenderHTMXError(w, r, http.StatusInternalServerError, "Could not render vulnerabilities", err)
}
case "sbom":
if err := h.Templates.ExecuteTemplate(w, "sbom-section", data); err != nil {
slog.Warn("Failed to render sbom section", "error", err)
RenderHTMXError(w, r, http.StatusInternalServerError, "Could not render SBOM", err)
}
default:
if err := h.Templates.ExecuteTemplate(w, "digest-content", data); err != nil {
slog.Warn("Failed to render digest content", "error", err)
RenderHTMXError(w, r, http.StatusInternalServerError, "Could not render content", err)
}
}
}