mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-29 04:06:58 +00:00
- 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
745 lines
22 KiB
Go
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)
|
|
}
|
|
}
|