Files
Evan Jarrett 6758996300 add SBOM package diffing, verify hold-service captain records
- diff view gains a Packages tab with added/removed/changed/unchanged
  package tables and purl-derived type/license/upstream links
- captain records verified against the DID's atcr_hold service before
  caching (processor + batch backfill), preventing forged holds
- fix empty-handle updates clobbering cached handles and colliding on
  the UNIQUE constraint
- move fillPrevCIDs into repo.go; DirectRepoOperator is now canonical,
  repomgr kept as a test oracle
- surface read-only crew status in hold selector
- reconcile docs
2026-06-13 12:49:03 -05:00

745 lines
22 KiB
Go

package handlers
import (
"fmt"
"log/slog"
"net/http"
"sort"
"strings"
"sync"
"atcr.io/pkg/appview/db"
"atcr.io/pkg/appview/holdclient"
"atcr.io/pkg/atproto"
"github.com/go-chi/chi/v5"
)
// LayerDiffEntry represents one row in the layer diff table.
type LayerDiffEntry struct {
Status string // "shared", "rebuilt", "added", "removed"
Layer LayerDetail // the "to" layer (or from-layer for "removed")
PrevLayer *LayerDetail // set for "rebuilt" — the old layer
}
// VulnDiffEntry represents a vulnerability categorized by diff status.
type VulnDiffEntry struct {
Status string // "fixed", "new", "unchanged"
Vuln vulnMatch
}
// SbomDiffEntry represents one package categorized by diff status.
type SbomDiffEntry struct {
Status string // "added", "removed", "changed", "unchanged"
Package sbomPackage // the "to" package (or the "from" package for "removed")
PrevVersion string // set for "changed" — the old version
}
// DiffSummary is the top-line summary for the banner and diff page.
type DiffSummary struct {
SizeDelta int64 // bytes, positive = "to" is larger
LayerCountFrom int
LayerCountTo int
VulnFixedCount int
VulnNewCount int
VulnFixedBySev vulnSummary
VulnNewBySev vulnSummary
HasVulnData bool
PkgAddedCount int
PkgRemovedCount int
PkgChangedCount int
HasSbomData bool
}
// layerKey returns the matching key for a layer — digest for real layers, command for empty layers.
func layerKey(l LayerDetail) string {
if l.Command != "" {
return l.Command
}
return l.Digest
}
// computeLayerDiff compares two ordered LayerDetail slices using LCS on commands (git diff style).
// Handles insertions and deletions in the middle, not just prefix divergence.
func computeLayerDiff(fromLayers, toLayers []LayerDetail) []LayerDiffEntry {
n := len(fromLayers)
m := len(toLayers)
// Build LCS table on layer keys (command or digest)
dp := make([][]int, n+1)
for i := range dp {
dp[i] = make([]int, m+1)
}
for i := 1; i <= n; i++ {
for j := 1; j <= m; j++ {
if layerKey(fromLayers[i-1]) == layerKey(toLayers[j-1]) {
dp[i][j] = dp[i-1][j-1] + 1
} else if dp[i-1][j] >= dp[i][j-1] {
dp[i][j] = dp[i-1][j]
} else {
dp[i][j] = dp[i][j-1]
}
}
}
// Backtrack to produce the diff
var result []LayerDiffEntry
i, j := n, m
// Build in reverse, then flip
var rev []LayerDiffEntry
for i > 0 || j > 0 {
if i > 0 && j > 0 && layerKey(fromLayers[i-1]) == layerKey(toLayers[j-1]) {
fl := fromLayers[i-1]
tl := toLayers[j-1]
// Same key — check if digest also matches
sameDigest := false
if fl.EmptyLayer && tl.EmptyLayer {
sameDigest = true // empty layers matched by command
} else if !fl.EmptyLayer && !tl.EmptyLayer {
sameDigest = fl.Digest == tl.Digest
}
if sameDigest {
rev = append(rev, LayerDiffEntry{Status: "shared", Layer: tl})
} else {
prevLayer := fl
rev = append(rev, LayerDiffEntry{Status: "rebuilt", Layer: tl, PrevLayer: &prevLayer})
}
i--
j--
} else if j > 0 && (i == 0 || dp[i][j-1] >= dp[i-1][j]) {
rev = append(rev, LayerDiffEntry{Status: "added", Layer: toLayers[j-1]})
j--
} else {
rev = append(rev, LayerDiffEntry{Status: "removed", Layer: fromLayers[i-1]})
i--
}
}
// Reverse
result = make([]LayerDiffEntry, len(rev))
for k, v := range rev {
result[len(rev)-1-k] = v
}
return result
}
// computeVulnDiff compares two vulnerability match slices by CVE ID.
func computeVulnDiff(fromMatches, toMatches []vulnMatch) []VulnDiffEntry {
fromSet := make(map[string]vulnMatch, len(fromMatches))
for _, m := range fromMatches {
fromSet[m.CVEID] = m
}
toSet := make(map[string]vulnMatch, len(toMatches))
for _, m := range toMatches {
toSet[m.CVEID] = m
}
var result []VulnDiffEntry
// Fixed: in from but not to
for id, m := range fromSet {
if _, ok := toSet[id]; !ok {
result = append(result, VulnDiffEntry{Status: "fixed", Vuln: m})
}
}
// New: in to but not from
for id, m := range toSet {
if _, ok := fromSet[id]; !ok {
result = append(result, VulnDiffEntry{Status: "new", Vuln: m})
}
}
// Unchanged: in both
for id, m := range toSet {
if _, ok := fromSet[id]; ok {
result = append(result, VulnDiffEntry{Status: "unchanged", Vuln: m})
}
}
return result
}
// computeSbomDiff compares two package lists. Identical entries
// (name+type+version) match first as "unchanged"; leftovers pair by
// name+type as "changed" (version bump); the rest are "added"/"removed".
// Multiset counting keeps duplicate identical entries on one side from all
// matching a single entry on the other, and the name+type grouping keeps a
// deb package from pairing against a binary of the same name.
func computeSbomDiff(fromPkgs, toPkgs []sbomPackage) []SbomDiffEntry {
exactKey := func(p sbomPackage) string {
return p.Name + "\x00" + p.Type + "\x00" + p.Version
}
nameKey := func(p sbomPackage) string {
return p.Name + "\x00" + p.Type
}
// Exact pass: count from-side entries, consume per to-side match.
fromCounts := make(map[string]int, len(fromPkgs))
for _, p := range fromPkgs {
fromCounts[exactKey(p)]++
}
var result []SbomDiffEntry
var toLeft []sbomPackage
for _, p := range toPkgs {
if k := exactKey(p); fromCounts[k] > 0 {
fromCounts[k]--
result = append(result, SbomDiffEntry{Status: "unchanged", Package: p})
} else {
toLeft = append(toLeft, p)
}
}
fromGroups := make(map[string][]sbomPackage)
for _, p := range fromPkgs {
if k := exactKey(p); fromCounts[k] > 0 {
fromCounts[k]--
fromGroups[nameKey(p)] = append(fromGroups[nameKey(p)], p)
}
}
// Version-change pass: pair leftovers that share name+type. Versions are
// sorted lexically per group — determinism matters more than semver
// correctness for pairing multiple installed versions.
toGroups := make(map[string][]sbomPackage)
for _, p := range toLeft {
toGroups[nameKey(p)] = append(toGroups[nameKey(p)], p)
}
byVersion := func(pkgs []sbomPackage) {
sort.Slice(pkgs, func(i, j int) bool { return pkgs[i].Version < pkgs[j].Version })
}
for k, toGroup := range toGroups {
fromGroup := fromGroups[k]
byVersion(fromGroup)
byVersion(toGroup)
paired := min(len(fromGroup), len(toGroup))
for i := range paired {
result = append(result, SbomDiffEntry{Status: "changed", Package: toGroup[i], PrevVersion: fromGroup[i].Version})
}
for _, p := range toGroup[paired:] {
result = append(result, SbomDiffEntry{Status: "added", Package: p})
}
for _, p := range fromGroup[paired:] {
result = append(result, SbomDiffEntry{Status: "removed", Package: p})
}
delete(fromGroups, k)
}
for _, fromGroup := range fromGroups {
for _, p := range fromGroup {
result = append(result, SbomDiffEntry{Status: "removed", Package: p})
}
}
// Map iteration above is unordered — sort for stable rendering.
sort.Slice(result, func(i, j int) bool {
a, b := result[i].Package, result[j].Package
if a.Name != b.Name {
return a.Name < b.Name
}
if a.Type != b.Type {
return a.Type < b.Type
}
if a.Version != b.Version {
return a.Version < b.Version
}
return result[i].Status < result[j].Status
})
return result
}
// computeDiffSummary derives the top-line summary from layer and vuln diffs.
func computeDiffSummary(fromLayers, toLayers []LayerDetail, vulnDiff []VulnDiffEntry, hasVulnData bool, sbomDiff []SbomDiffEntry, hasSbomData bool) DiffSummary {
var fromSize, toSize int64
for _, l := range fromLayers {
fromSize += l.Size
}
for _, l := range toLayers {
toSize += l.Size
}
summary := DiffSummary{
SizeDelta: toSize - fromSize,
LayerCountFrom: len(fromLayers),
LayerCountTo: len(toLayers),
HasVulnData: hasVulnData,
HasSbomData: hasSbomData,
}
for _, entry := range vulnDiff {
switch entry.Status {
case "fixed":
summary.VulnFixedCount++
addToSevCount(&summary.VulnFixedBySev, entry.Vuln.Severity)
case "new":
summary.VulnNewCount++
addToSevCount(&summary.VulnNewBySev, entry.Vuln.Severity)
}
}
for _, entry := range sbomDiff {
switch entry.Status {
case "added":
summary.PkgAddedCount++
case "removed":
summary.PkgRemovedCount++
case "changed":
summary.PkgChangedCount++
}
}
return summary
}
func addToSevCount(s *vulnSummary, severity string) {
// Normalize to canonical casing so "CRITICAL", "critical", "Crit" all land
// in the same bucket. Unknown severities count toward the total but don't
// bump any bucket — the template renders them as "Unknown" via the
// severityLabel helper.
switch strings.ToLower(strings.TrimSpace(severity)) {
case "critical", "crit", "c":
s.Critical++
case "high", "h":
s.High++
case "medium", "med", "m":
s.Medium++
case "low", "l":
s.Low++
}
s.Total++
}
// sbomDiffStatus maps one side's SBOM fetch result to a status the template
// can branch on: "ok", "no-data" (no scan record or SBOM blob),
// "not-applicable" (scanner skipped this artifact type), or
// "hold-unreachable" (couldn't resolve or reach the hold).
func sbomDiffStatus(d *sbomDetailsData) string {
switch {
case d == nil:
return "hold-unreachable"
case d.Status == atproto.ScanStatusSkipped:
return "not-applicable"
case d.Error != "":
return "no-data"
default:
return "ok"
}
}
// ManifestDiffHandler renders the full diff page comparing two manifests.
type ManifestDiffHandler struct {
BaseUIHandler
}
func (h *ManifestDiffHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
identifier := chi.URLParam(r, "handle")
// Route: /diff/{handle}/* — wildcard captures the repo name
repo := strings.TrimPrefix(chi.URLParam(r, "*"), "/")
if repo == "" {
RenderNotFound(w, r, &h.BaseUIHandler)
return
}
fromParam := r.URL.Query().Get("from")
toParam := r.URL.Query().Get("to")
if fromParam == "" || toParam == "" {
RenderNotFound(w, r, &h.BaseUIHandler)
return
}
// Resolve identity
did, resolvedHandle, _, err := atproto.ResolveIdentity(r.Context(), identifier)
if err != nil {
RenderNotFound(w, r, &h.BaseUIHandler)
return
}
owner, err := db.GetUserByDID(h.ReadOnlyDB, did)
if err != nil || owner == nil {
RenderNotFound(w, r, &h.BaseUIHandler)
return
}
if owner.Handle != resolvedHandle {
_ = db.UpdateUserHandle(h.DB, did, resolvedHandle)
owner.Handle = resolvedHandle
}
// Resolve from/to params — accept either digests (sha256:...) or tag names
fromDigest := fromParam
toDigest := toParam
if !strings.HasPrefix(fromDigest, "sha256:") {
tag, err := db.GetTagByName(h.ReadOnlyDB, owner.DID, repo, fromParam)
if err != nil || tag == nil {
RenderNotFound(w, r, &h.BaseUIHandler)
return
}
fromDigest = tag.Digest
}
if !strings.HasPrefix(toDigest, "sha256:") {
tag, err := db.GetTagByName(h.ReadOnlyDB, owner.DID, repo, toParam)
if err != nil || tag == nil {
RenderNotFound(w, r, &h.BaseUIHandler)
return
}
toDigest = tag.Digest
}
// Fetch both manifests
type manifestData struct {
manifest *db.ManifestWithMetadata
layers []LayerDetail
vulnData *vulnDetailsData
sbomData *sbomDetailsData
err error
}
// fetchManifest fetches layers and vulns for a digest.
// For manifest lists, it uses the provided platform child digest instead.
fetchManifest := func(digest, platformDigest string) manifestData {
m, err := db.GetManifestDetail(h.ReadOnlyDB, owner.DID, repo, digest)
if err != nil {
return manifestData{err: err}
}
// For multi-arch, resolve to the platform child
layerManifest := m
layerDigest := digest
holdEndpoint := m.HoldEndpoint
if m.IsManifestList && platformDigest != "" {
child, err := db.GetManifestDetail(h.ReadOnlyDB, owner.DID, repo, platformDigest)
if err == nil {
layerManifest = child
layerDigest = platformDigest
if child.HoldEndpoint != "" {
holdEndpoint = child.HoldEndpoint
}
}
}
dbLayers, _ := db.GetLayersForManifest(h.ReadOnlyDB, layerManifest.ID)
var layers []LayerDetail
var vulnData *vulnDetailsData
var sbomData *sbomDetailsData
hold, holdErr := ResolveHold(r.Context(), h.ReadOnlyDB, holdEndpoint)
if holdErr == nil {
// Parallelize the three hold fetches. They're independent and
// each takes a network round-trip; serial runs add up on slow links.
var fwg sync.WaitGroup
fwg.Add(3)
go func() {
defer fwg.Done()
config, err := holdclient.FetchImageConfig(r.Context(), hold.URL, layerDigest)
if err == nil {
layers = buildLayerDetails(config.History, dbLayers)
} else {
layers = buildLayerDetails(nil, dbLayers)
}
}()
go func() {
defer fwg.Done()
vd := FetchVulnDetails(r.Context(), hold.DID, layerDigest)
vulnData = &vd
}()
go func() {
defer fwg.Done()
sd := FetchSbomDetails(r.Context(), hold.DID, layerDigest)
sbomData = &sd
}()
fwg.Wait()
} else {
layers = buildLayerDetails(nil, dbLayers)
}
return manifestData{manifest: m, layers: layers, vulnData: vulnData, sbomData: sbomData}
}
// First fetch both top-level manifests to check for multi-arch
fromManifest, err := db.GetManifestDetail(h.ReadOnlyDB, owner.DID, repo, fromDigest)
if err != nil {
RenderNotFound(w, r, &h.BaseUIHandler)
return
}
toManifest, err := db.GetManifestDetail(h.ReadOnlyDB, owner.DID, repo, toDigest)
if err != nil {
RenderNotFound(w, r, &h.BaseUIHandler)
return
}
// Find common platforms for multi-arch
var commonPlatforms []db.PlatformInfo
var selectedPlatform string
isMultiArch := fromManifest.IsManifestList && toManifest.IsManifestList
fromPlatformDigest := ""
toPlatformDigest := ""
// platKey returns "os/arch[/variant]" for a platform.
platKey := func(os, arch, variant string) string {
k := os + "/" + arch
if variant != "" {
k += "/" + variant
}
return k
}
// pickPlatformChild returns the child digest from a manifest list whose
// platform matches the given key. Returns "" if no match.
pickPlatformChild := func(m *db.ManifestWithMetadata, key string) string {
for _, p := range m.Platforms {
if platKey(p.OS, p.Architecture, p.Variant) == key {
return p.Digest
}
}
return ""
}
if isMultiArch {
// Build intersection of platforms
for _, fp := range fromManifest.Platforms {
for _, tp := range toManifest.Platforms {
if fp.OS == tp.OS && fp.Architecture == tp.Architecture && fp.Variant == tp.Variant {
commonPlatforms = append(commonPlatforms, tp)
break
}
}
}
// Use query param or default to first common platform
selectedPlatform = r.URL.Query().Get("platform")
if len(commonPlatforms) > 0 {
if selectedPlatform == "" {
selectedPlatform = platKey(commonPlatforms[0].OS, commonPlatforms[0].Architecture, commonPlatforms[0].Variant)
}
fromPlatformDigest = pickPlatformChild(fromManifest, selectedPlatform)
toPlatformDigest = pickPlatformChild(toManifest, selectedPlatform)
}
} else if fromManifest.IsManifestList != toManifest.IsManifestList {
// Mixed: one side is a manifest list, the other is a platform child.
// Match them by looking up the single-arch side's platform via its
// parent manifest_references row and picking the matching child from
// the manifest list side.
var listSide *db.ManifestWithMetadata
var childDigest string
if fromManifest.IsManifestList {
listSide = fromManifest
childDigest = toDigest
} else {
listSide = toManifest
childDigest = fromDigest
}
plat, _ := db.GetChildManifestPlatform(h.ReadOnlyDB, owner.DID, repo, childDigest)
var listChildDigest string
if plat != nil {
listChildDigest = pickPlatformChild(listSide, platKey(plat.OS, plat.Architecture, plat.Variant))
}
// Fallback: if we couldn't determine the platform (or no match),
// default to the first non-attestation child of the manifest list so
// the diff at least shows real layers instead of an empty index.
if listChildDigest == "" {
for _, p := range listSide.Platforms {
if p.Digest != "" {
listChildDigest = p.Digest
break
}
}
}
if fromManifest.IsManifestList {
fromPlatformDigest = listChildDigest
} else {
toPlatformDigest = listChildDigest
}
}
// Fetch layer/vuln data in parallel
var fromData, toData manifestData
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
fromData = fetchManifest(fromDigest, fromPlatformDigest)
}()
go func() {
defer wg.Done()
toData = fetchManifest(toDigest, toPlatformDigest)
}()
wg.Wait()
// Track per-side fetch failures so we render the page with an inline
// alert naming which tag failed, instead of a generic 404 that makes
// users guess whether they typoed a tag or hit a transient outage.
// fromData.manifest / toData.manifest is nil only when the re-fetch at
// the top of fetchManifest hit a DB error (the tag resolution earlier
// already ruled out typos).
fromFailed := fromData.err != nil || fromData.manifest == nil
toFailed := toData.err != nil || toData.manifest == nil
// Fall back to the top-level manifest we already fetched so the page
// still has something to render for tag labels and metadata.
if fromFailed {
fromData.manifest = fromManifest
}
if toFailed {
toData.manifest = toManifest
}
// Compute diffs
layerDiff := computeLayerDiff(fromData.layers, toData.layers)
// ScanStatus distinguishes why vuln data may be missing: "ok" when both
// sides returned clean scan results; "no-data" when a scan was never
// recorded; "hold-unreachable" when we couldn't reach the hold to ask.
// The template branches on these so users can tell "not scanned yet"
// from "hold offline" at a glance.
fromScanStatus := "ok"
toScanStatus := "ok"
if fromData.vulnData == nil {
fromScanStatus = "hold-unreachable"
} else if fromData.vulnData.Error != "" {
fromScanStatus = "no-data"
}
if toData.vulnData == nil {
toScanStatus = "hold-unreachable"
} else if toData.vulnData.Error != "" {
toScanStatus = "no-data"
}
var vulnDiff []VulnDiffEntry
hasVulnData := fromScanStatus == "ok" && toScanStatus == "ok"
if hasVulnData {
vulnDiff = computeVulnDiff(fromData.vulnData.Matches, toData.vulnData.Matches)
}
// SBOM status mirrors the scan status, with one extra case: the scanner
// records status="skipped" for artifact types it doesn't scan, which the
// template surfaces as "not applicable" rather than "not scanned yet".
fromSbomStatus := sbomDiffStatus(fromData.sbomData)
toSbomStatus := sbomDiffStatus(toData.sbomData)
var sbomDiff []SbomDiffEntry
hasSbomData := fromSbomStatus == "ok" && toSbomStatus == "ok"
if hasSbomData {
sbomDiff = computeSbomDiff(fromData.sbomData.Packages, toData.sbomData.Packages)
}
summary := computeDiffSummary(fromData.layers, toData.layers, vulnDiff, hasVulnData, sbomDiff, hasSbomData)
// Determine tag labels
fromTag := fromDigest
if len(fromData.manifest.Tags) > 0 {
fromTag = fromData.manifest.Tags[0]
}
toTag := toDigest
if len(toData.manifest.Tags) > 0 {
toTag = toData.manifest.Tags[0]
}
// Count vulns by status for template
var fixedVulns, newVulns, unchangedVulns []vulnMatch
for _, entry := range vulnDiff {
switch entry.Status {
case "fixed":
fixedVulns = append(fixedVulns, entry.Vuln)
case "new":
newVulns = append(newVulns, entry.Vuln)
case "unchanged":
unchangedVulns = append(unchangedVulns, entry.Vuln)
}
}
// Split packages by status for template
var addedPackages, removedPackages, changedPackages, unchangedPackages []SbomDiffEntry
for _, entry := range sbomDiff {
switch entry.Status {
case "added":
addedPackages = append(addedPackages, entry)
case "removed":
removedPackages = append(removedPackages, entry)
case "changed":
changedPackages = append(changedPackages, entry)
case "unchanged":
unchangedPackages = append(unchangedPackages, entry)
}
}
title := fmt.Sprintf("Diff: %s → %s - %s/%s - %s", fromTag, toTag, owner.Handle, repo, h.ClientShortName)
description := fmt.Sprintf("Comparing %s to %s in %s/%s", fromTag, toTag, owner.Handle, repo)
meta := NewPageMeta(title, description).
WithCanonical(fmt.Sprintf("https://%s/diff/%s/%s?from=%s&to=%s", h.SiteURL, owner.Handle, repo, fromDigest, toDigest)).
WithSiteName(h.ClientShortName)
data := struct {
PageData
Meta *PageMeta
Owner *db.User
Repository string
FromManifest *db.ManifestWithMetadata
ToManifest *db.ManifestWithMetadata
FromTag string
ToTag string
Summary DiffSummary
LayerDiff []LayerDiffEntry
FixedVulns []vulnMatch
NewVulns []vulnMatch
UnchangedVulns []vulnMatch
HasVulnData bool
FromScanStatus string
ToScanStatus string
AddedPackages []SbomDiffEntry
RemovedPackages []SbomDiffEntry
ChangedPackages []SbomDiffEntry
UnchangedPackages []SbomDiffEntry
HasSbomData bool
FromSbomStatus string
ToSbomStatus string
FromFailed bool
ToFailed bool
IsMultiArch bool
CommonPlatforms []db.PlatformInfo
SelectedPlatform string
FromDigest string
ToDigest string
}{
PageData: NewPageData(r, &h.BaseUIHandler),
Meta: meta,
Owner: owner,
Repository: repo,
FromManifest: fromData.manifest,
ToManifest: toData.manifest,
FromTag: fromTag,
ToTag: toTag,
Summary: summary,
LayerDiff: layerDiff,
FixedVulns: fixedVulns,
NewVulns: newVulns,
UnchangedVulns: unchangedVulns,
HasVulnData: hasVulnData,
FromScanStatus: fromScanStatus,
ToScanStatus: toScanStatus,
AddedPackages: addedPackages,
RemovedPackages: removedPackages,
ChangedPackages: changedPackages,
UnchangedPackages: unchangedPackages,
HasSbomData: hasSbomData,
FromSbomStatus: fromSbomStatus,
ToSbomStatus: toSbomStatus,
FromFailed: fromFailed,
ToFailed: toFailed,
IsMultiArch: isMultiArch,
CommonPlatforms: commonPlatforms,
SelectedPlatform: selectedPlatform,
FromDigest: fromDigest,
ToDigest: toDigest,
}
if err := h.Templates.ExecuteTemplate(w, "diff", data); err != nil {
slog.Warn("Failed to render diff page", "error", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}