remove distribution from hold, add vulnerability scanning in appview.

1. Removing distribution/distribution from the Hold Service (biggest change)
  The hold service previously used distribution's StorageDriver interface for all blob operations. This replaces it with direct AWS SDK v2 calls through ATCR's own pkg/s3.S3Service:
  - New S3Service methods: Stat(), PutBytes(), Move(), Delete(), WalkBlobs(), ListPrefix() added to pkg/s3/types.go
  - Pull zone fix: Presigned URLs are now generated against the real S3 endpoint, then the host is swapped to the CDN URL post-signing (previously the CDN URL was set as the endpoint, which
  broke SigV4 signatures)
  - All hold subsystems migrated: GC, OCI uploads, XRPC handlers, profile uploads, scan broadcaster, manifest posts — all now use *s3.S3Service instead of storagedriver.StorageDriver
  - Config simplified: Removed configuration.Storage type and buildStorageConfigFromFields(); replaced with a simple S3Params() method
  - Mock expanded: MockS3Client gains an in-memory object store + 5 new methods, replacing duplicate mockStorageDriver implementations in tests (~160 lines deleted from each test file)
2. Vulnerability Scan UI in AppView (new feature)
  Displays scan results from the hold's PDS on the repository page:
  - New lexicon: io/atcr/hold/scan.json with vulnReportBlob field for storing full Grype reports
  - Two new HTMX endpoints: /api/scan-result (badge) and /api/vuln-details (modal with CVE table)
  - New templates: vuln-badge.html (severity count chips) and vuln-details.html (full CVE table with NVD/GHSA links)
  - Repository page: Lazy-loads scan badges per manifest via HTMX
  - Tests: ~590 lines of test coverage for both handlers
3. S3 Diagnostic Tool
  New cmd/s3-test/main.go (418 lines) — tests S3 connectivity with both SDK v1 and v2, including presigned URL generation, pull zone host swapping, and verbose signing debug output.
4. Deployment Tooling
  - New syncServiceUnit() for comparing/updating systemd units on servers
  - Update command now syncs config keys (adds missing keys from template) and service units with daemon-reload
5. DB Migration
  0011_fix_captain_successor_column.yaml — rebuilds hold_captain_records to add the successor column that was missed in a previous migration.
6. Documentation
  - APPVIEW-UI-FUTURE.md rewritten as a status-tracked feature inventory
  - DISTRIBUTION.md renamed to CREDENTIAL_HELPER.md
  - New REMOVING_DISTRIBUTION.md — 480-line analysis of fully removing distribution from the appview side
7. go.mod
  aws-sdk-go v1 moved from indirect to direct (needed by cmd/s3-test).
