add scan reports to hold pds

This commit is contained in:
Evan Jarrett
2026-02-04 10:25:09 -06:00
parent d6114cf549
commit 73109641e8
7 changed files with 864 additions and 191 deletions
+582 -1
View File
@@ -8,6 +8,7 @@ import (
"math"
"sort"
util "github.com/bluesky-social/indigo/lex/util"
cid "github.com/ipfs/go-cid"
cbg "github.com/whyrusleeping/cbor-gen"
xerrors "golang.org/x/xerrors"
@@ -25,7 +26,7 @@ func (t *CrewRecord) MarshalCBOR(w io.Writer) error {
}
cw := cbg.NewCborWriter(w)
fieldCount := 6
fieldCount := 7
if t.Tier == "" {
fieldCount--
@@ -153,6 +154,22 @@ func (t *CrewRecord) MarshalCBOR(w io.Writer) error {
return err
}
// t.Plankowner (bool) (bool)
if len("plankowner") > 8192 {
return xerrors.Errorf("Value in field \"plankowner\" was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("plankowner"))); err != nil {
return err
}
if _, err := cw.WriteString(string("plankowner")); err != nil {
return err
}
if err := cbg.WriteBool(w, t.Plankowner); err != nil {
return err
}
// t.Permissions ([]string) (slice)
if len("permissions") > 8192 {
return xerrors.Errorf("Value in field \"permissions\" was too long")
@@ -284,6 +301,24 @@ func (t *CrewRecord) UnmarshalCBOR(r io.Reader) (err error) {
t.AddedAt = string(sval)
}
// t.Plankowner (bool) (bool)
case "plankowner":
maj, extra, err = cr.ReadHeader()
if err != nil {
return err
}
if maj != cbg.MajOther {
return fmt.Errorf("booleans must be major type 7")
}
switch extra {
case 20:
t.Plankowner = false
case 21:
t.Plankowner = true
default:
return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra)
}
// t.Permissions ([]string) (slice)
case "permissions":
@@ -1767,3 +1802,549 @@ func (t *StatsRecord) UnmarshalCBOR(r io.Reader) (err error) {
return nil
}
func (t *ScanRecord) MarshalCBOR(w io.Writer) error {
if t == nil {
_, err := w.Write(cbg.CborNull)
return err
}
cw := cbg.NewCborWriter(w)
if _, err := cw.Write([]byte{172}); err != nil {
return err
}
// t.Low (int64) (int64)
if len("low") > 8192 {
return xerrors.Errorf("Value in field \"low\" was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("low"))); err != nil {
return err
}
if _, err := cw.WriteString(string("low")); err != nil {
return err
}
if t.Low >= 0 {
if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.Low)); err != nil {
return err
}
} else {
if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.Low-1)); err != nil {
return err
}
}
// t.High (int64) (int64)
if len("high") > 8192 {
return xerrors.Errorf("Value in field \"high\" was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("high"))); err != nil {
return err
}
if _, err := cw.WriteString(string("high")); err != nil {
return err
}
if t.High >= 0 {
if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.High)); err != nil {
return err
}
} else {
if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.High-1)); err != nil {
return err
}
}
// t.Type (string) (string)
if len("$type") > 8192 {
return xerrors.Errorf("Value in field \"$type\" was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
return err
}
if _, err := cw.WriteString(string("$type")); err != nil {
return err
}
if len(t.Type) > 8192 {
return xerrors.Errorf("Value in field t.Type was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Type))); err != nil {
return err
}
if _, err := cw.WriteString(string(t.Type)); err != nil {
return err
}
// t.Total (int64) (int64)
if len("total") > 8192 {
return xerrors.Errorf("Value in field \"total\" was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("total"))); err != nil {
return err
}
if _, err := cw.WriteString(string("total")); err != nil {
return err
}
if t.Total >= 0 {
if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.Total)); err != nil {
return err
}
} else {
if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.Total-1)); err != nil {
return err
}
}
// t.Medium (int64) (int64)
if len("medium") > 8192 {
return xerrors.Errorf("Value in field \"medium\" was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("medium"))); err != nil {
return err
}
if _, err := cw.WriteString(string("medium")); err != nil {
return err
}
if t.Medium >= 0 {
if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.Medium)); err != nil {
return err
}
} else {
if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.Medium-1)); err != nil {
return err
}
}
// t.UserDID (string) (string)
if len("userDid") > 8192 {
return xerrors.Errorf("Value in field \"userDid\" was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("userDid"))); err != nil {
return err
}
if _, err := cw.WriteString(string("userDid")); err != nil {
return err
}
if len(t.UserDID) > 8192 {
return xerrors.Errorf("Value in field t.UserDID was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.UserDID))); err != nil {
return err
}
if _, err := cw.WriteString(string(t.UserDID)); err != nil {
return err
}
// t.Critical (int64) (int64)
if len("critical") > 8192 {
return xerrors.Errorf("Value in field \"critical\" was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("critical"))); err != nil {
return err
}
if _, err := cw.WriteString(string("critical")); err != nil {
return err
}
if t.Critical >= 0 {
if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.Critical)); err != nil {
return err
}
} else {
if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.Critical-1)); err != nil {
return err
}
}
// t.Manifest (string) (string)
if len("manifest") > 8192 {
return xerrors.Errorf("Value in field \"manifest\" was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("manifest"))); err != nil {
return err
}
if _, err := cw.WriteString(string("manifest")); err != nil {
return err
}
if len(t.Manifest) > 8192 {
return xerrors.Errorf("Value in field t.Manifest was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Manifest))); err != nil {
return err
}
if _, err := cw.WriteString(string(t.Manifest)); err != nil {
return err
}
// t.SbomBlob (util.LexBlob) (struct)
if len("sbomBlob") > 8192 {
return xerrors.Errorf("Value in field \"sbomBlob\" was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sbomBlob"))); err != nil {
return err
}
if _, err := cw.WriteString(string("sbomBlob")); err != nil {
return err
}
if err := t.SbomBlob.MarshalCBOR(cw); err != nil {
return err
}
// t.ScannedAt (string) (string)
if len("scannedAt") > 8192 {
return xerrors.Errorf("Value in field \"scannedAt\" was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("scannedAt"))); err != nil {
return err
}
if _, err := cw.WriteString(string("scannedAt")); err != nil {
return err
}
if len(t.ScannedAt) > 8192 {
return xerrors.Errorf("Value in field t.ScannedAt was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.ScannedAt))); err != nil {
return err
}
if _, err := cw.WriteString(string(t.ScannedAt)); err != nil {
return err
}
// t.Repository (string) (string)
if len("repository") > 8192 {
return xerrors.Errorf("Value in field \"repository\" was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repository"))); err != nil {
return err
}
if _, err := cw.WriteString(string("repository")); err != nil {
return err
}
if len(t.Repository) > 8192 {
return xerrors.Errorf("Value in field t.Repository was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Repository))); err != nil {
return err
}
if _, err := cw.WriteString(string(t.Repository)); err != nil {
return err
}
// t.ScannerVersion (string) (string)
if len("scannerVersion") > 8192 {
return xerrors.Errorf("Value in field \"scannerVersion\" was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("scannerVersion"))); err != nil {
return err
}
if _, err := cw.WriteString(string("scannerVersion")); err != nil {
return err
}
if len(t.ScannerVersion) > 8192 {
return xerrors.Errorf("Value in field t.ScannerVersion was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.ScannerVersion))); err != nil {
return err
}
if _, err := cw.WriteString(string(t.ScannerVersion)); err != nil {
return err
}
return nil
}
func (t *ScanRecord) UnmarshalCBOR(r io.Reader) (err error) {
*t = ScanRecord{}
cr := cbg.NewCborReader(r)
maj, extra, err := cr.ReadHeader()
if err != nil {
return err
}
defer func() {
if err == io.EOF {
err = io.ErrUnexpectedEOF
}
}()
if maj != cbg.MajMap {
return fmt.Errorf("cbor input should be of type map")
}
if extra > cbg.MaxLength {
return fmt.Errorf("ScanRecord: map struct too large (%d)", extra)
}
n := extra
nameBuf := make([]byte, 14)
for i := uint64(0); i < n; i++ {
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192)
if err != nil {
return err
}
if !ok {
// Field doesn't exist on this type, so ignore it
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
return err
}
continue
}
switch string(nameBuf[:nameLen]) {
// t.Low (int64) (int64)
case "low":
{
maj, extra, err := cr.ReadHeader()
if err != nil {
return err
}
var extraI int64
switch maj {
case cbg.MajUnsignedInt:
extraI = int64(extra)
if extraI < 0 {
return fmt.Errorf("int64 positive overflow")
}
case cbg.MajNegativeInt:
extraI = int64(extra)
if extraI < 0 {
return fmt.Errorf("int64 negative overflow")
}
extraI = -1 - extraI
default:
return fmt.Errorf("wrong type for int64 field: %d", maj)
}
t.Low = int64(extraI)
}
// t.High (int64) (int64)
case "high":
{
maj, extra, err := cr.ReadHeader()
if err != nil {
return err
}
var extraI int64
switch maj {
case cbg.MajUnsignedInt:
extraI = int64(extra)
if extraI < 0 {
return fmt.Errorf("int64 positive overflow")
}
case cbg.MajNegativeInt:
extraI = int64(extra)
if extraI < 0 {
return fmt.Errorf("int64 negative overflow")
}
extraI = -1 - extraI
default:
return fmt.Errorf("wrong type for int64 field: %d", maj)
}
t.High = int64(extraI)
}
// t.Type (string) (string)
case "$type":
{
sval, err := cbg.ReadStringWithMax(cr, 8192)
if err != nil {
return err
}
t.Type = string(sval)
}
// t.Total (int64) (int64)
case "total":
{
maj, extra, err := cr.ReadHeader()
if err != nil {
return err
}
var extraI int64
switch maj {
case cbg.MajUnsignedInt:
extraI = int64(extra)
if extraI < 0 {
return fmt.Errorf("int64 positive overflow")
}
case cbg.MajNegativeInt:
extraI = int64(extra)
if extraI < 0 {
return fmt.Errorf("int64 negative overflow")
}
extraI = -1 - extraI
default:
return fmt.Errorf("wrong type for int64 field: %d", maj)
}
t.Total = int64(extraI)
}
// t.Medium (int64) (int64)
case "medium":
{
maj, extra, err := cr.ReadHeader()
if err != nil {
return err
}
var extraI int64
switch maj {
case cbg.MajUnsignedInt:
extraI = int64(extra)
if extraI < 0 {
return fmt.Errorf("int64 positive overflow")
}
case cbg.MajNegativeInt:
extraI = int64(extra)
if extraI < 0 {
return fmt.Errorf("int64 negative overflow")
}
extraI = -1 - extraI
default:
return fmt.Errorf("wrong type for int64 field: %d", maj)
}
t.Medium = int64(extraI)
}
// t.UserDID (string) (string)
case "userDid":
{
sval, err := cbg.ReadStringWithMax(cr, 8192)
if err != nil {
return err
}
t.UserDID = string(sval)
}
// t.Critical (int64) (int64)
case "critical":
{
maj, extra, err := cr.ReadHeader()
if err != nil {
return err
}
var extraI int64
switch maj {
case cbg.MajUnsignedInt:
extraI = int64(extra)
if extraI < 0 {
return fmt.Errorf("int64 positive overflow")
}
case cbg.MajNegativeInt:
extraI = int64(extra)
if extraI < 0 {
return fmt.Errorf("int64 negative overflow")
}
extraI = -1 - extraI
default:
return fmt.Errorf("wrong type for int64 field: %d", maj)
}
t.Critical = int64(extraI)
}
// t.Manifest (string) (string)
case "manifest":
{
sval, err := cbg.ReadStringWithMax(cr, 8192)
if err != nil {
return err
}
t.Manifest = string(sval)
}
// t.SbomBlob (util.LexBlob) (struct)
case "sbomBlob":
{
b, err := cr.ReadByte()
if err != nil {
return err
}
if b != cbg.CborNull[0] {
if err := cr.UnreadByte(); err != nil {
return err
}
t.SbomBlob = new(util.LexBlob)
if err := t.SbomBlob.UnmarshalCBOR(cr); err != nil {
return xerrors.Errorf("unmarshaling t.SbomBlob pointer: %w", err)
}
}
}
// t.ScannedAt (string) (string)
case "scannedAt":
{
sval, err := cbg.ReadStringWithMax(cr, 8192)
if err != nil {
return err
}
t.ScannedAt = string(sval)
}
// t.Repository (string) (string)
case "repository":
{
sval, err := cbg.ReadStringWithMax(cr, 8192)
if err != nil {
return err
}
t.Repository = string(sval)
}
// t.ScannerVersion (string) (string)
case "scannerVersion":
{
sval, err := cbg.ReadStringWithMax(cr, 8192)
if err != nil {
return err
}
t.ScannerVersion = string(sval)
}
default:
// Field doesn't exist on this type, so ignore it
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
return err
}
}
}
return nil
}
+1
View File
@@ -32,6 +32,7 @@ func main() {
atproto.LayerRecord{},
atproto.TangledProfileRecord{},
atproto.StatsRecord{},
atproto.ScanRecord{},
); err != nil {
fmt.Printf("Failed to generate CBOR encoders: %v\n", err)
os.Exit(1)
+56 -1
View File
@@ -10,6 +10,8 @@ import (
"fmt"
"strings"
"time"
lexutil "github.com/bluesky-social/indigo/lex/util"
)
// Collection names for ATProto records
@@ -41,6 +43,10 @@ const (
// Stored in hold's embedded PDS to track pull/push counts per owner+repo
StatsCollection = "io.atcr.hold.stats"
// ScanCollection is the collection name for vulnerability scan results
// Stored in hold's embedded PDS to track scan results per manifest
ScanCollection = "io.atcr.hold.scan"
// TangledProfileCollection is the collection name for tangled profiles
// Stored in hold's embedded PDS (singleton record at rkey "self")
TangledProfileCollection = "sh.tangled.actor.profile"
@@ -594,7 +600,7 @@ type CrewRecord struct {
Role string `json:"role" cborgen:"role"`
Permissions []string `json:"permissions" cborgen:"permissions"`
Tier string `json:"tier,omitempty" cborgen:"tier,omitempty"` // Optional tier for quota limits (e.g., 'deckhand', 'bosun', 'quartermaster')
Plankowner bool `json:"plankowner,omitempty" cborgen:"plankowner,omitempty"` // Early adopter flag - gets plankowner_crew_tier for free
Plankowner bool `json:"plankowner,omitempty" cborgen:"plankowner"` // Early adopter flag - gets plankowner_crew_tier for free
AddedAt string `json:"addedAt" cborgen:"addedAt"` // RFC3339 timestamp
}
@@ -675,6 +681,55 @@ func CrewRecordKey(memberDID string) string {
return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(hash[:16]))
}
// ScanRecord represents vulnerability scan results for a manifest
// Collection: io.atcr.hold.scan
// Stored in hold's embedded PDS to track scan results per manifest
// Uses CBOR encoding for efficient storage in hold's carstore
// 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
}
// 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 {
return &ScanRecord{
Type: ScanCollection,
Manifest: BuildManifestURI(userDID, manifestDigest),
Repository: repository,
UserDID: userDID,
SbomBlob: sbomBlob,
Critical: int64(critical),
High: int64(high),
Medium: int64(medium),
Low: int64(low),
Total: int64(total),
ScannerVersion: scannerVersion,
ScannedAt: time.Now().Format(time.RFC3339),
}
}
// ScanRecordKey generates a deterministic record key for a scan result
// Uses the manifest digest (without algorithm prefix) as the rkey
// This ensures one scan record per manifest, and re-scans upsert the record
func ScanRecordKey(manifestDigest string) string {
// Remove the "sha256:" prefix - the hex digest is already a valid rkey
return strings.TrimPrefix(manifestDigest, "sha256:")
}
// TangledProfileRecord represents a Tangled profile for the hold
// Collection: sh.tangled.actor.profile (singleton record at rkey "self")
// Stored in the hold's embedded PDS
+59
View File
@@ -0,0 +1,59 @@
package pds
import (
"context"
"fmt"
"atcr.io/pkg/atproto"
"github.com/ipfs/go-cid"
)
// CreateScanRecord creates or updates a scan result record in the hold's PDS
// Uses a deterministic rkey based on the manifest digest, so re-scans upsert
func (p *HoldPDS) CreateScanRecord(ctx context.Context, record *atproto.ScanRecord) (string, cid.Cid, error) {
if record.Type != atproto.ScanCollection {
return "", cid.Undef, fmt.Errorf("invalid record type: %s", record.Type)
}
if record.Manifest == "" {
return "", cid.Undef, fmt.Errorf("manifest AT-URI is required")
}
// Extract the digest from the manifest AT-URI to use as rkey
manifestDigest, err := atproto.ParseManifestURI(record.Manifest)
if err != nil {
return "", cid.Undef, fmt.Errorf("invalid manifest AT-URI: %w", err)
}
rkey := atproto.ScanRecordKey(manifestDigest)
// Upsert: re-scans update the existing record
rpath, recordCID, _, err := p.repomgr.UpsertRecord(
ctx,
p.uid,
atproto.ScanCollection,
rkey,
record,
)
if err != nil {
return "", cid.Undef, fmt.Errorf("failed to upsert scan record: %w", err)
}
return rpath, recordCID, nil
}
// GetScanRecord retrieves a scan result record by manifest digest
func (p *HoldPDS) GetScanRecord(ctx context.Context, manifestDigest string) (cid.Cid, *atproto.ScanRecord, error) {
rkey := atproto.ScanRecordKey(manifestDigest)
recordCID, val, err := p.repomgr.GetRecord(ctx, p.uid, atproto.ScanCollection, rkey, cid.Undef)
if err != nil {
return cid.Undef, nil, fmt.Errorf("failed to get scan record: %w", err)
}
scanRecord, ok := val.(*atproto.ScanRecord)
if !ok {
return cid.Undef, nil, fmt.Errorf("unexpected type for scan record: %T", val)
}
return recordCID, scanRecord, nil
}
+29 -117
View File
@@ -3,7 +3,6 @@ package pds
import (
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
@@ -12,6 +11,8 @@ import (
"sync"
"time"
"atcr.io/pkg/atproto"
lexutil "github.com/bluesky-social/indigo/lex/util"
storagedriver "github.com/distribution/distribution/v3/registry/storage/driver"
"github.com/gorilla/websocket"
)
@@ -353,7 +354,7 @@ func (sb *ScanBroadcaster) handleAck(sub *ScanSubscriber, seq int64) {
"subscriberId", sub.id)
}
// handleResult processes a completed scan result: stores ORAS manifest + marks completed
// handleResult processes a completed scan result: uploads SBOM blob + stores scan record in PDS
func (sb *ScanBroadcaster) handleResult(sub *ScanSubscriber, msg ScannerMessage) {
ctx := context.Background()
@@ -382,36 +383,40 @@ func (sb *ScanBroadcaster) handleResult(sub *ScanSubscriber, msg ScannerMessage)
return
}
// Store vulnerability report blob in S3
if msg.VulnReport != "" {
vulnJSON := []byte(msg.VulnReport)
vulnDigest := fmt.Sprintf("sha256:%x", sha256.Sum256(vulnJSON))
if err := sb.uploadBlob(ctx, vulnDigest, vulnJSON); err != nil {
slog.Error("Failed to upload vulnerability report blob",
// Upload SBOM as a blob to the hold's PDS blob storage (like manifest blobs)
var sbomBlob *lexutil.LexBlob
if msg.SBOM != "" {
blob, err := uploadBlobToStorage(ctx, sb.driver, sb.holdDID, []byte(msg.SBOM), "application/spdx+json")
if err != nil {
slog.Error("Failed to upload SBOM blob to PDS storage",
"seq", msg.Seq,
"error", err)
}
// Build and store ORAS manifest
if msg.Summary != nil {
if err := sb.storeORASManifest(ctx, manifestDigest, repository, userDID, vulnDigest, vulnJSON, *msg.Summary); err != nil {
slog.Error("Failed to store ORAS manifest",
"seq", msg.Seq,
"error", err)
}
} else {
sbomBlob = blob
}
}
// Store SBOM blob if provided
if msg.SBOM != "" {
sbomJSON := []byte(msg.SBOM)
sbomDigest := fmt.Sprintf("sha256:%x", sha256.Sum256(sbomJSON))
// Store scan result as a record in the hold's embedded PDS
if msg.Summary != nil {
scanRecord := atproto.NewScanRecord(
manifestDigest, repository, userDID,
sbomBlob,
msg.Summary.Critical, msg.Summary.High, msg.Summary.Medium, msg.Summary.Low, msg.Summary.Total,
"atcr-scanner-v1.0.0",
)
if err := sb.uploadBlob(ctx, sbomDigest, sbomJSON); err != nil {
slog.Error("Failed to upload SBOM blob",
rpath, _, err := sb.pds.CreateScanRecord(ctx, scanRecord)
if err != nil {
slog.Error("Failed to store scan record in PDS",
"seq", msg.Seq,
"error", err)
} else {
slog.Info("Scan record stored in PDS",
"rpath", rpath,
"manifest", scanRecord.Manifest,
"critical", msg.Summary.Critical,
"high", msg.Summary.High,
"total", msg.Summary.Total)
}
}
@@ -584,99 +589,6 @@ func (sb *ScanBroadcaster) ValidateScannerSecret(secret string) bool {
return sb.secret != "" && secret == sb.secret
}
// storeORASManifest creates an ORAS vulnerability manifest as a blob in S3
// The ORAS manifest's "subject" field references the original manifest by digest,
// enabling OCI referrers API discovery.
func (sb *ScanBroadcaster) storeORASManifest(ctx context.Context, manifestDigest, repository, userDID, vulnDigest string, vulnJSON []byte, summary VulnerabilitySummary) error {
scannerVersion := "atcr-scanner-v1.0.0"
// Create ORAS manifest
orasManifest := map[string]interface{}{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"artifactType": "application/vnd.atcr.vulnerabilities+json",
"config": map[string]interface{}{
"mediaType": "application/vnd.oci.empty.v1+json",
"digest": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
"size": 2,
},
"subject": map[string]interface{}{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": manifestDigest,
"size": 0,
},
"layers": []map[string]interface{}{
{
"mediaType": "application/json",
"digest": vulnDigest,
"size": len(vulnJSON),
"annotations": map[string]string{
"org.opencontainers.image.title": "vulnerability-report.json",
},
},
},
"annotations": map[string]string{
"io.atcr.vuln.critical": fmt.Sprintf("%d", summary.Critical),
"io.atcr.vuln.high": fmt.Sprintf("%d", summary.High),
"io.atcr.vuln.medium": fmt.Sprintf("%d", summary.Medium),
"io.atcr.vuln.low": fmt.Sprintf("%d", summary.Low),
"io.atcr.vuln.total": fmt.Sprintf("%d", summary.Total),
"io.atcr.vuln.scannedAt": time.Now().Format(time.RFC3339),
"io.atcr.vuln.scannerVersion": scannerVersion,
"io.atcr.vuln.repository": repository,
"io.atcr.vuln.ownerDid": userDID,
"io.atcr.vuln.holdDid": sb.holdDID,
},
}
orasManifestJSON, err := json.Marshal(orasManifest)
if err != nil {
return fmt.Errorf("failed to encode ORAS manifest: %w", err)
}
orasHash := sha256.Sum256(orasManifestJSON)
orasDigest := fmt.Sprintf("sha256:%x", orasHash)
// Upload ORAS manifest blob to S3
if err := sb.uploadBlob(ctx, orasDigest, orasManifestJSON); err != nil {
return fmt.Errorf("failed to upload ORAS manifest blob: %w", err)
}
slog.Info("ORAS manifest stored",
"digest", orasDigest,
"repository", repository,
"userDid", userDID,
"critical", summary.Critical,
"high", summary.High,
"total", summary.Total)
return nil
}
// uploadBlob uploads a blob to S3 storage
func (sb *ScanBroadcaster) uploadBlob(ctx context.Context, digest string, data []byte) error {
digestHex := digest[len("sha256:"):]
if len(digestHex) < 2 {
return fmt.Errorf("invalid digest: %s", digest)
}
blobPath := fmt.Sprintf("/docker/registry/v2/blobs/sha256/%s/%s/data",
digestHex[:2], digestHex)
writer, err := sb.driver.Writer(ctx, blobPath, false)
if err != nil {
return fmt.Errorf("failed to create storage writer: %w", err)
}
defer writer.Close()
if _, err := writer.Write(data); err != nil {
writer.Cancel(ctx)
return fmt.Errorf("failed to write blob data: %w", err)
}
return writer.Commit(ctx)
}
func generateSubscriberID() string {
b := make([]byte, 8)
rand.Read(b)
+1
View File
@@ -29,6 +29,7 @@ func init() {
lexutil.RegisterType(atproto.LayerCollection, &atproto.LayerRecord{})
lexutil.RegisterType(atproto.TangledProfileCollection, &atproto.TangledProfileRecord{})
lexutil.RegisterType(atproto.StatsCollection, &atproto.StatsRecord{})
lexutil.RegisterType(atproto.ScanCollection, &atproto.ScanRecord{})
}
// HoldPDS is a minimal ATProto PDS implementation for a hold service