add diff support for layers and vulns

This commit is contained in:
Evan Jarrett
2026-03-29 21:42:55 -07:00
parent f20170f595
commit 183d89530d
10 changed files with 1489 additions and 0 deletions
+25
View File
@@ -712,6 +712,31 @@ type LatestTagInfo struct {
ArtifactType string
}
// MostRecentTagInfo holds the newest tag for a repo, including its digest and hold endpoint.
type MostRecentTagInfo struct {
Tag string
Digest string
HoldEndpoint string
CreatedAt time.Time
}
// GetMostRecentTag returns the most recently created tag with its digest and hold endpoint.
// Returns nil, nil if no tags exist.
func GetMostRecentTag(db DBTX, did, repository string) (*MostRecentTagInfo, error) {
var info MostRecentTagInfo
err := db.QueryRow(`
SELECT t.tag, t.digest, COALESCE(m.hold_endpoint, ''), t.created_at
FROM tags t
LEFT JOIN manifests m ON t.digest = m.digest AND t.did = m.did AND t.repository = m.repository
WHERE t.did = ? AND t.repository = ?
ORDER BY t.created_at DESC LIMIT 1
`, did, repository).Scan(&info.Tag, &info.Digest, &info.HoldEndpoint, &info.CreatedAt)
if err != nil {
return nil, nil // no tags is not an error
}
return &info, nil
}
// RepositoryExists checks if any manifests exist for a given repository.
func RepositoryExists(db DBTX, did, repository string) (bool, error) {
var count int
+462
View File
@@ -0,0 +1,462 @@
package handlers
import (
"fmt"
"log/slog"
"net/http"
"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
}
// 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
}
// 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
}
// computeDiffSummary derives the top-line summary from layer and vuln diffs.
func computeDiffSummary(fromLayers, toLayers []LayerDetail, vulnDiff []VulnDiffEntry, hasVulnData 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,
}
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)
}
}
return summary
}
func addToSevCount(s *vulnSummary, severity string) {
switch severity {
case "Critical":
s.Critical++
case "High":
s.High++
case "Medium":
s.Medium++
case "Low":
s.Low++
}
s.Total++
}
// 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
}
fromDigest := r.URL.Query().Get("from")
toDigest := r.URL.Query().Get("to")
if fromDigest == "" || toDigest == "" {
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.ReadOnlyDB, did, resolvedHandle)
owner.Handle = resolvedHandle
}
// Fetch both manifests
type manifestData struct {
manifest *db.ManifestWithMetadata
layers []LayerDetail
vulnData *vulnDetailsData
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
hold, holdErr := ResolveHold(r.Context(), h.ReadOnlyDB, holdEndpoint)
if holdErr == nil {
config, err := holdclient.FetchImageConfig(r.Context(), hold.URL, layerDigest)
if err == nil {
layers = buildLayerDetails(config.History, dbLayers)
} else {
layers = buildLayerDetails(nil, dbLayers)
}
vd := FetchVulnDetails(r.Context(), hold.DID, layerDigest)
vulnData = &vd
} else {
layers = buildLayerDetails(nil, dbLayers)
}
return manifestData{manifest: m, layers: layers, vulnData: vulnData}
}
// 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 := ""
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 = commonPlatforms[0].OS + "/" + commonPlatforms[0].Architecture
if commonPlatforms[0].Variant != "" {
selectedPlatform += "/" + commonPlatforms[0].Variant
}
}
// Find matching platform digests
for _, fp := range fromManifest.Platforms {
platKey := fp.OS + "/" + fp.Architecture
if fp.Variant != "" {
platKey += "/" + fp.Variant
}
if platKey == selectedPlatform {
fromPlatformDigest = fp.Digest
break
}
}
for _, tp := range toManifest.Platforms {
platKey := tp.OS + "/" + tp.Architecture
if tp.Variant != "" {
platKey += "/" + tp.Variant
}
if platKey == selectedPlatform {
toPlatformDigest = tp.Digest
break
}
}
}
}
// 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()
if fromData.err != nil || toData.err != nil {
RenderNotFound(w, r, &h.BaseUIHandler)
return
}
// Compute diffs
layerDiff := computeLayerDiff(fromData.layers, toData.layers)
var vulnDiff []VulnDiffEntry
hasVulnData := fromData.vulnData != nil && toData.vulnData != nil &&
fromData.vulnData.Error == "" && toData.vulnData.Error == ""
if hasVulnData {
vulnDiff = computeVulnDiff(fromData.vulnData.Matches, toData.vulnData.Matches)
}
summary := computeDiffSummary(fromData.layers, toData.layers, vulnDiff, hasVulnData)
// 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)
}
}
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
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,
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)
}
}
+402
View File
@@ -0,0 +1,402 @@
package handlers
import (
"testing"
)
func TestComputeLayerDiff_IdenticalLayers(t *testing.T) {
layers := []LayerDetail{
{Index: 1, Digest: "sha256:aaa", Size: 100, Command: "ADD file in /"},
{Index: 2, Digest: "sha256:bbb", Size: 200, Command: "RUN apt-get update"},
}
diff := computeLayerDiff(layers, layers)
if len(diff) != 2 {
t.Fatalf("expected 2 entries, got %d", len(diff))
}
for _, e := range diff {
if e.Status != "shared" {
t.Errorf("expected shared, got %s", e.Status)
}
}
}
func TestComputeLayerDiff_SharedPrefixThenDivergence(t *testing.T) {
from := []LayerDetail{
{Index: 1, Digest: "sha256:base", Size: 100},
{Index: 2, Digest: "sha256:old", Size: 200},
}
to := []LayerDetail{
{Index: 1, Digest: "sha256:base", Size: 100},
{Index: 2, Digest: "sha256:new1", Size: 300},
{Index: 3, Digest: "sha256:new2", Size: 150},
}
diff := computeLayerDiff(from, to)
// Lockstep: shared, then -/+ pair (no command match), then +1 added
if len(diff) != 4 {
t.Fatalf("expected 4 entries, got %d", len(diff))
}
expected := []struct {
status string
digest string
}{
{"shared", "sha256:base"},
{"removed", "sha256:old"}, // no command, different digest → -/+
{"added", "sha256:new1"},
{"added", "sha256:new2"}, // extra layer in to
}
for i, e := range expected {
if diff[i].Status != e.status {
t.Errorf("[%d] expected status %s, got %s", i, e.status, diff[i].Status)
}
if diff[i].Layer.Digest != e.digest {
t.Errorf("[%d] expected digest %s, got %s", i, e.digest, diff[i].Layer.Digest)
}
}
}
func TestComputeLayerDiff_SameCommandDifferentDigest(t *testing.T) {
from := []LayerDetail{
{Index: 1, Digest: "sha256:base", Size: 100, Command: "ADD file in /"},
{Index: 2, Digest: "sha256:old", Size: 200, Command: "RUN apt-get update"},
{Index: 3, Digest: "sha256:old2", Size: 300, Command: "RUN pip install flask"},
}
to := []LayerDetail{
{Index: 1, Digest: "sha256:base", Size: 100, Command: "ADD file in /"},
{Index: 2, Digest: "sha256:new", Size: 250, Command: "RUN apt-get update"},
{Index: 3, Digest: "sha256:new2", Size: 350, Command: "RUN pip install flask"},
}
diff := computeLayerDiff(from, to)
if len(diff) != 3 {
t.Fatalf("expected 3 entries, got %d", len(diff))
}
if diff[0].Status != "shared" {
t.Errorf("[0] expected shared, got %s", diff[0].Status)
}
if diff[1].Status != "rebuilt" {
t.Errorf("[1] expected rebuilt, got %s", diff[1].Status)
}
if diff[1].PrevLayer == nil || diff[1].PrevLayer.Size != 200 {
t.Error("[1] expected PrevLayer with size 200")
}
if diff[2].Status != "rebuilt" {
t.Errorf("[2] expected rebuilt, got %s", diff[2].Status)
}
}
func TestComputeLayerDiff_DifferentCommandDifferentDigest(t *testing.T) {
from := []LayerDetail{
{Index: 1, Digest: "sha256:base", Size: 100, Command: "ADD file in /"},
{Index: 2, Digest: "sha256:old", Size: 200, Command: "RUN pip install requests==2.28"},
}
to := []LayerDetail{
{Index: 1, Digest: "sha256:base", Size: 100, Command: "ADD file in /"},
{Index: 2, Digest: "sha256:new", Size: 250, Command: "RUN pip install requests==2.31"},
}
diff := computeLayerDiff(from, to)
if len(diff) != 3 {
t.Fatalf("expected 3 entries, got %d", len(diff))
}
if diff[0].Status != "shared" {
t.Errorf("[0] expected shared, got %s", diff[0].Status)
}
// Different command → -/+ pair
if diff[1].Status != "removed" {
t.Errorf("[1] expected removed, got %s", diff[1].Status)
}
if diff[2].Status != "added" {
t.Errorf("[2] expected added, got %s", diff[2].Status)
}
}
func TestComputeLayerDiff_InsertedLayer(t *testing.T) {
from := []LayerDetail{
{Index: 1, Digest: "sha256:aaa", Size: 100, Command: "ADD file in /"},
{Index: 2, Digest: "sha256:bbb", Size: 200, Command: "RUN apt-get update"},
{Index: 3, Digest: "sha256:ccc", Size: 300, Command: "RUN pip install flask"},
}
to := []LayerDetail{
{Index: 1, Digest: "sha256:aaa", Size: 100, Command: "ADD file in /"},
{Index: 2, Digest: "sha256:ddd", Size: 210, Command: "RUN apt-get update"},
{Index: 3, Digest: "sha256:eee", Size: 150, Command: "RUN apt-get install curl"},
{Index: 4, Digest: "sha256:fff", Size: 310, Command: "RUN pip install flask"},
}
diff := computeLayerDiff(from, to)
// Expected: shared, rebuilt, +added, rebuilt
expected := []string{"shared", "rebuilt", "added", "rebuilt"}
if len(diff) != len(expected) {
t.Fatalf("expected %d entries, got %d: %v", len(expected), len(diff), diffStatuses(diff))
}
for i, e := range expected {
if diff[i].Status != e {
t.Errorf("[%d] expected %s, got %s", i, e, diff[i].Status)
}
}
}
func TestComputeLayerDiff_RemovedLayer(t *testing.T) {
from := []LayerDetail{
{Index: 1, Digest: "sha256:aaa", Size: 100, Command: "ADD file in /"},
{Index: 2, Digest: "sha256:bbb", Size: 200, Command: "RUN apt-get update"},
{Index: 3, Digest: "sha256:ccc", Size: 150, Command: "RUN apt-get install curl"},
{Index: 4, Digest: "sha256:ddd", Size: 300, Command: "RUN pip install flask"},
}
to := []LayerDetail{
{Index: 1, Digest: "sha256:aaa", Size: 100, Command: "ADD file in /"},
{Index: 2, Digest: "sha256:eee", Size: 210, Command: "RUN apt-get update"},
{Index: 3, Digest: "sha256:fff", Size: 310, Command: "RUN pip install flask"},
}
diff := computeLayerDiff(from, to)
// Expected: shared, rebuilt, -removed, rebuilt
expected := []string{"shared", "rebuilt", "removed", "rebuilt"}
if len(diff) != len(expected) {
t.Fatalf("expected %d entries, got %d: %v", len(expected), len(diff), diffStatuses(diff))
}
for i, e := range expected {
if diff[i].Status != e {
t.Errorf("[%d] expected %s, got %s", i, e, diff[i].Status)
}
}
}
// helper for test error messages
func diffStatuses(diff []LayerDiffEntry) []string {
var s []string
for _, d := range diff {
s = append(s, d.Status)
}
return s
}
func TestComputeLayerDiff_EmptyLayersMatchByCommand(t *testing.T) {
from := []LayerDetail{
{Index: 1, Digest: "sha256:base", Size: 100},
{Index: 0, EmptyLayer: true, Command: "ENV FOO=bar"},
{Index: 2, Digest: "sha256:old", Size: 200},
}
to := []LayerDetail{
{Index: 1, Digest: "sha256:base", Size: 100},
{Index: 0, EmptyLayer: true, Command: "ENV FOO=bar"},
{Index: 2, Digest: "sha256:new", Size: 300},
}
diff := computeLayerDiff(from, to)
if len(diff) != 4 {
t.Fatalf("expected 4 entries, got %d", len(diff))
}
if diff[0].Status != "shared" || diff[1].Status != "shared" {
t.Error("first two entries should be shared (base layer + empty layer)")
}
// Different digests, no command → -/+ pair
if diff[2].Status != "removed" {
t.Errorf("[2] expected removed, got %s", diff[2].Status)
}
if diff[3].Status != "added" {
t.Errorf("[3] expected added, got %s", diff[3].Status)
}
}
func TestComputeLayerDiff_CompletelyDifferent(t *testing.T) {
from := []LayerDetail{
{Index: 1, Digest: "sha256:old1", Size: 100},
}
to := []LayerDetail{
{Index: 1, Digest: "sha256:new1", Size: 200},
{Index: 2, Digest: "sha256:new2", Size: 300},
}
diff := computeLayerDiff(from, to)
// Lockstep: -/+ pair for position 1, then +1 added
if len(diff) != 3 {
t.Fatalf("expected 3 entries, got %d", len(diff))
}
if diff[0].Status != "removed" {
t.Errorf("[0] expected removed, got %s", diff[0].Status)
}
if diff[1].Status != "added" {
t.Errorf("[1] expected added, got %s", diff[1].Status)
}
if diff[2].Status != "added" {
t.Errorf("[2] expected added, got %s", diff[2].Status)
}
}
func TestComputeLayerDiff_EmptyInputs(t *testing.T) {
diff := computeLayerDiff(nil, nil)
if len(diff) != 0 {
t.Fatalf("expected 0 entries, got %d", len(diff))
}
diff = computeLayerDiff(nil, []LayerDetail{{Index: 1, Digest: "sha256:a"}})
if len(diff) != 1 || diff[0].Status != "added" {
t.Error("expected 1 added entry")
}
diff = computeLayerDiff([]LayerDetail{{Index: 1, Digest: "sha256:a"}}, nil)
if len(diff) != 1 || diff[0].Status != "removed" {
t.Error("expected 1 removed entry")
}
}
func TestComputeVulnDiff_FixedAndNew(t *testing.T) {
from := []vulnMatch{
{CVEID: "CVE-2024-001", Severity: "Critical", Package: "openssl", Version: "1.1.0"},
{CVEID: "CVE-2024-002", Severity: "High", Package: "curl", Version: "7.85"},
{CVEID: "CVE-2024-003", Severity: "Medium", Package: "zlib", Version: "1.2.11"},
}
to := []vulnMatch{
{CVEID: "CVE-2024-002", Severity: "High", Package: "curl", Version: "7.85"},
{CVEID: "CVE-2025-001", Severity: "High", Package: "requests", Version: "2.31"},
}
diff := computeVulnDiff(from, to)
counts := map[string]int{}
for _, e := range diff {
counts[e.Status]++
}
if counts["fixed"] != 2 {
t.Errorf("expected 2 fixed, got %d", counts["fixed"])
}
if counts["new"] != 1 {
t.Errorf("expected 1 new, got %d", counts["new"])
}
if counts["unchanged"] != 1 {
t.Errorf("expected 1 unchanged, got %d", counts["unchanged"])
}
}
func TestComputeVulnDiff_AllFixed(t *testing.T) {
from := []vulnMatch{
{CVEID: "CVE-2024-001", Severity: "Critical"},
{CVEID: "CVE-2024-002", Severity: "High"},
}
diff := computeVulnDiff(from, nil)
for _, e := range diff {
if e.Status != "fixed" {
t.Errorf("expected fixed, got %s", e.Status)
}
}
if len(diff) != 2 {
t.Errorf("expected 2, got %d", len(diff))
}
}
func TestComputeVulnDiff_AllNew(t *testing.T) {
to := []vulnMatch{
{CVEID: "CVE-2025-001", Severity: "Critical"},
}
diff := computeVulnDiff(nil, to)
if len(diff) != 1 || diff[0].Status != "new" {
t.Error("expected 1 new entry")
}
}
func TestComputeVulnDiff_Empty(t *testing.T) {
diff := computeVulnDiff(nil, nil)
if len(diff) != 0 {
t.Errorf("expected 0, got %d", len(diff))
}
}
func TestComputeDiffSummary(t *testing.T) {
fromLayers := []LayerDetail{
{Index: 1, Size: 1000},
{Index: 2, Size: 2000},
}
toLayers := []LayerDetail{
{Index: 1, Size: 1000},
{Index: 2, Size: 2500},
{Index: 3, Size: 500},
}
vulnDiff := []VulnDiffEntry{
{Status: "fixed", Vuln: vulnMatch{Severity: "Critical"}},
{Status: "fixed", Vuln: vulnMatch{Severity: "High"}},
{Status: "fixed", Vuln: vulnMatch{Severity: "High"}},
{Status: "new", Vuln: vulnMatch{Severity: "Medium"}},
{Status: "unchanged", Vuln: vulnMatch{Severity: "Low"}},
}
summary := computeDiffSummary(fromLayers, toLayers, vulnDiff, true)
if summary.SizeDelta != 1000 {
t.Errorf("expected size delta 1000, got %d", summary.SizeDelta)
}
if summary.LayerCountFrom != 2 {
t.Errorf("expected from count 2, got %d", summary.LayerCountFrom)
}
if summary.LayerCountTo != 3 {
t.Errorf("expected to count 3, got %d", summary.LayerCountTo)
}
if summary.VulnFixedCount != 3 {
t.Errorf("expected 3 fixed, got %d", summary.VulnFixedCount)
}
if summary.VulnNewCount != 1 {
t.Errorf("expected 1 new, got %d", summary.VulnNewCount)
}
if summary.VulnFixedBySev.Critical != 1 {
t.Errorf("expected 1 fixed critical, got %d", summary.VulnFixedBySev.Critical)
}
if summary.VulnFixedBySev.High != 2 {
t.Errorf("expected 2 fixed high, got %d", summary.VulnFixedBySev.High)
}
if summary.VulnNewBySev.Medium != 1 {
t.Errorf("expected 1 new medium, got %d", summary.VulnNewBySev.Medium)
}
if !summary.HasVulnData {
t.Error("expected HasVulnData to be true")
}
}
func TestComputeDiffSummary_NoVulnData(t *testing.T) {
summary := computeDiffSummary(
[]LayerDetail{{Size: 100}},
[]LayerDetail{{Size: 200}},
nil,
false,
)
if summary.HasVulnData {
t.Error("expected HasVulnData to be false")
}
if summary.SizeDelta != 100 {
t.Errorf("expected size delta 100, got %d", summary.SizeDelta)
}
}
func TestComputeDiffSummary_SmallerImage(t *testing.T) {
summary := computeDiffSummary(
[]LayerDetail{{Size: 5000}, {Size: 3000}},
[]LayerDetail{{Size: 2000}},
nil,
false,
)
if summary.SizeDelta != -6000 {
t.Errorf("expected size delta -6000, got %d", summary.SizeDelta)
}
if summary.LayerCountFrom != 2 || summary.LayerCountTo != 1 {
t.Error("unexpected layer counts")
}
}
+235
View File
@@ -0,0 +1,235 @@
package handlers
import (
"fmt"
"log/slog"
"net/http"
"strings"
"atcr.io/pkg/appview/db"
"atcr.io/pkg/appview/holdclient"
"atcr.io/pkg/atproto"
"github.com/go-chi/chi/v5"
)
// UpgradeBannerHandler returns an HTMX fragment with an upgrade nudge
// when the user is viewing an older tagged manifest.
type UpgradeBannerHandler struct {
BaseUIHandler
}
func (h *UpgradeBannerHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
identifier := chi.URLParam(r, "handle")
pathParts := strings.SplitN(strings.TrimPrefix(chi.URLParam(r, "*"), "/"), "/", 2)
if len(pathParts) < 1 {
slog.Debug("Upgrade banner: no path parts")
w.WriteHeader(http.StatusNoContent)
return
}
repository := pathParts[0]
currentDigest := r.URL.Query().Get("digest")
holdEndpoint := r.URL.Query().Get("holdEndpoint")
if currentDigest == "" {
slog.Debug("Upgrade banner: no digest param")
w.WriteHeader(http.StatusNoContent)
return
}
slog.Debug("Upgrade banner request", "handle", identifier, "repo", repository, "digest", currentDigest, "holdEndpoint", holdEndpoint)
// Resolve identity
did, _, _, err := atproto.ResolveIdentity(r.Context(), identifier)
if err != nil {
slog.Debug("Upgrade banner: identity resolution failed", "error", err)
w.WriteHeader(http.StatusNoContent)
return
}
// Get most recent tag
newest, err := db.GetMostRecentTag(h.ReadOnlyDB, did, repository)
if err != nil || newest == nil {
slog.Debug("Upgrade banner: no tags found", "did", did, "repo", repository)
w.WriteHeader(http.StatusNoContent)
return
}
if newest.Digest == currentDigest {
slog.Debug("Upgrade banner: already viewing newest tag", "tag", newest.Tag)
w.WriteHeader(http.StatusNoContent)
return
}
slog.Debug("Upgrade banner: newer tag found", "newerTag", newest.Tag, "newerDigest", newest.Digest)
// Fetch layers for both to compute size/layer delta
currentManifest, err := db.GetManifestDetail(h.ReadOnlyDB, did, repository, currentDigest)
if err != nil {
slog.Debug("Upgrade banner: failed to get current manifest", "error", err)
w.WriteHeader(http.StatusNoContent)
return
}
newerManifest, err := db.GetManifestDetail(h.ReadOnlyDB, did, repository, newest.Digest)
if err != nil {
slog.Debug("Upgrade banner: failed to get newer manifest", "error", err)
w.WriteHeader(http.StatusNoContent)
return
}
slog.Debug("Upgrade banner: fetched both manifests",
"currentID", currentManifest.ID, "newerID", newerManifest.ID,
"currentIsManifestList", currentManifest.IsManifestList, "newerIsManifestList", newerManifest.IsManifestList)
// For multi-arch manifests, resolve to a common platform child
currentDigestForLayers := currentDigest
newerDigestForLayers := newest.Digest
currentHoldEndpoint := holdEndpoint
newerHoldEndpoint := newest.HoldEndpoint
if newerHoldEndpoint == "" {
newerHoldEndpoint = newerManifest.HoldEndpoint
}
currentManifestForLayers := currentManifest
newerManifestForLayers := newerManifest
if currentManifest.IsManifestList && newerManifest.IsManifestList {
// Find first common platform
found := false
for _, cp := range currentManifest.Platforms {
for _, np := range newerManifest.Platforms {
if cp.OS == np.OS && cp.Architecture == np.Architecture && cp.Variant == np.Variant {
// Resolve child manifests
cm, err := db.GetManifestDetail(h.ReadOnlyDB, did, repository, cp.Digest)
if err != nil {
continue
}
nm, err := db.GetManifestDetail(h.ReadOnlyDB, did, repository, np.Digest)
if err != nil {
continue
}
currentManifestForLayers = cm
newerManifestForLayers = nm
currentDigestForLayers = cp.Digest
newerDigestForLayers = np.Digest
if cp.HoldEndpoint != "" {
currentHoldEndpoint = cp.HoldEndpoint
}
if np.HoldEndpoint != "" {
newerHoldEndpoint = np.HoldEndpoint
}
found = true
slog.Debug("Upgrade banner: using common platform", "os", cp.OS, "arch", cp.Architecture)
break
}
}
if found {
break
}
}
if !found {
slog.Debug("Upgrade banner: no common platform found")
w.WriteHeader(http.StatusNoContent)
return
}
} else if currentManifest.IsManifestList || newerManifest.IsManifestList {
// One is multi-arch, the other isn't — can't compare meaningfully
// Still show a basic banner without layer/vuln details
}
// Fetch layers for both
currentDBLayers, _ := db.GetLayersForManifest(h.ReadOnlyDB, currentManifestForLayers.ID)
newerDBLayers, _ := db.GetLayersForManifest(h.ReadOnlyDB, newerManifestForLayers.ID)
// Build layer details (try to get config history for richer diff)
var currentLayers, newerLayers []LayerDetail
// Resolve hold for current manifest
if currentHoldEndpoint != "" {
hold, holdErr := ResolveHold(r.Context(), h.ReadOnlyDB, currentHoldEndpoint)
if holdErr == nil {
config, err := holdclient.FetchImageConfig(r.Context(), hold.URL, currentDigestForLayers)
if err == nil {
currentLayers = buildLayerDetails(config.History, currentDBLayers)
}
}
}
if currentLayers == nil {
currentLayers = buildLayerDetails(nil, currentDBLayers)
}
// Resolve hold for newer manifest
if newerHoldEndpoint != "" {
hold, holdErr := ResolveHold(r.Context(), h.ReadOnlyDB, newerHoldEndpoint)
if holdErr == nil {
config, err := holdclient.FetchImageConfig(r.Context(), hold.URL, newerDigestForLayers)
if err == nil {
newerLayers = buildLayerDetails(config.History, newerDBLayers)
}
}
}
if newerLayers == nil {
newerLayers = buildLayerDetails(nil, newerDBLayers)
}
// Fetch vuln summaries for both
var vulnDiff []VulnDiffEntry
hasVulnData := false
if currentHoldEndpoint != "" && newerHoldEndpoint != "" {
currentHold, err1 := ResolveHold(r.Context(), h.ReadOnlyDB, currentHoldEndpoint)
newerHold, err2 := ResolveHold(r.Context(), h.ReadOnlyDB, newerHoldEndpoint)
if err1 == nil && err2 == nil {
currentVuln := FetchVulnDetails(r.Context(), currentHold.DID, currentDigestForLayers)
newerVuln := FetchVulnDetails(r.Context(), newerHold.DID, newerDigestForLayers)
if currentVuln.Error == "" && newerVuln.Error == "" {
hasVulnData = true
vulnDiff = computeVulnDiff(currentVuln.Matches, newerVuln.Matches)
}
}
}
summary := computeDiffSummary(currentLayers, newerLayers, vulnDiff, hasVulnData)
slog.Debug("Upgrade banner: computed summary", "hasVulnData", hasVulnData,
"layersFrom", summary.LayerCountFrom, "layersTo", summary.LayerCountTo, "sizeDelta", summary.SizeDelta)
// Don't show banner if nothing meaningful changed
if !hasVulnData && summary.LayerCountFrom == summary.LayerCountTo && summary.SizeDelta == 0 {
slog.Debug("Upgrade banner: no meaningful changes, skipping")
w.WriteHeader(http.StatusNoContent)
return
}
owner, _ := db.GetUserByDID(h.ReadOnlyDB, did)
if owner == nil {
slog.Debug("Upgrade banner: owner not found", "did", did)
w.WriteHeader(http.StatusNoContent)
return
}
data := struct {
NewerTag string
NewerDigest string
FromDigest string
Summary DiffSummary
DiffURL string
OwnerHandle string
Repository string
}{
NewerTag: newest.Tag,
NewerDigest: newest.Digest,
FromDigest: currentDigest,
Summary: summary,
DiffURL: fmt.Sprintf("/diff/%s/%s?from=%s&to=%s", owner.Handle, repository, currentDigest, newest.Digest),
OwnerHandle: owner.Handle,
Repository: repository,
}
slog.Debug("Upgrade banner: rendering template", "newerTag", data.NewerTag, "hasVulnData", data.Summary.HasVulnData,
"fixedCount", data.Summary.VulnFixedCount, "newCount", data.Summary.VulnNewCount,
"layersFrom", data.Summary.LayerCountFrom, "layersTo", data.Summary.LayerCountTo,
"sizeDelta", data.Summary.SizeDelta)
w.Header().Set("Content-Type", "text/html")
if err := h.Templates.ExecuteTemplate(w, "upgrade-banner", data); err != nil {
slog.Error("Failed to render upgrade banner", "error", err)
}
}
+6
View File
@@ -157,6 +157,12 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
).ServeHTTP)
router.Get("/api/digest-content/{handle}/*", (&uihandlers.DigestContentHandler{BaseUIHandler: base}).ServeHTTP)
router.Get("/api/upgrade-banner/{handle}/*", (&uihandlers.UpgradeBannerHandler{BaseUIHandler: base}).ServeHTTP)
// Diff page: /diff/{handle}/{repo}?from=...&to=...
router.Get("/diff/{handle}/*", middleware.OptionalAuth(deps.SessionStore, deps.Database)(
&uihandlers.ManifestDiffHandler{BaseUIHandler: base},
).ServeHTTP)
router.Get("/d/{handle}/*", middleware.OptionalAuth(deps.SessionStore, deps.Database)(
&uihandlers.DigestDetailHandler{BaseUIHandler: base},
+103
View File
@@ -0,0 +1,103 @@
{{ define "diff" }}
<!DOCTYPE html>
<html lang="en">
<head>
{{ template "head" . }}
{{ template "meta" .Meta }}
</head>
<body>
{{ template "nav" . }}
<main class="container mx-auto px-4 py-8">
<div class="space-y-6">
<!-- Breadcrumb -->
<div class="text-sm breadcrumbs">
<ul>
<li><a href="/u/{{ .Owner.Handle }}" class="link link-primary">{{ .Owner.Handle }}</a></li>
<li><a href="/r/{{ .Owner.Handle }}/{{ .Repository }}" class="link link-primary">{{ .Repository }}</a></li>
<li>Comparing {{ .FromTag }} to {{ .ToTag }}</li>
</ul>
</div>
<!-- Summary Card -->
<div class="card bg-base-100 shadow-sm border border-base-300 p-6">
<div class="flex flex-wrap items-center gap-2 mb-4">
<h1 class="text-xl font-bold">
<span class="font-mono">{{ .FromTag }}</span>
<span class="text-base-content/40 mx-1"></span>
<span class="font-mono">{{ .ToTag }}</span>
</h1>
</div>
<div class="flex flex-wrap gap-4">
<!-- Size delta -->
<div class="stat bg-base-200/50 rounded-lg p-3">
<div class="stat-title text-xs">Size</div>
<div class="stat-value text-sm">{{ humanizeByteDelta .Summary.SizeDelta }}</div>
</div>
<!-- Layer count -->
<div class="stat bg-base-200/50 rounded-lg p-3">
<div class="stat-title text-xs">Layers</div>
<div class="stat-value text-sm">{{ .Summary.LayerCountFrom }} → {{ .Summary.LayerCountTo }}</div>
</div>
{{ if .HasVulnData }}
<!-- Vulns fixed -->
{{ if gt .Summary.VulnFixedCount 0 }}
<div class="stat bg-success/10 rounded-lg p-3">
<div class="stat-title text-xs">Fixed</div>
<div class="stat-value text-sm text-success">-{{ .Summary.VulnFixedCount }} vuln{{ if gt .Summary.VulnFixedCount 1 }}s{{ end }}</div>
<div class="stat-desc text-xs">
{{ if gt .Summary.VulnFixedBySev.Critical 0 }}{{ .Summary.VulnFixedBySev.Critical }}C {{ end }}
{{ if gt .Summary.VulnFixedBySev.High 0 }}{{ .Summary.VulnFixedBySev.High }}H {{ end }}
{{ if gt .Summary.VulnFixedBySev.Medium 0 }}{{ .Summary.VulnFixedBySev.Medium }}M {{ end }}
{{ if gt .Summary.VulnFixedBySev.Low 0 }}{{ .Summary.VulnFixedBySev.Low }}L{{ end }}
</div>
</div>
{{ end }}
<!-- Vulns new -->
{{ if gt .Summary.VulnNewCount 0 }}
<div class="stat bg-error/10 rounded-lg p-3">
<div class="stat-title text-xs">New</div>
<div class="stat-value text-sm text-error">+{{ .Summary.VulnNewCount }} vuln{{ if gt .Summary.VulnNewCount 1 }}s{{ end }}</div>
<div class="stat-desc text-xs">
{{ if gt .Summary.VulnNewBySev.Critical 0 }}{{ .Summary.VulnNewBySev.Critical }}C {{ end }}
{{ if gt .Summary.VulnNewBySev.High 0 }}{{ .Summary.VulnNewBySev.High }}H {{ end }}
{{ if gt .Summary.VulnNewBySev.Medium 0 }}{{ .Summary.VulnNewBySev.Medium }}M {{ end }}
{{ if gt .Summary.VulnNewBySev.Low 0 }}{{ .Summary.VulnNewBySev.Low }}L{{ end }}
</div>
</div>
{{ end }}
{{ end }}
</div>
{{ if .IsMultiArch }}
<div class="flex items-center gap-3 pt-4 border-t border-base-200">
<label for="diff-arch-select" class="text-sm font-medium whitespace-nowrap">{{ icon "cpu" "size-4" }} Platform</label>
<select id="diff-arch-select" class="select select-sm select-bordered"
onchange="switchDiffPlatform(this.value)">
{{ range .CommonPlatforms }}
{{ $platKey := printf "%s/%s" .OS .Architecture }}{{ if .Variant }}{{ $platKey = printf "%s/%s/%s" .OS .Architecture .Variant }}{{ end }}
<option value="{{ $platKey }}"{{ if eq $platKey $.SelectedPlatform }} selected{{ end }}>{{ .OS }}/{{ .Architecture }}{{ if .Variant }}/{{ .Variant }}{{ end }}</option>
{{ end }}
</select>
</div>
<script>
function switchDiffPlatform(platform) {
var url = '/diff/{{ .Owner.Handle }}/{{ .Repository }}?from={{ .FromDigest }}&to={{ .ToDigest }}&platform=' + encodeURIComponent(platform);
window.location.href = url;
}
</script>
{{ end }}
</div>
{{ template "diff-content" . }}
</div>
</main>
{{ template "footer" . }}
</body>
</html>
{{ end }}
+7
View File
@@ -77,6 +77,13 @@
{{ end }}
</div>
<!-- Upgrade Banner (HTMX lazy-loaded) -->
<div id="upgrade-banner"
hx-get="/api/upgrade-banner/{{ .Owner.Handle }}/{{ .Repository }}?digest={{ .Manifest.Digest }}{{ if .Manifest.HoldEndpoint }}&holdEndpoint={{ .Manifest.HoldEndpoint }}{{ end }}"
hx-trigger="load"
hx-swap="innerHTML">
</div>
<!-- Content: Layers + Vulnerabilities -->
<div id="digest-content">
{{ if .Manifest.IsManifestList }}
@@ -0,0 +1,184 @@
{{ define "diff-content" }}
<!-- Layers + Vulnerabilities Diff -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Layer Diff (Left) -->
<div class="card bg-base-100 shadow-sm border border-base-300 p-6 space-y-4 min-w-0">
<h2 class="text-lg font-semibold">Layers</h2>
{{ if .LayerDiff }}
<div class="overflow-x-auto">
<table class="table table-xs w-full">
<thead>
<tr class="text-xs">
<th class="w-6"></th>
<th class="w-8">#</th>
<th>Command</th>
<th class="text-right w-24">Size</th>
</tr>
</thead>
<tbody>
{{ range .LayerDiff }}
<tr class="{{ if eq .Status "added" }}bg-success/10{{ else if eq .Status "removed" }}bg-error/10{{ else if eq .Status "rebuilt" }}bg-warning/10{{ else }}opacity-60{{ end }}">
<td class="font-mono text-xs text-center font-bold {{ if eq .Status "added" }}text-success{{ else if eq .Status "removed" }}text-error{{ else if eq .Status "rebuilt" }}text-warning{{ end }}">{{ if eq .Status "added" }}+{{ else if eq .Status "removed" }}-{{ else if eq .Status "rebuilt" }}~{{ end }}</td>
<td class="font-mono text-xs">{{ .Layer.Index }}</td>
<td>
{{ if .Layer.Command }}
<code class="font-mono text-xs break-all line-clamp-2" title="{{ .Layer.Command }}">{{ .Layer.Command }}</code>
{{ end }}
</td>
<td class="text-right text-sm whitespace-nowrap">
{{ if not .Layer.EmptyLayer }}{{ humanizeBytes .Layer.Size }}{{ end }}
{{ if and (eq .Status "rebuilt") .PrevLayer }}
{{ if ne .Layer.Size .PrevLayer.Size }}
<span class="text-xs text-base-content/50">({{ humanizeByteDelta (sub64 .Layer.Size .PrevLayer.Size) }})</span>
{{ end }}
{{ end }}
</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
{{ else }}
<p class="text-base-content/60">No layer information available</p>
{{ end }}
</div>
<!-- Vulnerability Diff (Right) -->
<div class="card bg-base-100 shadow-sm border border-base-300 p-6 space-y-4 min-w-0">
<h2 class="text-lg font-semibold">Vulnerabilities</h2>
{{ if not .HasVulnData }}
<p class="text-base-content/60">Vulnerability scan data not available for both manifests</p>
{{ else }}
<!-- Fixed Vulns -->
{{ if .FixedVulns }}
<div class="collapse collapse-arrow bg-success/5 border border-success/20 rounded-lg">
<input type="checkbox" checked />
<div class="collapse-title font-medium text-sm flex items-center gap-2">
{{ icon "shield-check" "size-4 text-success" }}
Fixed ({{ len .FixedVulns }})
</div>
<div class="collapse-content">
<div class="overflow-x-auto">
<table class="table table-xs w-full">
<thead>
<tr class="text-xs">
<th>CVE</th>
<th>Severity</th>
<th>Package</th>
<th>Was</th>
</tr>
</thead>
<tbody>
{{ range .FixedVulns }}
<tr>
<td>
{{ if .CVEURL }}<a href="{{ .CVEURL }}" target="_blank" rel="noopener" class="link link-primary text-xs font-mono">{{ .CVEID }}</a>
{{ else }}<span class="text-xs font-mono">{{ .CVEID }}</span>{{ end }}
</td>
<td>
<span class="badge badge-xs {{ if eq .Severity "Critical" }}badge-error{{ else if eq .Severity "High" }}badge-warning{{ else if eq .Severity "Medium" }}badge-info{{ else }}badge-ghost{{ end }}">{{ .Severity }}</span>
</td>
<td class="text-xs">{{ .Package }}</td>
<td class="text-xs font-mono">{{ .Version }}</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
</div>
</div>
{{ end }}
<!-- New Vulns -->
{{ if .NewVulns }}
<div class="collapse collapse-arrow bg-error/5 border border-error/20 rounded-lg">
<input type="checkbox" checked />
<div class="collapse-title font-medium text-sm flex items-center gap-2">
{{ icon "alert-triangle" "size-4 text-error" }}
New ({{ len .NewVulns }})
</div>
<div class="collapse-content">
<div class="overflow-x-auto">
<table class="table table-xs w-full">
<thead>
<tr class="text-xs">
<th>CVE</th>
<th>Severity</th>
<th>Package</th>
<th>Version</th>
<th>Fix</th>
</tr>
</thead>
<tbody>
{{ range .NewVulns }}
<tr>
<td>
{{ if .CVEURL }}<a href="{{ .CVEURL }}" target="_blank" rel="noopener" class="link link-primary text-xs font-mono">{{ .CVEID }}</a>
{{ else }}<span class="text-xs font-mono">{{ .CVEID }}</span>{{ end }}
</td>
<td>
<span class="badge badge-xs {{ if eq .Severity "Critical" }}badge-error{{ else if eq .Severity "High" }}badge-warning{{ else if eq .Severity "Medium" }}badge-info{{ else }}badge-ghost{{ end }}">{{ .Severity }}</span>
</td>
<td class="text-xs">{{ .Package }}</td>
<td class="text-xs font-mono">{{ .Version }}</td>
<td class="text-xs font-mono">{{ .FixedIn }}</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
</div>
</div>
{{ end }}
<!-- Unchanged Vulns -->
{{ if .UnchangedVulns }}
<div class="collapse collapse-arrow bg-base-200/50 border border-base-300 rounded-lg">
<input type="checkbox" />
<div class="collapse-title font-medium text-sm text-base-content/60">
Unchanged ({{ len .UnchangedVulns }})
</div>
<div class="collapse-content">
<div class="overflow-x-auto">
<table class="table table-xs w-full">
<thead>
<tr class="text-xs">
<th>CVE</th>
<th>Severity</th>
<th>Package</th>
<th>Version</th>
<th>Fix</th>
</tr>
</thead>
<tbody>
{{ range .UnchangedVulns }}
<tr>
<td>
{{ if .CVEURL }}<a href="{{ .CVEURL }}" target="_blank" rel="noopener" class="link link-primary text-xs font-mono">{{ .CVEID }}</a>
{{ else }}<span class="text-xs font-mono">{{ .CVEID }}</span>{{ end }}
</td>
<td>
<span class="badge badge-xs {{ if eq .Severity "Critical" }}badge-error{{ else if eq .Severity "High" }}badge-warning{{ else if eq .Severity "Medium" }}badge-info{{ else }}badge-ghost{{ end }}">{{ .Severity }}</span>
</td>
<td class="text-xs">{{ .Package }}</td>
<td class="text-xs font-mono">{{ .Version }}</td>
<td class="text-xs font-mono">{{ .FixedIn }}</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
</div>
</div>
{{ end }}
{{ if and (not .FixedVulns) (not .NewVulns) (not .UnchangedVulns) }}
<p class="text-base-content/60">No vulnerabilities found in either manifest</p>
{{ end }}
{{ end }}
</div>
</div>
{{ end }}
@@ -0,0 +1,34 @@
{{ define "upgrade-banner" }}
<div class="alert shadow-sm border border-info/30 bg-info/5">
<div class="flex items-center gap-3 w-full">
{{ icon "info" "size-5 text-info shrink-0" }}
<div class="flex-1 text-sm">
<span class="font-semibold">{{ .NewerTag }}</span>
{{ if .Summary.HasVulnData }}
{{ if gt .Summary.VulnFixedCount 0 }}
fixes
{{ if gt .Summary.VulnFixedBySev.Critical 0 }}<span class="font-semibold text-error">{{ .Summary.VulnFixedBySev.Critical }} Critical</span>{{ end }}
{{ if gt .Summary.VulnFixedBySev.High 0 }}{{ if gt .Summary.VulnFixedBySev.Critical 0 }}, {{ end }}<span class="font-semibold text-warning">{{ .Summary.VulnFixedBySev.High }} High</span>{{ end }}
{{ if gt .Summary.VulnFixedBySev.Medium 0 }}{{ if or (gt .Summary.VulnFixedBySev.Critical 0) (gt .Summary.VulnFixedBySev.High 0) }}, {{ end }}<span class="font-semibold">{{ .Summary.VulnFixedBySev.Medium }} Medium</span>{{ end }}
{{ if and (eq .Summary.VulnFixedBySev.Critical 0) (eq .Summary.VulnFixedBySev.High 0) (eq .Summary.VulnFixedBySev.Medium 0) }}<span class="font-semibold">{{ .Summary.VulnFixedCount }} Low</span>{{ end }}
vuln{{ if gt .Summary.VulnFixedCount 1 }}s{{ end }}
{{ else }}
is available
{{ end }}
{{ if gt .Summary.VulnNewCount 0 }}
<span class="text-base-content/60">(+{{ .Summary.VulnNewCount }} new)</span>
{{ end }}
{{ else }}
is available
{{ end }}
{{ if ne .Summary.LayerCountFrom .Summary.LayerCountTo }}
· {{ if gt .Summary.LayerCountTo .Summary.LayerCountFrom }}+{{ end }}{{ sub .Summary.LayerCountTo .Summary.LayerCountFrom }} layer{{ if ne (sub .Summary.LayerCountTo .Summary.LayerCountFrom) 1 }}s{{ end }}
{{ end }}
{{ if ne .Summary.SizeDelta 0 }}
({{ humanizeByteDelta .Summary.SizeDelta }})
{{ end }}
</div>
<a href="{{ .DiffURL }}" class="btn btn-sm btn-info btn-outline shrink-0">View diff</a>
</div>
</div>
{{ end }}
+31
View File
@@ -226,6 +226,37 @@ func Templates(overrides *BrandingOverrides) (*template.Template, error) {
return a - b
},
"sub64": func(a, b int64) int64 {
return a - b
},
"absInt": func(n int) int {
if n < 0 {
return -n
}
return n
},
"humanizeByteDelta": func(bytes int64) string {
prefix := "+"
if bytes < 0 {
prefix = "-"
bytes = -bytes
} else if bytes == 0 {
return "no change"
}
const unit = 1024
if bytes < unit {
return fmt.Sprintf("%s%d B", prefix, bytes)
}
div, exp := int64(unit), 0
for n := bytes / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%s%.1f %cB", prefix, float64(bytes)/float64(div), "KMGTPE"[exp])
},
"dict": func(values ...any) map[string]any {
dict := make(map[string]any, len(values)/2)
for i := 0; i < len(values); i += 2 {