This commit is contained in:
Evan Jarrett
2026-02-13 15:26:24 -06:00
parent 434a5f1eee
commit de02e1f046
38 changed files with 3134 additions and 962 deletions
+37 -1
View File
@@ -1851,7 +1851,7 @@ func (t *ScanRecord) MarshalCBOR(w io.Writer) error {
cw := cbg.NewCborWriter(w)
if _, err := cw.Write([]byte{172}); err != nil {
if _, err := cw.Write([]byte{173}); err != nil {
return err
}
@@ -2118,6 +2118,22 @@ func (t *ScanRecord) MarshalCBOR(w io.Writer) error {
if _, err := cw.WriteString(string(t.ScannerVersion)); err != nil {
return err
}
// t.VulnReportBlob (util.LexBlob) (struct)
if len("vulnReportBlob") > 8192 {
return xerrors.Errorf("Value in field \"vulnReportBlob\" was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("vulnReportBlob"))); err != nil {
return err
}
if _, err := cw.WriteString(string("vulnReportBlob")); err != nil {
return err
}
if err := t.VulnReportBlob.MarshalCBOR(cw); err != nil {
return err
}
return nil
}
@@ -2378,6 +2394,26 @@ func (t *ScanRecord) UnmarshalCBOR(r io.Reader) (err error) {
t.ScannerVersion = string(sval)
}
// t.VulnReportBlob (util.LexBlob) (struct)
case "vulnReportBlob":
{
b, err := cr.ReadByte()
if err != nil {
return err
}
if b != cbg.CborNull[0] {
if err := cr.UnreadByte(); err != nil {
return err
}
t.VulnReportBlob = new(util.LexBlob)
if err := t.VulnReportBlob.UnmarshalCBOR(cr); err != nil {
return xerrors.Errorf("unmarshaling t.VulnReportBlob pointer: %w", err)
}
}
}
default:
// Field doesn't exist on this type, so ignore it
+15 -12
View File
@@ -801,30 +801,33 @@ func CrewRecordKey(memberDID string) string {
// RKey is deterministic: based on manifest digest (one scan per manifest)
type ScanRecord struct {
Type string `json:"$type" cborgen:"$type"`
Manifest string `json:"manifest" cborgen:"manifest"` // AT-URI of the scanned manifest (e.g., "at://did:plc:xyz/io.atcr.manifest/abc123...")
Repository string `json:"repository" cborgen:"repository"` // Repository name (e.g., "myapp")
UserDID string `json:"userDid" cborgen:"userDid"` // DID of the image owner
SbomBlob *lexutil.LexBlob `json:"sbomBlob,omitempty" cborgen:"sbomBlob"` // SBOM blob uploaded to hold's PDS blob storage
Critical int64 `json:"critical" cborgen:"critical"` // Count of critical vulnerabilities
High int64 `json:"high" cborgen:"high"` // Count of high vulnerabilities
Medium int64 `json:"medium" cborgen:"medium"` // Count of medium vulnerabilities
Low int64 `json:"low" cborgen:"low"` // Count of low vulnerabilities
Total int64 `json:"total" cborgen:"total"` // Total vulnerability count
ScannerVersion string `json:"scannerVersion" cborgen:"scannerVersion"` // Scanner version (e.g., "atcr-scanner-v1.0.0")
ScannedAt string `json:"scannedAt" cborgen:"scannedAt"` // RFC3339 timestamp of scan completion
Manifest string `json:"manifest" cborgen:"manifest"` // AT-URI of the scanned manifest (e.g., "at://did:plc:xyz/io.atcr.manifest/abc123...")
Repository string `json:"repository" cborgen:"repository"` // Repository name (e.g., "myapp")
UserDID string `json:"userDid" cborgen:"userDid"` // DID of the image owner
SbomBlob *lexutil.LexBlob `json:"sbomBlob,omitempty" cborgen:"sbomBlob"` // SBOM blob uploaded to hold's PDS blob storage
VulnReportBlob *lexutil.LexBlob `json:"vulnReportBlob,omitempty" cborgen:"vulnReportBlob"` // Grype vulnerability report blob (full CVE details)
Critical int64 `json:"critical" cborgen:"critical"` // Count of critical vulnerabilities
High int64 `json:"high" cborgen:"high"` // Count of high vulnerabilities
Medium int64 `json:"medium" cborgen:"medium"` // Count of medium vulnerabilities
Low int64 `json:"low" cborgen:"low"` // Count of low vulnerabilities
Total int64 `json:"total" cborgen:"total"` // Total vulnerability count
ScannerVersion string `json:"scannerVersion" cborgen:"scannerVersion"` // Scanner version (e.g., "atcr-scanner-v1.0.0")
ScannedAt string `json:"scannedAt" cborgen:"scannedAt"` // RFC3339 timestamp of scan completion
}
// NewScanRecord creates a new scan record
// manifestDigest: the manifest digest (e.g., "sha256:abc123...")
// userDID: the DID of the image owner (used to build the manifest AT-URI)
// sbomBlob: blob reference from uploading SBOM to PDS blob storage (nil if no SBOM)
func NewScanRecord(manifestDigest, repository, userDID string, sbomBlob *lexutil.LexBlob, critical, high, medium, low, total int, scannerVersion string) *ScanRecord {
// vulnReportBlob: blob reference from uploading Grype vulnerability report (nil if no report)
func NewScanRecord(manifestDigest, repository, userDID string, sbomBlob, vulnReportBlob *lexutil.LexBlob, critical, high, medium, low, total int, scannerVersion string) *ScanRecord {
return &ScanRecord{
Type: ScanCollection,
Manifest: BuildManifestURI(userDID, manifestDigest),
Repository: repository,
UserDID: userDID,
SbomBlob: sbomBlob,
VulnReportBlob: vulnReportBlob,
Critical: int64(critical),
High: int64(high),
Medium: int64(medium),