···1010 "fmt"
1111 "strings"
1212 "time"
1313+1414+ lexutil "github.com/bluesky-social/indigo/lex/util"
1315)
14161517// Collection names for ATProto records
···4042 // StatsCollection is the collection name for repository statistics
4143 // Stored in hold's embedded PDS to track pull/push counts per owner+repo
4244 StatsCollection = "io.atcr.hold.stats"
4545+4646+ // ScanCollection is the collection name for vulnerability scan results
4747+ // Stored in hold's embedded PDS to track scan results per manifest
4848+ ScanCollection = "io.atcr.hold.scan"
43494450 // TangledProfileCollection is the collection name for tangled profiles
4551 // Stored in hold's embedded PDS (singleton record at rkey "self")
···594600 Role string `json:"role" cborgen:"role"`
595601 Permissions []string `json:"permissions" cborgen:"permissions"`
596602 Tier string `json:"tier,omitempty" cborgen:"tier,omitempty"` // Optional tier for quota limits (e.g., 'deckhand', 'bosun', 'quartermaster')
597597- Plankowner bool `json:"plankowner,omitempty" cborgen:"plankowner,omitempty"` // Early adopter flag - gets plankowner_crew_tier for free
603603+ Plankowner bool `json:"plankowner,omitempty" cborgen:"plankowner"` // Early adopter flag - gets plankowner_crew_tier for free
598604 AddedAt string `json:"addedAt" cborgen:"addedAt"` // RFC3339 timestamp
599605}
600606···673679 // Use first 16 bytes (128 bits) for collision resistance
674680 // Encode with base32 (alphanumeric, lowercase, no padding) for ATProto rkey compatibility
675681 return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(hash[:16]))
682682+}
683683+684684+// ScanRecord represents vulnerability scan results for a manifest
685685+// Collection: io.atcr.hold.scan
686686+// Stored in hold's embedded PDS to track scan results per manifest
687687+// Uses CBOR encoding for efficient storage in hold's carstore
688688+// RKey is deterministic: based on manifest digest (one scan per manifest)
689689+type ScanRecord struct {
690690+ Type string `json:"$type" cborgen:"$type"`
691691+ Manifest string `json:"manifest" cborgen:"manifest"` // AT-URI of the scanned manifest (e.g., "at://did:plc:xyz/io.atcr.manifest/abc123...")
692692+ Repository string `json:"repository" cborgen:"repository"` // Repository name (e.g., "myapp")
693693+ UserDID string `json:"userDid" cborgen:"userDid"` // DID of the image owner
694694+ SbomBlob *lexutil.LexBlob `json:"sbomBlob,omitempty" cborgen:"sbomBlob"` // SBOM blob uploaded to hold's PDS blob storage
695695+ Critical int64 `json:"critical" cborgen:"critical"` // Count of critical vulnerabilities
696696+ High int64 `json:"high" cborgen:"high"` // Count of high vulnerabilities
697697+ Medium int64 `json:"medium" cborgen:"medium"` // Count of medium vulnerabilities
698698+ Low int64 `json:"low" cborgen:"low"` // Count of low vulnerabilities
699699+ Total int64 `json:"total" cborgen:"total"` // Total vulnerability count
700700+ ScannerVersion string `json:"scannerVersion" cborgen:"scannerVersion"` // Scanner version (e.g., "atcr-scanner-v1.0.0")
701701+ ScannedAt string `json:"scannedAt" cborgen:"scannedAt"` // RFC3339 timestamp of scan completion
702702+}
703703+704704+// NewScanRecord creates a new scan record
705705+// manifestDigest: the manifest digest (e.g., "sha256:abc123...")
706706+// userDID: the DID of the image owner (used to build the manifest AT-URI)
707707+// sbomBlob: blob reference from uploading SBOM to PDS blob storage (nil if no SBOM)
708708+func NewScanRecord(manifestDigest, repository, userDID string, sbomBlob *lexutil.LexBlob, critical, high, medium, low, total int, scannerVersion string) *ScanRecord {
709709+ return &ScanRecord{
710710+ Type: ScanCollection,
711711+ Manifest: BuildManifestURI(userDID, manifestDigest),
712712+ Repository: repository,
713713+ UserDID: userDID,
714714+ SbomBlob: sbomBlob,
715715+ Critical: int64(critical),
716716+ High: int64(high),
717717+ Medium: int64(medium),
718718+ Low: int64(low),
719719+ Total: int64(total),
720720+ ScannerVersion: scannerVersion,
721721+ ScannedAt: time.Now().Format(time.RFC3339),
722722+ }
723723+}
724724+725725+// ScanRecordKey generates a deterministic record key for a scan result
726726+// Uses the manifest digest (without algorithm prefix) as the rkey
727727+// This ensures one scan record per manifest, and re-scans upsert the record
728728+func ScanRecordKey(manifestDigest string) string {
729729+ // Remove the "sha256:" prefix - the hex digest is already a valid rkey
730730+ return strings.TrimPrefix(manifestDigest, "sha256:")
676731}
677732678733// TangledProfileRecord represents a Tangled profile for the hold
+59
pkg/hold/pds/scan.go
···11+package pds
22+33+import (
44+ "context"
55+ "fmt"
66+77+ "atcr.io/pkg/atproto"
88+ "github.com/ipfs/go-cid"
99+)
1010+1111+// CreateScanRecord creates or updates a scan result record in the hold's PDS
1212+// Uses a deterministic rkey based on the manifest digest, so re-scans upsert
1313+func (p *HoldPDS) CreateScanRecord(ctx context.Context, record *atproto.ScanRecord) (string, cid.Cid, error) {
1414+ if record.Type != atproto.ScanCollection {
1515+ return "", cid.Undef, fmt.Errorf("invalid record type: %s", record.Type)
1616+ }
1717+1818+ if record.Manifest == "" {
1919+ return "", cid.Undef, fmt.Errorf("manifest AT-URI is required")
2020+ }
2121+2222+ // Extract the digest from the manifest AT-URI to use as rkey
2323+ manifestDigest, err := atproto.ParseManifestURI(record.Manifest)
2424+ if err != nil {
2525+ return "", cid.Undef, fmt.Errorf("invalid manifest AT-URI: %w", err)
2626+ }
2727+ rkey := atproto.ScanRecordKey(manifestDigest)
2828+2929+ // Upsert: re-scans update the existing record
3030+ rpath, recordCID, _, err := p.repomgr.UpsertRecord(
3131+ ctx,
3232+ p.uid,
3333+ atproto.ScanCollection,
3434+ rkey,
3535+ record,
3636+ )
3737+ if err != nil {
3838+ return "", cid.Undef, fmt.Errorf("failed to upsert scan record: %w", err)
3939+ }
4040+4141+ return rpath, recordCID, nil
4242+}
4343+4444+// GetScanRecord retrieves a scan result record by manifest digest
4545+func (p *HoldPDS) GetScanRecord(ctx context.Context, manifestDigest string) (cid.Cid, *atproto.ScanRecord, error) {
4646+ rkey := atproto.ScanRecordKey(manifestDigest)
4747+4848+ recordCID, val, err := p.repomgr.GetRecord(ctx, p.uid, atproto.ScanCollection, rkey, cid.Undef)
4949+ if err != nil {
5050+ return cid.Undef, nil, fmt.Errorf("failed to get scan record: %w", err)
5151+ }
5252+5353+ scanRecord, ok := val.(*atproto.ScanRecord)
5454+ if !ok {
5555+ return cid.Undef, nil, fmt.Errorf("unexpected type for scan record: %T", val)
5656+ }
5757+5858+ return recordCID, scanRecord, nil
5959+}
···2929 lexutil.RegisterType(atproto.LayerCollection, &atproto.LayerRecord{})
3030 lexutil.RegisterType(atproto.TangledProfileCollection, &atproto.TangledProfileRecord{})
3131 lexutil.RegisterType(atproto.StatsCollection, &atproto.StatsRecord{})
3232+ lexutil.RegisterType(atproto.ScanCollection, &atproto.ScanRecord{})
3233}
33343435// HoldPDS is a minimal ATProto PDS implementation for a hold service