A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go

move the vuln report to tags instead of manifests

evan.jarrett.net dba20199 cd4986c0

verified
+73 -38
+3
pkg/appview/db/models.go
··· 127 127 Architecture string 128 128 Variant string 129 129 OSVersion string 130 + Digest string // child platform manifest digest (for manifest lists) 131 + HoldEndpoint string // hold endpoint for this platform manifest 130 132 } 131 133 132 134 // TagWithPlatforms extends Tag with platform information 133 135 type TagWithPlatforms struct { 134 136 Tag 137 + HoldEndpoint string // hold endpoint from the tag's own manifest 135 138 Platforms []PlatformInfo 136 139 IsMultiArch bool 137 140 HasAttestations bool // true if manifest list contains attestation references
+14 -3
pkg/appview/db/queries.go
··· 636 636 t.created_at, 637 637 m.media_type, 638 638 m.artifact_type, 639 + m.hold_endpoint, 639 640 COALESCE(mr.platform_os, '') as platform_os, 640 641 COALESCE(mr.platform_architecture, '') as platform_architecture, 641 642 COALESCE(mr.platform_variant, '') as platform_variant, 642 643 COALESCE(mr.platform_os_version, '') as platform_os_version, 643 - COALESCE(mr.is_attestation, 0) as is_attestation 644 + COALESCE(mr.is_attestation, 0) as is_attestation, 645 + COALESCE(mr.digest, '') as child_digest, 646 + COALESCE(child_m.hold_endpoint, m.hold_endpoint, '') as child_hold_endpoint 644 647 FROM tags t 645 648 JOIN manifests m ON t.digest = m.digest AND t.did = m.did AND t.repository = m.repository 646 649 LEFT JOIN manifest_references mr ON m.id = mr.manifest_id 650 + LEFT JOIN manifests child_m ON mr.digest = child_m.digest AND child_m.did = t.did AND child_m.repository = t.repository 647 651 WHERE t.did = ? AND t.repository = ? 648 652 ORDER BY t.created_at DESC, mr.reference_index 649 653 `, did, repository) ··· 659 663 660 664 for rows.Next() { 661 665 var t Tag 662 - var mediaType, artifactType, platformOS, platformArch, platformVariant, platformOSVersion string 666 + var mediaType, artifactType, holdEndpoint string 667 + var platformOS, platformArch, platformVariant, platformOSVersion string 663 668 var isAttestation bool 669 + var childDigest, childHoldEndpoint string 664 670 665 671 if err := rows.Scan(&t.ID, &t.DID, &t.Repository, &t.Tag, &t.Digest, &t.CreatedAt, 666 - &mediaType, &artifactType, &platformOS, &platformArch, &platformVariant, &platformOSVersion, &isAttestation); err != nil { 672 + &mediaType, &artifactType, &holdEndpoint, 673 + &platformOS, &platformArch, &platformVariant, &platformOSVersion, 674 + &isAttestation, &childDigest, &childHoldEndpoint); err != nil { 667 675 return nil, err 668 676 } 669 677 ··· 672 680 if _, exists := tagMap[tagKey]; !exists { 673 681 tagMap[tagKey] = &TagWithPlatforms{ 674 682 Tag: t, 683 + HoldEndpoint: holdEndpoint, 675 684 Platforms: []PlatformInfo{}, 676 685 ArtifactType: artifactType, 677 686 } ··· 692 701 Architecture: platformArch, 693 702 Variant: platformVariant, 694 703 OSVersion: platformOSVersion, 704 + Digest: childDigest, 705 + HoldEndpoint: childHoldEndpoint, 695 706 }) 696 707 } 697 708 }
+24 -14
pkg/appview/handlers/repository.go
··· 230 230 artifactType = manifests[0].ArtifactType 231 231 } 232 232 233 - // Collect digests for batch scan-result request 234 - var scanDigests []string 235 - var scanHoldEndpoint string 236 - for _, m := range manifests { 237 - if !m.IsManifestList && m.HoldEndpoint != "" { 238 - if scanHoldEndpoint == "" { 239 - scanHoldEndpoint = m.HoldEndpoint 233 + // Collect digests for batch scan-result requests, grouped by hold endpoint 234 + holdDigests := make(map[string][]string) // holdEndpoint → []hexDigest 235 + seen := make(map[string]bool) // dedup digests 236 + for _, t := range tagsWithPlatforms { 237 + if len(t.Platforms) > 0 { 238 + // Multi-arch: collect each platform's child digest 239 + for _, p := range t.Platforms { 240 + if p.Digest != "" && p.HoldEndpoint != "" && !seen[p.Digest] { 241 + seen[p.Digest] = true 242 + hex := strings.TrimPrefix(p.Digest, "sha256:") 243 + holdDigests[p.HoldEndpoint] = append(holdDigests[p.HoldEndpoint], hex) 244 + } 240 245 } 241 - if m.HoldEndpoint == scanHoldEndpoint { 242 - scanDigests = append(scanDigests, strings.TrimPrefix(m.Digest, "sha256:")) 246 + } else if t.HoldEndpoint != "" { 247 + // Single-arch: use tag's own digest 248 + if !seen[t.Digest] { 249 + seen[t.Digest] = true 250 + hex := strings.TrimPrefix(t.Digest, "sha256:") 251 + holdDigests[t.HoldEndpoint] = append(holdDigests[t.HoldEndpoint], hex) 243 252 } 244 253 } 245 254 } 246 - var scanBatchParams string 247 - if len(scanDigests) > 0 { 248 - scanBatchParams = "holdEndpoint=" + url.QueryEscape(scanHoldEndpoint) + "&digests=" + strings.Join(scanDigests, ",") 255 + var scanBatchParams []template.HTML 256 + for hold, digests := range holdDigests { 257 + scanBatchParams = append(scanBatchParams, template.HTML( 258 + "holdEndpoint="+url.QueryEscape(hold)+"&digests="+strings.Join(digests, ","))) 249 259 } 250 260 251 261 // Build page meta ··· 285 295 IsOwner bool // Whether current user owns this repository 286 296 ReadmeHTML template.HTML 287 297 ArtifactType string // Dominant artifact type: container-image, helm-chart, unknown 288 - ScanBatchParams template.HTML // Pre-encoded query string for batch scan-result endpoint 298 + ScanBatchParams []template.HTML // Pre-encoded query strings for batch scan-result endpoint (one per hold) 289 299 }{ 290 300 PageData: NewPageData(r, &h.BaseUIHandler), 291 301 Meta: meta, ··· 299 309 IsOwner: isOwner, 300 310 ReadmeHTML: readmeHTML, 301 311 ArtifactType: artifactType, 302 - ScanBatchParams: template.HTML(scanBatchParams), 312 + ScanBatchParams: scanBatchParams, 303 313 } 304 314 305 315 if err := h.Templates.ExecuteTemplate(w, "repository", data); err != nil {
+28 -20
pkg/appview/templates/pages/repository.html
··· 150 150 {{ end }} 151 151 </div> 152 152 </div> 153 - <div class="text-sm"> 154 - <div class="flex flex-wrap justify-between items-center gap-2"> 155 - <div class="flex items-center gap-2"> 156 - <code class="font-mono text-xs text-base-content/60 truncate max-w-40" title="{{ .Tag.Digest }}">{{ .Tag.Digest }}</code> 157 - <button class="btn btn-ghost btn-xs" onclick="copyToClipboard('{{ .Tag.Digest }}')" aria-label="Copy tag digest to clipboard">{{ icon "copy" "size-3" }}</button> 158 - </div> 159 - {{ if .Platforms }} 160 - <div class="flex flex-wrap gap-1"> 161 - {{ range .Platforms }} 162 - <span class="badge badge-sm badge-soft badge-secondary">{{ .OS }}/{{ .Architecture }}{{ if .Variant }}/{{ .Variant }}{{ end }}</span> 153 + <div class="text-sm space-y-2"> 154 + <div class="flex items-center gap-2"> 155 + <code class="font-mono text-xs text-base-content/60 truncate max-w-40" title="{{ .Tag.Digest }}">{{ .Tag.Digest }}</code> 156 + <button class="btn btn-ghost btn-xs" onclick="copyToClipboard('{{ .Tag.Digest }}')" aria-label="Copy tag digest to clipboard">{{ icon "copy" "size-3" }}</button> 157 + </div> 158 + {{ if .Platforms }} 159 + <div class="space-y-1"> 160 + {{ range .Platforms }} 161 + <div class="flex flex-wrap items-center gap-2"> 162 + <span class="badge badge-sm badge-soft badge-secondary">{{ .OS }}/{{ .Architecture }}{{ if .Variant }}/{{ .Variant }}{{ end }}</span> 163 + {{ if .Digest }} 164 + <code class="font-mono text-xs text-base-content/60 truncate max-w-40" title="{{ .Digest }}">{{ .Digest }}</code> 165 + <button class="btn btn-ghost btn-xs" onclick="copyToClipboard('{{ .Digest }}')" aria-label="Copy platform digest to clipboard">{{ icon "copy" "size-3" }}</button> 166 + {{ if .HoldEndpoint }} 167 + <span id="scan-badge-{{ trimPrefix "sha256:" .Digest }}"></span> 168 + {{ end }} 163 169 {{ end }} 164 170 </div> 165 171 {{ end }} 166 172 </div> 173 + {{ else if .HoldEndpoint }} 174 + {{/* Single-arch: scan badge for the tag's own digest */}} 175 + <div><span id="scan-badge-{{ trimPrefix "sha256:" .Tag.Digest }}"></span></div> 176 + {{ end }} 167 177 </div> 168 178 {{ if eq .ArtifactType "helm-chart" }} 169 179 {{ template "docker-command" (print "helm pull oci://" $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name " --version " .Tag.Tag) }} ··· 173 183 </div> 174 184 {{ end }} 175 185 </div> 186 + {{ if $.ScanBatchParams }} 187 + {{ range $.ScanBatchParams }} 188 + <div hx-get="/api/scan-results?{{ . }}" 189 + hx-trigger="load delay:500ms" 190 + hx-swap="none" 191 + style="display:none"></div> 192 + {{ end }} 193 + {{ end }} 176 194 {{ else }} 177 195 <p class="text-base-content/60">No tags available</p> 178 196 {{ end }} ··· 225 243 <code class="font-mono text-xs text-base-content/60 truncate max-w-40" title="{{ .Manifest.Digest }}">{{ .Manifest.Digest }}</code> 226 244 <button class="btn btn-ghost btn-xs" onclick="copyToClipboard('{{ .Manifest.Digest }}')" aria-label="Copy manifest digest to clipboard">{{ icon "copy" "size-3" }}</button> 227 245 </div> 228 - {{/* Vulnerability scan badge — own row below digest */}} 229 - {{ if and (not .IsManifestList) .Manifest.HoldEndpoint }} 230 - <div><span id="scan-badge-{{ trimPrefix "sha256:" .Manifest.Digest }}"></span></div> 231 - {{ end }} 232 246 </div> 233 247 <div class="flex items-center gap-2"> 234 248 <time class="text-sm text-base-content/60" datetime="{{ .Manifest.CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}"> ··· 267 281 </div> 268 282 {{ end }} 269 283 </div> 270 - {{ if $.ScanBatchParams }} 271 - <div hx-get="/api/scan-results?{{ $.ScanBatchParams }}" 272 - hx-trigger="load delay:500ms" 273 - hx-swap="none" 274 - style="display:none"></div> 275 - {{ end }} 276 284 {{ else }} 277 285 <p class="text-base-content/60">No manifests available</p> 278 286 {{ end }}
+4 -1
pkg/hold/pds/did.go
··· 170 170 rotationKey, _ := parseOptionalMultibaseKey(cfg.RotationKey) 171 171 172 172 if err := EnsurePLCCurrent(ctx, did, rotationKey, signingKey, cfg.PublicURL, cfg.PLCDirectoryURL); err != nil { 173 - return "", fmt.Errorf("failed to ensure PLC identity is current: %w", err) 173 + slog.Warn("Failed to verify PLC identity is current (will retry on next restart)", 174 + "did", did, 175 + "error", err, 176 + ) 174 177 } 175 178 176 179 return did, nil