diff --git a/pkg/appview/db/migrations/0012_add_layer_annotations.yaml b/pkg/appview/db/migrations/0012_add_layer_annotations.yaml new file mode 100644 index 0000000..a425935 --- /dev/null +++ b/pkg/appview/db/migrations/0012_add_layer_annotations.yaml @@ -0,0 +1,3 @@ +description: Add annotations column to layers table for caching layer-level annotations (e.g. in-toto predicate types) +query: | + ALTER TABLE layers ADD COLUMN annotations TEXT; diff --git a/pkg/appview/db/models.go b/pkg/appview/db/models.go index 8c4a055..bfc8554 100644 --- a/pkg/appview/db/models.go +++ b/pkg/appview/db/models.go @@ -29,11 +29,12 @@ type Manifest struct { // Layer represents a layer in a manifest type Layer struct { - ManifestID int64 - Digest string - Size int64 - MediaType string - LayerIndex int + ManifestID int64 + Digest string + Size int64 + MediaType string + LayerIndex int + Annotations map[string]string // JSON-encoded layer annotations (e.g. in-toto predicate type) } // ManifestReference represents a reference to a manifest in a manifest list/index @@ -149,3 +150,12 @@ type ManifestWithMetadata struct { Pending bool // Whether health check is still in progress // Note: ArtifactType is available via embedded Manifest struct } + +// AttestationDetail represents an attestation manifest and its layers +type AttestationDetail struct { + Digest string + MediaType string // attestation manifest media type + Size int64 + HoldEndpoint string // hold DID/URL where blobs are stored + Layers []Layer +} diff --git a/pkg/appview/db/queries.go b/pkg/appview/db/queries.go index 0041a8c..a2a2ae4 100644 --- a/pkg/appview/db/queries.go +++ b/pkg/appview/db/queries.go @@ -2,6 +2,7 @@ package db import ( "database/sql" + "encoding/json" "fmt" "strings" "time" @@ -576,12 +577,27 @@ func InsertManifest(db DBTX, manifest *Manifest) (int64, error) { return id, nil } -// InsertLayer inserts a new layer record +// InsertLayer inserts or updates a layer record. +// Uses upsert so backfill re-processing populates new columns (e.g. annotations). func InsertLayer(db DBTX, layer *Layer) error { + var annotationsJSON *string + if len(layer.Annotations) > 0 { + b, err := json.Marshal(layer.Annotations) + if err != nil { + return fmt.Errorf("failed to marshal layer annotations: %w", err) + } + s := string(b) + annotationsJSON = &s + } _, err := db.Exec(` - INSERT INTO layers (manifest_id, digest, size, media_type, layer_index) - VALUES (?, ?, ?, ?, ?) - `, layer.ManifestID, layer.Digest, layer.Size, layer.MediaType, layer.LayerIndex) + INSERT INTO layers (manifest_id, digest, size, media_type, layer_index, annotations) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(manifest_id, layer_index) DO UPDATE SET + digest = excluded.digest, + size = excluded.size, + media_type = excluded.media_type, + annotations = excluded.annotations + `, layer.ManifestID, layer.Digest, layer.Size, layer.MediaType, layer.LayerIndex, annotationsJSON) return err } @@ -820,7 +836,7 @@ func GetRepositoriesForDID(db DBTX, did string) ([]string, error) { // GetLayersForManifest fetches all layers for a manifest func GetLayersForManifest(db DBTX, manifestID int64) ([]Layer, error) { rows, err := db.Query(` - SELECT manifest_id, digest, size, media_type, layer_index + SELECT manifest_id, digest, size, media_type, layer_index, annotations FROM layers WHERE manifest_id = ? ORDER BY layer_index @@ -834,9 +850,15 @@ func GetLayersForManifest(db DBTX, manifestID int64) ([]Layer, error) { var layers []Layer for rows.Next() { var l Layer - if err := rows.Scan(&l.ManifestID, &l.Digest, &l.Size, &l.MediaType, &l.LayerIndex); err != nil { + var annotationsJSON sql.NullString + if err := rows.Scan(&l.ManifestID, &l.Digest, &l.Size, &l.MediaType, &l.LayerIndex, &annotationsJSON); err != nil { return nil, err } + if annotationsJSON.Valid && annotationsJSON.String != "" { + if err := json.Unmarshal([]byte(annotationsJSON.String), &l.Annotations); err != nil { + return nil, fmt.Errorf("failed to unmarshal layer annotations: %w", err) + } + } layers = append(layers, l) } @@ -1209,6 +1231,78 @@ func GetManifestTags(db DBTX, did, repository, digest string) ([]string, error) return tags, nil } +// GetAttestationDetails returns attestation manifests and their layers for a manifest list. +// Joins manifest_references (is_attestation=true) → manifests → layers. +func GetAttestationDetails(db DBTX, did, repository, manifestListDigest string) ([]AttestationDetail, error) { + // Step 1: Get the manifest list ID and hold endpoint + var manifestListID int64 + var parentHoldEndpoint string + err := db.QueryRow(` + SELECT id, hold_endpoint FROM manifests + WHERE did = ? AND repository = ? AND digest = ? + `, did, repository, manifestListDigest).Scan(&manifestListID, &parentHoldEndpoint) + if err != nil { + return nil, err + } + + // Step 2: Get attestation references and join to their manifest records + rows, err := db.Query(` + SELECT mr.digest, mr.media_type, mr.size, m.id + FROM manifest_references mr + LEFT JOIN manifests m ON m.digest = mr.digest AND m.did = ? AND m.repository = ? + WHERE mr.manifest_id = ? AND mr.is_attestation = 1 + ORDER BY mr.reference_index + `, did, repository, manifestListID) + if err != nil { + return nil, err + } + defer rows.Close() + + type refRow struct { + digest string + mediaType string + size int64 + manifestID *int64 // may be NULL if attestation manifest not indexed yet + } + var refs []refRow + for rows.Next() { + var r refRow + var mid sql.NullInt64 + if err := rows.Scan(&r.digest, &r.mediaType, &r.size, &mid); err != nil { + return nil, err + } + if mid.Valid { + r.manifestID = &mid.Int64 + } + refs = append(refs, r) + } + if err := rows.Err(); err != nil { + return nil, err + } + + // Step 3: For each attestation manifest, fetch its layers + // Use the parent manifest list's hold endpoint — attestation blobs are in the same hold + details := make([]AttestationDetail, 0, len(refs)) + for _, ref := range refs { + detail := AttestationDetail{ + Digest: ref.digest, + MediaType: ref.mediaType, + Size: ref.size, + HoldEndpoint: parentHoldEndpoint, + } + if ref.manifestID != nil { + layers, err := GetLayersForManifest(db, *ref.manifestID) + if err != nil { + return nil, err + } + detail.Layers = layers + } + details = append(details, detail) + } + + return details, nil +} + // BackfillState represents the backfill progress type BackfillState struct { StartCursor int64 diff --git a/pkg/appview/db/schema.sql b/pkg/appview/db/schema.sql index ad4ba1e..7b59bf4 100644 --- a/pkg/appview/db/schema.sql +++ b/pkg/appview/db/schema.sql @@ -55,6 +55,7 @@ CREATE TABLE IF NOT EXISTS layers ( size INTEGER NOT NULL, media_type TEXT NOT NULL, layer_index INTEGER NOT NULL, + annotations TEXT, PRIMARY KEY(manifest_id, layer_index), FOREIGN KEY(manifest_id) REFERENCES manifests(id) ON DELETE CASCADE ); diff --git a/pkg/appview/handlers/attestation_details.go b/pkg/appview/handlers/attestation_details.go new file mode 100644 index 0000000..a2f5d57 --- /dev/null +++ b/pkg/appview/handlers/attestation_details.go @@ -0,0 +1,334 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strings" + "time" + + "atcr.io/pkg/appview/db" + "atcr.io/pkg/appview/middleware" + "atcr.io/pkg/atproto" + "atcr.io/pkg/auth" +) + +// AttestationDetailsHandler handles requests for attestation detail modal content. +// Returns an HTML fragment (attestation-details partial) for insertion into the modal body. +type AttestationDetailsHandler struct { + BaseUIHandler +} + +// attestationDetailsData is the template data for the attestation-details partial. +type attestationDetailsData struct { + Attestations []attestationInfo + Error string + LoginURL string // login URL with return_to for the current repo page +} + +type attestationInfo struct { + Digest string + PredicateType string // human-friendly predicate type name + RawJSON string // formatted JSON content of the attestation (empty for binary) + MediaType string // layer media type (shown when content is not displayable) + Size int64 // layer size in bytes + FetchError string // non-empty if blob fetch failed + NeedsLogin bool // true if blob fetch failed due to auth (403) +} + +// predicateTypePrefixes maps in-toto predicate type URI prefixes to human-friendly names. +// Prefix matching handles version drift (e.g. v1 → v1.1) without code changes. +// Source: https://github.com/in-toto/attestation/tree/main/spec/predicates +var predicateTypePrefixes = []struct { + prefix string + name string +}{ + {"https://slsa.dev/provenance/", "SLSA Provenance"}, + {"https://slsa.dev/verification_summary/", "SLSA Verification Summary"}, + {"https://spdx.dev/Document", "SPDX SBOM"}, + {"https://cyclonedx.org/bom", "CycloneDX SBOM"}, + {"https://in-toto.io/attestation/vulns", "Vulnerability Scan"}, + {"https://in-toto.io/attestation/scai/", "SCAI Report"}, + {"https://in-toto.io/attestation/link/", "in-toto Link"}, + {"https://in-toto.io/attestation/runtime-trace/", "Runtime Trace"}, + {"https://in-toto.io/attestation/release", "Release"}, + {"https://in-toto.io/attestation/test-result/", "Test Result"}, + {"https://in-toto.io/attestation/reference/", "Reference"}, +} + +func friendlyPredicateType(predicateType string) string { + for _, p := range predicateTypePrefixes { + if strings.HasPrefix(predicateType, p.prefix) { + return p.name + } + } + if predicateType != "" { + return predicateType + } + return "Unknown" +} + +// inTotoStatement is the minimal in-toto statement structure for extracting the predicate type. +type inTotoStatement struct { + Type string `json:"_type"` + PredicateType string `json:"predicateType"` + Subject json.RawMessage `json:"subject"` + Predicate json.RawMessage `json:"predicate"` +} + +func (h *AttestationDetailsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + digest := r.URL.Query().Get("digest") + did := r.URL.Query().Get("did") + repo := r.URL.Query().Get("repo") + + if digest == "" || did == "" || repo == "" { + h.renderDetails(w, attestationDetailsData{Error: "Missing required parameters"}) + return + } + + details, err := db.GetAttestationDetails(h.ReadOnlyDB, did, repo, digest) + if err != nil { + slog.Warn("Failed to fetch attestation details", "error", err, "digest", digest) + h.renderDetails(w, attestationDetailsData{Error: "No attestation details found"}) + return + } + + if len(details) == 0 { + h.renderDetails(w, attestationDetailsData{Error: "No attestations found for this manifest"}) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + + // Look up repo owner for PDS endpoint and handle + var pdsEndpoint string + var loginURL string + if repoOwner, err := db.GetUserByDID(h.ReadOnlyDB, did); err == nil && repoOwner != nil { + pdsEndpoint = repoOwner.PDSEndpoint + returnTo := fmt.Sprintf("/r/%s/%s", repoOwner.Handle, repo) + loginURL = "/auth/oauth/login?return_to=" + url.QueryEscape(returnTo) + } + + // If the viewer is logged in, get a service token so we can read from private holds + var serviceToken string + loggedIn := false + if user := middleware.GetUser(r); user != nil && h.Refresher != nil { + loggedIn = true + holdDID := atproto.ResolveHoldDIDFromURL(details[0].HoldEndpoint) + if holdDID == "" { + holdDID = details[0].HoldEndpoint // might already be a DID + } + if token, err := auth.GetOrFetchServiceToken(ctx, h.Refresher, user.DID, holdDID, user.PDSEndpoint); err == nil { + serviceToken = token + } else { + slog.Debug("Could not get service token for attestation fetch", "error", err) + } + } + + attestations := make([]attestationInfo, 0, len(details)) + for _, d := range details { + info := attestationInfo{ + Digest: d.Digest, + } + + // Resolve layer digest and annotations — prefer local DB, fallback to PDS + layerDigest := "" + if len(d.Layers) > 0 { + layerDigest = d.Layers[0].Digest + // Check local DB annotations for predicate type + if pt, ok := d.Layers[0].Annotations["in-toto.io/predicate-type"]; ok { + info.PredicateType = friendlyPredicateType(pt) + } else if pdsEndpoint != "" { + // Annotations not cached locally (pre-migration data) — fetch from PDS + pdsLayers, err := fetchLayersFromPDS(ctx, pdsEndpoint, did, d.Digest) + if err != nil { + slog.Debug("Failed to fetch annotations from PDS for pre-migration layer", "error", err, "digest", d.Digest) + } else if len(pdsLayers) > 0 { + if pt, ok := pdsLayers[0].Annotations["in-toto.io/predicate-type"]; ok { + info.PredicateType = friendlyPredicateType(pt) + } + } + } + } else if pdsEndpoint != "" { + // Fallback: layers not in local DB yet — fetch from PDS + pdsLayers, err := fetchLayersFromPDS(ctx, pdsEndpoint, did, d.Digest) + if err != nil { + slog.Warn("Failed to fetch attestation manifest from PDS", "error", err, "digest", d.Digest) + } else if len(pdsLayers) > 0 { + layerDigest = pdsLayers[0].Digest + if pt, ok := pdsLayers[0].Annotations["in-toto.io/predicate-type"]; ok { + info.PredicateType = friendlyPredicateType(pt) + } + } + } + + if layerDigest != "" && d.HoldEndpoint != "" { + content, err := fetchLayerBlob(ctx, d.HoldEndpoint, layerDigest, serviceToken) + if err != nil { + slog.Warn("Failed to fetch attestation blob", "error", err, "digest", layerDigest) + if !loggedIn && strings.Contains(err.Error(), "403") { + info.NeedsLogin = true + } else { + info.FetchError = "Could not fetch attestation content" + } + if info.PredicateType == "" { + info.PredicateType = "Unknown" + } + } else { + // Check if content is valid JSON before trying to display it + var parsed json.RawMessage + if json.Unmarshal(content, &parsed) == nil { + // It's JSON — parse in-toto statement for predicate type (if not already set from annotations) + if info.PredicateType == "" { + var stmt inTotoStatement + if err := json.Unmarshal(content, &stmt); err == nil { + info.PredicateType = friendlyPredicateType(stmt.PredicateType) + } else { + info.PredicateType = "Unknown" + } + } + // Pretty-print for display + if pretty, err := json.MarshalIndent(parsed, "", " "); err == nil { + info.RawJSON = string(pretty) + } else { + info.RawJSON = string(content) + } + } else { + // Binary content — show media type and size instead + if info.PredicateType == "" { + info.PredicateType = "Binary Attachment" + } + info.Size = int64(len(content)) + } + } + } else { + if info.PredicateType == "" { + info.PredicateType = "Unknown" + } + info.FetchError = "Attestation content not available" + } + + attestations = append(attestations, info) + } + + h.renderDetails(w, attestationDetailsData{Attestations: attestations, LoginURL: loginURL}) +} + +// fetchLayersFromPDS fetches an attestation manifest record from the user's PDS +// and returns its layers. Used as a fallback when layers aren't in the local DB. +func fetchLayersFromPDS(ctx context.Context, pdsEndpoint, did, attestationDigest string) ([]atproto.BlobReference, error) { + rkey := strings.TrimPrefix(attestationDigest, "sha256:") + + getRecordURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", + pdsEndpoint, + url.QueryEscape(did), + url.QueryEscape(atproto.ManifestCollection), + url.QueryEscape(rkey), + ) + + req, err := http.NewRequestWithContext(ctx, "GET", getRecordURL, nil) + if err != nil { + return nil, fmt.Errorf("build PDS request: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("PDS request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, nil // record doesn't exist + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("PDS returned status %d", resp.StatusCode) + } + + var result struct { + Value atproto.ManifestRecord `json:"value"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("parse PDS response: %w", err) + } + + return result.Value.Layers, nil +} + +// fetchLayerBlob fetches a small OCI layer blob from a hold service. +// Two-hop flow: (1) get presigned URL from hold, (2) fetch blob from S3. +// serviceToken is optional — pass "" for public holds. +func fetchLayerBlob(ctx context.Context, holdEndpoint, layerDigest, serviceToken string) ([]byte, error) { + holdURL := atproto.ResolveHoldURL(holdEndpoint) + holdDID := atproto.ResolveHoldDIDFromURL(holdEndpoint) + if holdURL == "" || holdDID == "" { + return nil, fmt.Errorf("could not resolve hold endpoint: %s", holdEndpoint) + } + + // Step 1: Request presigned URL from hold + getBlobURL := fmt.Sprintf("%s%s?did=%s&cid=%s", + holdURL, + atproto.SyncGetBlob, + url.QueryEscape(holdDID), + url.QueryEscape(layerDigest), + ) + + req, err := http.NewRequestWithContext(ctx, "GET", getBlobURL, nil) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + if serviceToken != "" { + req.Header.Set("Authorization", "Bearer "+serviceToken) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("hold request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("hold returned status %d", resp.StatusCode) + } + + var presigned struct { + URL string `json:"url"` + } + if err := json.NewDecoder(resp.Body).Decode(&presigned); err != nil { + return nil, fmt.Errorf("parse presigned response: %w", err) + } + + if presigned.URL == "" { + return nil, fmt.Errorf("empty presigned URL") + } + + // Step 2: Fetch blob from S3 + s3Req, err := http.NewRequestWithContext(ctx, "GET", presigned.URL, nil) + if err != nil { + return nil, fmt.Errorf("build S3 request: %w", err) + } + + s3Resp, err := http.DefaultClient.Do(s3Req) + if err != nil { + return nil, fmt.Errorf("S3 request: %w", err) + } + defer s3Resp.Body.Close() + + if s3Resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("S3 returned status %d", s3Resp.StatusCode) + } + + // Limit read to 1MB to avoid loading huge blobs + return io.ReadAll(io.LimitReader(s3Resp.Body, 1<<20)) +} + +func (h *AttestationDetailsHandler) renderDetails(w http.ResponseWriter, data attestationDetailsData) { + w.Header().Set("Content-Type", "text/html") + if err := h.Templates.ExecuteTemplate(w, "attestation-details", data); err != nil { + slog.Warn("Failed to render attestation details", "error", err) + } +} diff --git a/pkg/appview/jetstream/processor.go b/pkg/appview/jetstream/processor.go index 016a1d9..2d74e94 100644 --- a/pkg/appview/jetstream/processor.go +++ b/pkg/appview/jetstream/processor.go @@ -364,11 +364,12 @@ func (p *Processor) ProcessManifest(ctx context.Context, did string, recordData // Insert layers (for image manifests) for i, layer := range manifestRecord.Layers { if err := db.InsertLayer(p.db, &db.Layer{ - ManifestID: manifestID, - Digest: layer.Digest, - MediaType: layer.MediaType, - Size: layer.Size, - LayerIndex: i, + ManifestID: manifestID, + Digest: layer.Digest, + MediaType: layer.MediaType, + Size: layer.Size, + LayerIndex: i, + Annotations: layer.Annotations, }); err != nil { // Continue on error - layer might already exist continue diff --git a/pkg/appview/routes/routes.go b/pkg/appview/routes/routes.go index 2a1da9d..423c63b 100644 --- a/pkg/appview/routes/routes.go +++ b/pkg/appview/routes/routes.go @@ -126,6 +126,11 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) { router.Get("/api/scan-result", (&uihandlers.ScanResultHandler{BaseUIHandler: base}).ServeHTTP) router.Get("/api/vuln-details", (&uihandlers.VulnDetailsHandler{BaseUIHandler: base}).ServeHTTP) + // Attestation details API endpoint (HTMX modal content) + router.Get("/api/attestation-details", middleware.OptionalAuth(deps.SessionStore, deps.Database)( + &uihandlers.AttestationDetailsHandler{BaseUIHandler: base}, + ).ServeHTTP) + router.Get("/u/{handle}", middleware.OptionalAuth(deps.SessionStore, deps.Database)( &uihandlers.UserPageHandler{BaseUIHandler: base}, ).ServeHTTP) diff --git a/pkg/appview/src/css/main.css b/pkg/appview/src/css/main.css index 81694f9..d1d8096 100644 --- a/pkg/appview/src/css/main.css +++ b/pkg/appview/src/css/main.css @@ -228,7 +228,7 @@ COMMAND / CODE DISPLAY ---------------------------------------- */ .cmd { - @apply flex items-center gap-2 relative w-full overflow-hidden; + @apply flex items-center gap-2 relative w-full max-w-lg overflow-hidden; @apply bg-base-200 border border-base-300 rounded-md; @apply px-3 py-2; } diff --git a/pkg/appview/templates/components/docker-command.html b/pkg/appview/templates/components/docker-command.html index 049fdfe..2026494 100644 --- a/pkg/appview/templates/components/docker-command.html +++ b/pkg/appview/templates/components/docker-command.html @@ -8,7 +8,7 @@
{{ . }}
-