A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

add scan reports to hold pds

+864 -191
+136 -72
CLAUDE.md
··· 6 6 7 7 ATCR (ATProto Container Registry) is an OCI-compliant container registry that uses the AT Protocol for manifest storage and S3 for blob storage. This creates a decentralized container registry where manifests are stored in users' Personal Data Servers (PDS) while layers are stored in S3. 8 8 9 + ## Go Workspace 10 + 11 + The project uses a Go workspace (`go.work`) with two modules: 12 + - `atcr.io` — Main module (appview, hold, credential-helper, oauth-helper) 13 + - `atcr.io/scanner` — Scanner module (separate to isolate heavy Syft/Grype dependencies) 14 + 9 15 ## Build Commands 16 + 17 + Always build into the `bin/` directory (`-o bin/...`), not the project root. 10 18 11 19 ```bash 12 - # Build all binaries 13 - # create go builds in the bin/ directory 20 + # Build main binaries 14 21 go build -o bin/atcr-appview ./cmd/appview 15 22 go build -o bin/atcr-hold ./cmd/hold 16 23 go build -o bin/docker-credential-atcr ./cmd/credential-helper 17 24 go build -o bin/oauth-helper ./cmd/oauth-helper 25 + 26 + # Build scanner (separate module) 27 + cd scanner && go build -o ../bin/atcr-scanner ./cmd/scanner && cd .. 28 + 29 + # Build hold with billing support (optional, uses build tag) 30 + go build -tags billing -o bin/atcr-hold ./cmd/hold 18 31 19 32 # Run tests 20 33 go test ./... ··· 38 51 # Build Docker images 39 52 docker build -t atcr.io/appview:latest . 40 53 docker build -f Dockerfile.hold -t atcr.io/hold:latest . 54 + docker build -f Dockerfile.scanner -t atcr.io/scanner:latest . 41 55 42 56 # Or use docker-compose 43 57 docker-compose up -d 44 58 45 - # Run locally (AppView) - configure via env vars (see .env.appview.example) 46 - export ATCR_HTTP_ADDR=:5000 47 - export ATCR_DEFAULT_HOLD=http://127.0.0.1:8080 48 - ./bin/atcr-appview serve 59 + # Generate default config files 60 + ./bin/atcr-appview config init config-appview.yaml 61 + ./bin/atcr-hold config init config-hold.yaml 49 62 50 - # Or use .env file: 51 - cp .env.appview.example .env.appview 52 - # Edit .env.appview with your settings 53 - source .env.appview 54 - ./bin/atcr-appview serve 63 + # Run locally (AppView) - YAML config (preferred) 64 + ./bin/atcr-appview serve --config config-appview.yaml 65 + # Or env vars only (still works): 66 + ATCR_SERVER_DEFAULT_HOLD_DID=did:web:hold01.atcr.io ./bin/atcr-appview serve 55 67 56 - # Legacy mode (still supported): 57 - # ./bin/atcr-appview serve config/config.yml 58 - 59 - # Run hold service (configure via env vars - see .env.hold.example) 68 + # Run hold service - YAML config (preferred) 60 69 # For local development, use Minio as S3-compatible storage: 61 70 # docker run -p 9000:9000 minio/minio server /data 62 - export HOLD_PUBLIC_URL=http://127.0.0.1:8080 63 - export AWS_ACCESS_KEY_ID=minioadmin 64 - export AWS_SECRET_ACCESS_KEY=minioadmin 65 - export S3_BUCKET=test 66 - export S3_ENDPOINT=http://localhost:9000 67 - export HOLD_OWNER=did:plc:your-did-here 68 - ./bin/atcr-hold 69 - # Hold starts immediately with embedded PDS 71 + ./bin/atcr-hold serve --config config-hold.yaml 72 + # Or env vars only: 73 + HOLD_SERVER_PUBLIC_URL=http://127.0.0.1:8080 S3_BUCKET=test ./bin/atcr-hold serve 74 + 75 + # Run scanner service (env vars only, no YAML) 76 + SCANNER_HOLD_URL=ws://localhost:8080 SCANNER_SHARED_SECRET=secret ./bin/atcr-scanner serve 77 + 78 + # Usage report tool 79 + go run ./cmd/usage-report --hold https://hold01.atcr.io 80 + go run ./cmd/usage-report --hold https://hold01.atcr.io --from-manifests 70 81 71 82 # Request Bluesky relay crawl (makes your PDS discoverable) 72 83 ./deploy/request-crawl.sh hold01.atcr.io 73 - # Or specify a different relay: 74 - ./deploy/request-crawl.sh hold01.atcr.io https://custom-relay.example.com/xrpc/com.atproto.sync.requestCrawl 75 84 ``` 76 85 77 86 ## Architecture Overview ··· 84 93 - **Blobs/Layers** → S3 or user-deployed storage (large binary data) 85 94 - **Authentication** → ATProto OAuth with DPoP + Docker credential helpers 86 95 87 - ### Three-Component Architecture 96 + ### Four-Component Architecture 88 97 89 98 1. **AppView** (`cmd/appview`) - OCI Distribution API server 90 99 - Resolves identities (handle/DID → PDS endpoint) ··· 99 108 - Supports S3-compatible storage (AWS S3, Storj, Minio, UpCloud, etc.) 100 109 - Authorization based on captain record (public, allowAllCrew) 101 110 - Self-describing via DID resolution 102 - - Configured entirely via environment variables 111 + - Optional subsystems: admin UI, quota enforcement, billing (Stripe), garbage collection 112 + - Dispatches scan jobs to scanner instances via WebSocket 103 113 104 - 3. **Credential Helper** (`cmd/credential-helper`) - Client-side OAuth 114 + 3. **Scanner** (`scanner/cmd/scanner`) - Vulnerability scanning service 115 + - Separate Go module (heavy Syft/Grype dependencies isolated) 116 + - Connects to hold service via WebSocket (`/xrpc/io.atcr.hold.subscribeScanJobs`) 117 + - Generates SBOMs with Syft, scans for vulnerabilities with Grype 118 + - Priority queue with tier-based scheduling (owner > quartermaster > bosun > deckhand) 119 + - Competing-consumer pattern: multiple scanners pull from same hold 120 + 121 + 4. **Credential Helper** (`cmd/credential-helper`) - Client-side OAuth 105 122 - Implements Docker credential helper protocol 106 123 - ATProto OAuth flow with DPoP 107 124 - Token caching and refresh ··· 559 576 - Hold validates tokens and checks crew membership for authorization 560 577 - Tokens cached for 50 seconds (valid for 60 seconds from PDS) 561 578 562 - **Configuration:** Environment variables (see `.env.hold.example`) 563 - - `HOLD_PUBLIC_URL` - Public URL of hold service (required, used for did:web generation) 564 - - `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` - S3 credentials (required) 565 - - `S3_BUCKET` - S3 bucket name (required) 566 - - `S3_ENDPOINT` - S3 endpoint URL (for non-AWS providers like Storj, Minio, UpCloud) 567 - - `HOLD_PUBLIC` - Allow public reads (default: false) 568 - - `HOLD_OWNER` - DID for captain record creation (optional) 569 - - `HOLD_ALLOW_ALL_CREW` - Allow any authenticated user to register as crew (default: false) 570 - - `HOLD_DATABASE_DIR` - Directory for embedded PDS database (required) 571 - - `HOLD_KEY_PATH` - Path for PDS signing keys (optional, generated if missing) 579 + **Hold Subsystems** (`pkg/hold/`): 580 + - **Admin UI** (`admin/`) - Web-based admin panel for crew and storage management (enabled via `admin.enabled: true`) 581 + - **Quota** (`quota/`) - Per-user storage quota enforcement with tier-based limits (configured in YAML under `quota:`) 582 + - **Billing** (`billing/`) - Stripe integration for paid tiers (build tag `billing`, compile with `-tags billing`). Zero overhead when disabled. 583 + - **Garbage Collection** (`gc/`) - Blob garbage collection for orphaned data 584 + - **Scan Broadcaster** (`pds/scan_broadcaster.go`) - WebSocket server dispatching scan jobs to scanner instances via `/xrpc/io.atcr.hold.subscribeScanJobs` 572 585 573 586 **Deployment:** Can run on Fly.io, Railway, Docker, Kubernetes, etc. 587 + 588 + #### Scanner Service (`scanner/`) 589 + 590 + Separate Go module for vulnerability scanning. Connects to hold services via WebSocket. 591 + 592 + **Architecture:** 593 + - `scanner/internal/client/hold.go` - WebSocket client with auto-reconnect (exponential backoff) 594 + - `scanner/internal/queue/priority_queue.go` - Thread-safe priority queue (tier-based: owner > quartermaster > bosun > deckhand) 595 + - `scanner/internal/scan/worker.go` - Configurable worker pool 596 + - `scanner/internal/scan/syft.go` - SBOM generation via Syft 597 + - `scanner/internal/scan/grype.go` - Vulnerability scanning via Grype 598 + - `scanner/internal/scan/extractor.go` - Container layer extraction 599 + - `scanner/internal/config/config.go` - Environment-only config (no YAML, no Viper) 600 + 601 + **Scanner env vars** (prefix `SCANNER_`): 602 + - `SCANNER_HOLD_URL` - WebSocket URL of hold service (required) 603 + - `SCANNER_SHARED_SECRET` - Authentication secret (required) 604 + - `SCANNER_WORKERS` - Number of concurrent scan workers 605 + - `SCANNER_VULN_ENABLED` - Enable vulnerability scanning (default: true) 606 + - `SCANNER_ADDR` - Health endpoint address (default: `:9090`) 607 + 608 + #### Usage Report Tool (`cmd/usage-report/`) 609 + 610 + CLI tool for analyzing hold storage usage: 611 + ```bash 612 + go run ./cmd/usage-report --hold https://hold01.atcr.io # summary 613 + go run ./cmd/usage-report --hold https://hold01.atcr.io --from-manifests # from manifests 614 + go run ./cmd/usage-report --hold https://hold01.atcr.io --list-blobs # individual blobs 615 + ``` 574 616 575 617 ### ATProto Storage Model 576 618 ··· 657 699 658 700 ### Configuration 659 701 660 - **AppView configuration** (environment variables): 702 + ATCR uses **Viper** for configuration. YAML files are the primary method; environment variables work as overrides. 661 703 662 - Both AppView and Hold service follow the same pattern: **zero config files, all configuration via environment variables**. 704 + **Loading priority** (highest wins): 705 + 1. Environment variables (always override YAML) 706 + 2. YAML config file (via `--config` / `-c` flag) 707 + 3. Hardcoded defaults 663 708 664 - See `.env.appview.example` for all available options. Key environment variables: 709 + **Generating config files:** 710 + ```bash 711 + ./bin/atcr-appview config init config-appview.yaml # fully-commented YAML with defaults 712 + ./bin/atcr-hold config init config-hold.yaml 713 + ``` 665 714 666 - **Server:** 667 - - `ATCR_HTTP_ADDR` - HTTP listen address (default: `:5000`) 668 - - `ATCR_BASE_URL` - Public URL for OAuth/JWT realm (auto-detected in dev) 669 - - `ATCR_DEFAULT_HOLD_DID` - Default hold DID for blob storage (REQUIRED, e.g., `did:web:hold01.atcr.io`) 715 + **Env var naming convention:** Prefix + YAML path with `_` separators: 716 + - AppView prefix: `ATCR_` — e.g., `server.default_hold_did` → `ATCR_SERVER_DEFAULT_HOLD_DID` 717 + - Hold prefix: `HOLD_` — e.g., `server.public_url` → `HOLD_SERVER_PUBLIC_URL` 718 + - S3 uses standard AWS names: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `S3_BUCKET`, `S3_ENDPOINT` 670 719 671 - **Authentication:** 672 - - `ATCR_AUTH_KEY_PATH` - JWT signing key path (default: `/var/lib/atcr/auth/private-key.pem`) 720 + **AppView config** (see `config-appview.example.yaml`): 721 + - `server.addr` - Listen address (default: `:5000`) 722 + - `server.base_url` - Public URL for OAuth/JWT realm (auto-detected in dev) 723 + - `server.default_hold_did` - Default hold DID for blob storage (REQUIRED) 724 + - `server.oauth_key_path` - P-256 key for OAuth client auth (auto-generated) 725 + - `server.registry_domain` - Separate domain for OCI API (e.g., `buoy.cr`) 726 + - `auth.key_path` - RSA key for signing registry JWTs 727 + - `ui.database_path` - SQLite database path 728 + - `jetstream.url` - ATProto firehose endpoint 729 + - `jetstream.backfill_enabled` - Sync existing records on startup 730 + - `log_shipper` - Remote log shipping (victoria, opensearch, loki) 673 731 674 - **UI:** 675 - - `ATCR_UI_DATABASE_PATH` - SQLite database path (default: `/var/lib/atcr/ui.db`) 732 + **Hold config** (see `config-hold.example.yaml`): 733 + - `server.public_url` - Externally reachable URL for did:web identity (REQUIRED) 734 + - `server.public` - Allow unauthenticated reads (default: false) 735 + - `storage.bucket` - S3 bucket (REQUIRED) 736 + - `storage.endpoint` - Custom S3 endpoint for non-AWS providers 737 + - `registration.owner_did` - DID for captain record auto-creation 738 + - `registration.allow_all_crew` - Allow any authenticated user to join 739 + - `database.path` - Embedded PDS database directory 740 + - `admin.enabled` - Enable web admin panel 741 + - `quota.tiers` - Storage quota tiers (e.g., `deckhand: {quota: "5GB"}`) 742 + - `quota.defaults.new_crew_tier` - Default tier for new crew members 676 743 677 - **Jetstream:** 678 - - `JETSTREAM_URL` - ATProto event stream URL 679 - - `ATCR_BACKFILL_ENABLED` - Enable periodic sync (default: false) 744 + **Hold billing config** (requires `-tags billing` build): 745 + - `billing.enabled` - Enable Stripe billing 746 + - `billing.currency` - ISO currency code 747 + - `billing.tiers` - Map of tier names to Stripe price IDs 748 + - Env vars: `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` 680 749 681 - **Legacy:** `config/config.yml` is still supported but deprecated. Use environment variables instead. 682 - 683 - **Hold Service configuration** (environment variables): 684 - 685 - See `.env.hold.example` for all available options. Key environment variables: 686 - - `HOLD_PUBLIC_URL` - Public URL of hold service (REQUIRED) 687 - - `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` - S3 credentials (REQUIRED) 688 - - `S3_BUCKET` - S3 bucket name (REQUIRED) 689 - - `S3_ENDPOINT` - S3 endpoint URL (for non-AWS providers) 690 - - `HOLD_PUBLIC` - Allow public reads (default: false) 691 - - `HOLD_OWNER` - DID for captain record creation (optional) 692 - - `HOLD_ALLOW_ALL_CREW` - Allow any authenticated user to register as crew (default: false) 750 + **Scanner config** (env vars only, no YAML/Viper): 751 + - `SCANNER_HOLD_URL`, `SCANNER_SHARED_SECRET`, `SCANNER_WORKERS`, `SCANNER_ADDR` 693 752 694 753 **Credential Helper**: 695 754 - Token storage: `~/.atcr/credential-helper-token.json` (or Docker's credential store) ··· 706 765 - Storage drivers imported as `_ "github.com/distribution/distribution/v3/registry/storage/driver/s3-aws"` 707 766 - Hold service reuses distribution's driver factory for multi-backend support 708 767 768 + **Configuration system:** 769 + - Config loading uses Viper (`pkg/config/viper.go`) — YAML primary, env vars override 770 + - Config structs use `comment` struct tags for auto-generating commented YAML via `MarshalCommentedYAML()` (`pkg/config/marshal.go`) 771 + - AppView config: `pkg/appview/config.go` (prefix `ATCR_`) 772 + - Hold config: `pkg/hold/config.go` (prefix `HOLD_`, plus standard `AWS_*`/`S3_*` bindings) 773 + - Quota/billing configs are subsections of the hold YAML file, loaded by passing the config path 774 + - Scanner config is env-only (no Viper): `scanner/internal/config/config.go` 775 + 709 776 **OAuth implementation:** 710 777 - Client (`pkg/auth/oauth/client.go`) encapsulates all OAuth configuration 711 778 - Token validation via `com.atproto.server.getSession` ensures no trust in client-provided identity ··· 746 813 - Client methods are consistent across authorization, token exchange, and refresh flows 747 814 748 815 **Adding BYOS support for a user**: 749 - 1. User sets environment variables (storage credentials, public URL, HOLD_OWNER) 816 + 1. User configures hold YAML (storage credentials, public URL, owner DID) 750 817 2. User runs hold service - creates captain + crew records in embedded PDS 751 818 3. Hold creates `io.atcr.hold.captain` + `io.atcr.hold.crew` records 752 819 4. User sets sailor profile `defaultHold` to point to their hold ··· 754 821 6. No AppView changes needed - fully decentralized 755 822 756 823 **Using S3-compatible storage**: 757 - ATCR requires S3-compatible storage. Supported providers: 758 - - AWS S3 - Set `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `S3_BUCKET` 759 - - Storj - Set `S3_ENDPOINT=https://gateway.storjshare.io` 760 - - Minio - Set `S3_ENDPOINT=http://localhost:9000` 761 - - UpCloud - Set `S3_ENDPOINT=https://[bucket-id].upcloudobjects.com` 762 - - Azure/GCS - Use their S3-compatible API endpoints 824 + ATCR requires S3-compatible storage. Configure in hold YAML under `storage:` or via env vars. 825 + Supported providers: AWS S3, Storj (`storage.endpoint: https://gateway.storjshare.io`), 826 + Minio (`storage.endpoint: http://localhost:9000`), UpCloud, Azure/GCS (S3-compatible endpoints). 763 827 764 828 **Working with the database**: 765 829 - **Base schema** defined in `pkg/appview/db/schema.sql` - source of truth for fresh installations ··· 767 831 - **Queries** in `pkg/appview/db/queries.go` 768 832 - **Stores** for OAuth, devices, sessions in separate files 769 833 - **Execution order**: schema.sql first, then migrations (automatically on startup) 770 - - **Database path** configurable via `ATCR_UI_DATABASE_PATH` env var 834 + - **Database path** configurable via `ui.database_path` in YAML (or `ATCR_UI_DATABASE_PATH` env var) 771 835 - **Adding new tables**: Add to `schema.sql` only (no migration needed) 772 836 - **Altering tables**: Create migration AND update `schema.sql` to keep them in sync 773 837
+582 -1
pkg/atproto/cbor_gen.go
··· 8 8 "math" 9 9 "sort" 10 10 11 + util "github.com/bluesky-social/indigo/lex/util" 11 12 cid "github.com/ipfs/go-cid" 12 13 cbg "github.com/whyrusleeping/cbor-gen" 13 14 xerrors "golang.org/x/xerrors" ··· 25 26 } 26 27 27 28 cw := cbg.NewCborWriter(w) 28 - fieldCount := 6 29 + fieldCount := 7 29 30 30 31 if t.Tier == "" { 31 32 fieldCount-- ··· 150 151 return err 151 152 } 152 153 if _, err := cw.WriteString(string(t.AddedAt)); err != nil { 154 + return err 155 + } 156 + 157 + // t.Plankowner (bool) (bool) 158 + if len("plankowner") > 8192 { 159 + return xerrors.Errorf("Value in field \"plankowner\" was too long") 160 + } 161 + 162 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("plankowner"))); err != nil { 163 + return err 164 + } 165 + if _, err := cw.WriteString(string("plankowner")); err != nil { 166 + return err 167 + } 168 + 169 + if err := cbg.WriteBool(w, t.Plankowner); err != nil { 153 170 return err 154 171 } 155 172 ··· 283 300 } 284 301 285 302 t.AddedAt = string(sval) 303 + } 304 + // t.Plankowner (bool) (bool) 305 + case "plankowner": 306 + 307 + maj, extra, err = cr.ReadHeader() 308 + if err != nil { 309 + return err 310 + } 311 + if maj != cbg.MajOther { 312 + return fmt.Errorf("booleans must be major type 7") 313 + } 314 + switch extra { 315 + case 20: 316 + t.Plankowner = false 317 + case 21: 318 + t.Plankowner = true 319 + default: 320 + return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra) 286 321 } 287 322 // t.Permissions ([]string) (slice) 288 323 case "permissions": ··· 1767 1802 1768 1803 return nil 1769 1804 } 1805 + func (t *ScanRecord) MarshalCBOR(w io.Writer) error { 1806 + if t == nil { 1807 + _, err := w.Write(cbg.CborNull) 1808 + return err 1809 + } 1810 + 1811 + cw := cbg.NewCborWriter(w) 1812 + 1813 + if _, err := cw.Write([]byte{172}); err != nil { 1814 + return err 1815 + } 1816 + 1817 + // t.Low (int64) (int64) 1818 + if len("low") > 8192 { 1819 + return xerrors.Errorf("Value in field \"low\" was too long") 1820 + } 1821 + 1822 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("low"))); err != nil { 1823 + return err 1824 + } 1825 + if _, err := cw.WriteString(string("low")); err != nil { 1826 + return err 1827 + } 1828 + 1829 + if t.Low >= 0 { 1830 + if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.Low)); err != nil { 1831 + return err 1832 + } 1833 + } else { 1834 + if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.Low-1)); err != nil { 1835 + return err 1836 + } 1837 + } 1838 + 1839 + // t.High (int64) (int64) 1840 + if len("high") > 8192 { 1841 + return xerrors.Errorf("Value in field \"high\" was too long") 1842 + } 1843 + 1844 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("high"))); err != nil { 1845 + return err 1846 + } 1847 + if _, err := cw.WriteString(string("high")); err != nil { 1848 + return err 1849 + } 1850 + 1851 + if t.High >= 0 { 1852 + if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.High)); err != nil { 1853 + return err 1854 + } 1855 + } else { 1856 + if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.High-1)); err != nil { 1857 + return err 1858 + } 1859 + } 1860 + 1861 + // t.Type (string) (string) 1862 + if len("$type") > 8192 { 1863 + return xerrors.Errorf("Value in field \"$type\" was too long") 1864 + } 1865 + 1866 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 1867 + return err 1868 + } 1869 + if _, err := cw.WriteString(string("$type")); err != nil { 1870 + return err 1871 + } 1872 + 1873 + if len(t.Type) > 8192 { 1874 + return xerrors.Errorf("Value in field t.Type was too long") 1875 + } 1876 + 1877 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Type))); err != nil { 1878 + return err 1879 + } 1880 + if _, err := cw.WriteString(string(t.Type)); err != nil { 1881 + return err 1882 + } 1883 + 1884 + // t.Total (int64) (int64) 1885 + if len("total") > 8192 { 1886 + return xerrors.Errorf("Value in field \"total\" was too long") 1887 + } 1888 + 1889 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("total"))); err != nil { 1890 + return err 1891 + } 1892 + if _, err := cw.WriteString(string("total")); err != nil { 1893 + return err 1894 + } 1895 + 1896 + if t.Total >= 0 { 1897 + if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.Total)); err != nil { 1898 + return err 1899 + } 1900 + } else { 1901 + if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.Total-1)); err != nil { 1902 + return err 1903 + } 1904 + } 1905 + 1906 + // t.Medium (int64) (int64) 1907 + if len("medium") > 8192 { 1908 + return xerrors.Errorf("Value in field \"medium\" was too long") 1909 + } 1910 + 1911 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("medium"))); err != nil { 1912 + return err 1913 + } 1914 + if _, err := cw.WriteString(string("medium")); err != nil { 1915 + return err 1916 + } 1917 + 1918 + if t.Medium >= 0 { 1919 + if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.Medium)); err != nil { 1920 + return err 1921 + } 1922 + } else { 1923 + if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.Medium-1)); err != nil { 1924 + return err 1925 + } 1926 + } 1927 + 1928 + // t.UserDID (string) (string) 1929 + if len("userDid") > 8192 { 1930 + return xerrors.Errorf("Value in field \"userDid\" was too long") 1931 + } 1932 + 1933 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("userDid"))); err != nil { 1934 + return err 1935 + } 1936 + if _, err := cw.WriteString(string("userDid")); err != nil { 1937 + return err 1938 + } 1939 + 1940 + if len(t.UserDID) > 8192 { 1941 + return xerrors.Errorf("Value in field t.UserDID was too long") 1942 + } 1943 + 1944 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.UserDID))); err != nil { 1945 + return err 1946 + } 1947 + if _, err := cw.WriteString(string(t.UserDID)); err != nil { 1948 + return err 1949 + } 1950 + 1951 + // t.Critical (int64) (int64) 1952 + if len("critical") > 8192 { 1953 + return xerrors.Errorf("Value in field \"critical\" was too long") 1954 + } 1955 + 1956 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("critical"))); err != nil { 1957 + return err 1958 + } 1959 + if _, err := cw.WriteString(string("critical")); err != nil { 1960 + return err 1961 + } 1962 + 1963 + if t.Critical >= 0 { 1964 + if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.Critical)); err != nil { 1965 + return err 1966 + } 1967 + } else { 1968 + if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.Critical-1)); err != nil { 1969 + return err 1970 + } 1971 + } 1972 + 1973 + // t.Manifest (string) (string) 1974 + if len("manifest") > 8192 { 1975 + return xerrors.Errorf("Value in field \"manifest\" was too long") 1976 + } 1977 + 1978 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("manifest"))); err != nil { 1979 + return err 1980 + } 1981 + if _, err := cw.WriteString(string("manifest")); err != nil { 1982 + return err 1983 + } 1984 + 1985 + if len(t.Manifest) > 8192 { 1986 + return xerrors.Errorf("Value in field t.Manifest was too long") 1987 + } 1988 + 1989 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Manifest))); err != nil { 1990 + return err 1991 + } 1992 + if _, err := cw.WriteString(string(t.Manifest)); err != nil { 1993 + return err 1994 + } 1995 + 1996 + // t.SbomBlob (util.LexBlob) (struct) 1997 + if len("sbomBlob") > 8192 { 1998 + return xerrors.Errorf("Value in field \"sbomBlob\" was too long") 1999 + } 2000 + 2001 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sbomBlob"))); err != nil { 2002 + return err 2003 + } 2004 + if _, err := cw.WriteString(string("sbomBlob")); err != nil { 2005 + return err 2006 + } 2007 + 2008 + if err := t.SbomBlob.MarshalCBOR(cw); err != nil { 2009 + return err 2010 + } 2011 + 2012 + // t.ScannedAt (string) (string) 2013 + if len("scannedAt") > 8192 { 2014 + return xerrors.Errorf("Value in field \"scannedAt\" was too long") 2015 + } 2016 + 2017 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("scannedAt"))); err != nil { 2018 + return err 2019 + } 2020 + if _, err := cw.WriteString(string("scannedAt")); err != nil { 2021 + return err 2022 + } 2023 + 2024 + if len(t.ScannedAt) > 8192 { 2025 + return xerrors.Errorf("Value in field t.ScannedAt was too long") 2026 + } 2027 + 2028 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.ScannedAt))); err != nil { 2029 + return err 2030 + } 2031 + if _, err := cw.WriteString(string(t.ScannedAt)); err != nil { 2032 + return err 2033 + } 2034 + 2035 + // t.Repository (string) (string) 2036 + if len("repository") > 8192 { 2037 + return xerrors.Errorf("Value in field \"repository\" was too long") 2038 + } 2039 + 2040 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repository"))); err != nil { 2041 + return err 2042 + } 2043 + if _, err := cw.WriteString(string("repository")); err != nil { 2044 + return err 2045 + } 2046 + 2047 + if len(t.Repository) > 8192 { 2048 + return xerrors.Errorf("Value in field t.Repository was too long") 2049 + } 2050 + 2051 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Repository))); err != nil { 2052 + return err 2053 + } 2054 + if _, err := cw.WriteString(string(t.Repository)); err != nil { 2055 + return err 2056 + } 2057 + 2058 + // t.ScannerVersion (string) (string) 2059 + if len("scannerVersion") > 8192 { 2060 + return xerrors.Errorf("Value in field \"scannerVersion\" was too long") 2061 + } 2062 + 2063 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("scannerVersion"))); err != nil { 2064 + return err 2065 + } 2066 + if _, err := cw.WriteString(string("scannerVersion")); err != nil { 2067 + return err 2068 + } 2069 + 2070 + if len(t.ScannerVersion) > 8192 { 2071 + return xerrors.Errorf("Value in field t.ScannerVersion was too long") 2072 + } 2073 + 2074 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.ScannerVersion))); err != nil { 2075 + return err 2076 + } 2077 + if _, err := cw.WriteString(string(t.ScannerVersion)); err != nil { 2078 + return err 2079 + } 2080 + return nil 2081 + } 2082 + 2083 + func (t *ScanRecord) UnmarshalCBOR(r io.Reader) (err error) { 2084 + *t = ScanRecord{} 2085 + 2086 + cr := cbg.NewCborReader(r) 2087 + 2088 + maj, extra, err := cr.ReadHeader() 2089 + if err != nil { 2090 + return err 2091 + } 2092 + defer func() { 2093 + if err == io.EOF { 2094 + err = io.ErrUnexpectedEOF 2095 + } 2096 + }() 2097 + 2098 + if maj != cbg.MajMap { 2099 + return fmt.Errorf("cbor input should be of type map") 2100 + } 2101 + 2102 + if extra > cbg.MaxLength { 2103 + return fmt.Errorf("ScanRecord: map struct too large (%d)", extra) 2104 + } 2105 + 2106 + n := extra 2107 + 2108 + nameBuf := make([]byte, 14) 2109 + for i := uint64(0); i < n; i++ { 2110 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192) 2111 + if err != nil { 2112 + return err 2113 + } 2114 + 2115 + if !ok { 2116 + // Field doesn't exist on this type, so ignore it 2117 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 2118 + return err 2119 + } 2120 + continue 2121 + } 2122 + 2123 + switch string(nameBuf[:nameLen]) { 2124 + // t.Low (int64) (int64) 2125 + case "low": 2126 + { 2127 + maj, extra, err := cr.ReadHeader() 2128 + if err != nil { 2129 + return err 2130 + } 2131 + var extraI int64 2132 + switch maj { 2133 + case cbg.MajUnsignedInt: 2134 + extraI = int64(extra) 2135 + if extraI < 0 { 2136 + return fmt.Errorf("int64 positive overflow") 2137 + } 2138 + case cbg.MajNegativeInt: 2139 + extraI = int64(extra) 2140 + if extraI < 0 { 2141 + return fmt.Errorf("int64 negative overflow") 2142 + } 2143 + extraI = -1 - extraI 2144 + default: 2145 + return fmt.Errorf("wrong type for int64 field: %d", maj) 2146 + } 2147 + 2148 + t.Low = int64(extraI) 2149 + } 2150 + // t.High (int64) (int64) 2151 + case "high": 2152 + { 2153 + maj, extra, err := cr.ReadHeader() 2154 + if err != nil { 2155 + return err 2156 + } 2157 + var extraI int64 2158 + switch maj { 2159 + case cbg.MajUnsignedInt: 2160 + extraI = int64(extra) 2161 + if extraI < 0 { 2162 + return fmt.Errorf("int64 positive overflow") 2163 + } 2164 + case cbg.MajNegativeInt: 2165 + extraI = int64(extra) 2166 + if extraI < 0 { 2167 + return fmt.Errorf("int64 negative overflow") 2168 + } 2169 + extraI = -1 - extraI 2170 + default: 2171 + return fmt.Errorf("wrong type for int64 field: %d", maj) 2172 + } 2173 + 2174 + t.High = int64(extraI) 2175 + } 2176 + // t.Type (string) (string) 2177 + case "$type": 2178 + 2179 + { 2180 + sval, err := cbg.ReadStringWithMax(cr, 8192) 2181 + if err != nil { 2182 + return err 2183 + } 2184 + 2185 + t.Type = string(sval) 2186 + } 2187 + // t.Total (int64) (int64) 2188 + case "total": 2189 + { 2190 + maj, extra, err := cr.ReadHeader() 2191 + if err != nil { 2192 + return err 2193 + } 2194 + var extraI int64 2195 + switch maj { 2196 + case cbg.MajUnsignedInt: 2197 + extraI = int64(extra) 2198 + if extraI < 0 { 2199 + return fmt.Errorf("int64 positive overflow") 2200 + } 2201 + case cbg.MajNegativeInt: 2202 + extraI = int64(extra) 2203 + if extraI < 0 { 2204 + return fmt.Errorf("int64 negative overflow") 2205 + } 2206 + extraI = -1 - extraI 2207 + default: 2208 + return fmt.Errorf("wrong type for int64 field: %d", maj) 2209 + } 2210 + 2211 + t.Total = int64(extraI) 2212 + } 2213 + // t.Medium (int64) (int64) 2214 + case "medium": 2215 + { 2216 + maj, extra, err := cr.ReadHeader() 2217 + if err != nil { 2218 + return err 2219 + } 2220 + var extraI int64 2221 + switch maj { 2222 + case cbg.MajUnsignedInt: 2223 + extraI = int64(extra) 2224 + if extraI < 0 { 2225 + return fmt.Errorf("int64 positive overflow") 2226 + } 2227 + case cbg.MajNegativeInt: 2228 + extraI = int64(extra) 2229 + if extraI < 0 { 2230 + return fmt.Errorf("int64 negative overflow") 2231 + } 2232 + extraI = -1 - extraI 2233 + default: 2234 + return fmt.Errorf("wrong type for int64 field: %d", maj) 2235 + } 2236 + 2237 + t.Medium = int64(extraI) 2238 + } 2239 + // t.UserDID (string) (string) 2240 + case "userDid": 2241 + 2242 + { 2243 + sval, err := cbg.ReadStringWithMax(cr, 8192) 2244 + if err != nil { 2245 + return err 2246 + } 2247 + 2248 + t.UserDID = string(sval) 2249 + } 2250 + // t.Critical (int64) (int64) 2251 + case "critical": 2252 + { 2253 + maj, extra, err := cr.ReadHeader() 2254 + if err != nil { 2255 + return err 2256 + } 2257 + var extraI int64 2258 + switch maj { 2259 + case cbg.MajUnsignedInt: 2260 + extraI = int64(extra) 2261 + if extraI < 0 { 2262 + return fmt.Errorf("int64 positive overflow") 2263 + } 2264 + case cbg.MajNegativeInt: 2265 + extraI = int64(extra) 2266 + if extraI < 0 { 2267 + return fmt.Errorf("int64 negative overflow") 2268 + } 2269 + extraI = -1 - extraI 2270 + default: 2271 + return fmt.Errorf("wrong type for int64 field: %d", maj) 2272 + } 2273 + 2274 + t.Critical = int64(extraI) 2275 + } 2276 + // t.Manifest (string) (string) 2277 + case "manifest": 2278 + 2279 + { 2280 + sval, err := cbg.ReadStringWithMax(cr, 8192) 2281 + if err != nil { 2282 + return err 2283 + } 2284 + 2285 + t.Manifest = string(sval) 2286 + } 2287 + // t.SbomBlob (util.LexBlob) (struct) 2288 + case "sbomBlob": 2289 + 2290 + { 2291 + 2292 + b, err := cr.ReadByte() 2293 + if err != nil { 2294 + return err 2295 + } 2296 + if b != cbg.CborNull[0] { 2297 + if err := cr.UnreadByte(); err != nil { 2298 + return err 2299 + } 2300 + t.SbomBlob = new(util.LexBlob) 2301 + if err := t.SbomBlob.UnmarshalCBOR(cr); err != nil { 2302 + return xerrors.Errorf("unmarshaling t.SbomBlob pointer: %w", err) 2303 + } 2304 + } 2305 + 2306 + } 2307 + // t.ScannedAt (string) (string) 2308 + case "scannedAt": 2309 + 2310 + { 2311 + sval, err := cbg.ReadStringWithMax(cr, 8192) 2312 + if err != nil { 2313 + return err 2314 + } 2315 + 2316 + t.ScannedAt = string(sval) 2317 + } 2318 + // t.Repository (string) (string) 2319 + case "repository": 2320 + 2321 + { 2322 + sval, err := cbg.ReadStringWithMax(cr, 8192) 2323 + if err != nil { 2324 + return err 2325 + } 2326 + 2327 + t.Repository = string(sval) 2328 + } 2329 + // t.ScannerVersion (string) (string) 2330 + case "scannerVersion": 2331 + 2332 + { 2333 + sval, err := cbg.ReadStringWithMax(cr, 8192) 2334 + if err != nil { 2335 + return err 2336 + } 2337 + 2338 + t.ScannerVersion = string(sval) 2339 + } 2340 + 2341 + default: 2342 + // Field doesn't exist on this type, so ignore it 2343 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 2344 + return err 2345 + } 2346 + } 2347 + } 2348 + 2349 + return nil 2350 + }
+1
pkg/atproto/generate.go
··· 32 32 atproto.LayerRecord{}, 33 33 atproto.TangledProfileRecord{}, 34 34 atproto.StatsRecord{}, 35 + atproto.ScanRecord{}, 35 36 ); err != nil { 36 37 fmt.Printf("Failed to generate CBOR encoders: %v\n", err) 37 38 os.Exit(1)
+56 -1
pkg/atproto/lexicon.go
··· 10 10 "fmt" 11 11 "strings" 12 12 "time" 13 + 14 + lexutil "github.com/bluesky-social/indigo/lex/util" 13 15 ) 14 16 15 17 // Collection names for ATProto records ··· 40 42 // StatsCollection is the collection name for repository statistics 41 43 // Stored in hold's embedded PDS to track pull/push counts per owner+repo 42 44 StatsCollection = "io.atcr.hold.stats" 45 + 46 + // ScanCollection is the collection name for vulnerability scan results 47 + // Stored in hold's embedded PDS to track scan results per manifest 48 + ScanCollection = "io.atcr.hold.scan" 43 49 44 50 // TangledProfileCollection is the collection name for tangled profiles 45 51 // Stored in hold's embedded PDS (singleton record at rkey "self") ··· 594 600 Role string `json:"role" cborgen:"role"` 595 601 Permissions []string `json:"permissions" cborgen:"permissions"` 596 602 Tier string `json:"tier,omitempty" cborgen:"tier,omitempty"` // Optional tier for quota limits (e.g., 'deckhand', 'bosun', 'quartermaster') 597 - Plankowner bool `json:"plankowner,omitempty" cborgen:"plankowner,omitempty"` // Early adopter flag - gets plankowner_crew_tier for free 603 + Plankowner bool `json:"plankowner,omitempty" cborgen:"plankowner"` // Early adopter flag - gets plankowner_crew_tier for free 598 604 AddedAt string `json:"addedAt" cborgen:"addedAt"` // RFC3339 timestamp 599 605 } 600 606 ··· 673 679 // Use first 16 bytes (128 bits) for collision resistance 674 680 // Encode with base32 (alphanumeric, lowercase, no padding) for ATProto rkey compatibility 675 681 return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(hash[:16])) 682 + } 683 + 684 + // ScanRecord represents vulnerability scan results for a manifest 685 + // Collection: io.atcr.hold.scan 686 + // Stored in hold's embedded PDS to track scan results per manifest 687 + // Uses CBOR encoding for efficient storage in hold's carstore 688 + // RKey is deterministic: based on manifest digest (one scan per manifest) 689 + type ScanRecord struct { 690 + Type string `json:"$type" cborgen:"$type"` 691 + Manifest string `json:"manifest" cborgen:"manifest"` // AT-URI of the scanned manifest (e.g., "at://did:plc:xyz/io.atcr.manifest/abc123...") 692 + Repository string `json:"repository" cborgen:"repository"` // Repository name (e.g., "myapp") 693 + UserDID string `json:"userDid" cborgen:"userDid"` // DID of the image owner 694 + SbomBlob *lexutil.LexBlob `json:"sbomBlob,omitempty" cborgen:"sbomBlob"` // SBOM blob uploaded to hold's PDS blob storage 695 + Critical int64 `json:"critical" cborgen:"critical"` // Count of critical vulnerabilities 696 + High int64 `json:"high" cborgen:"high"` // Count of high vulnerabilities 697 + Medium int64 `json:"medium" cborgen:"medium"` // Count of medium vulnerabilities 698 + Low int64 `json:"low" cborgen:"low"` // Count of low vulnerabilities 699 + Total int64 `json:"total" cborgen:"total"` // Total vulnerability count 700 + ScannerVersion string `json:"scannerVersion" cborgen:"scannerVersion"` // Scanner version (e.g., "atcr-scanner-v1.0.0") 701 + ScannedAt string `json:"scannedAt" cborgen:"scannedAt"` // RFC3339 timestamp of scan completion 702 + } 703 + 704 + // NewScanRecord creates a new scan record 705 + // manifestDigest: the manifest digest (e.g., "sha256:abc123...") 706 + // userDID: the DID of the image owner (used to build the manifest AT-URI) 707 + // sbomBlob: blob reference from uploading SBOM to PDS blob storage (nil if no SBOM) 708 + func NewScanRecord(manifestDigest, repository, userDID string, sbomBlob *lexutil.LexBlob, critical, high, medium, low, total int, scannerVersion string) *ScanRecord { 709 + return &ScanRecord{ 710 + Type: ScanCollection, 711 + Manifest: BuildManifestURI(userDID, manifestDigest), 712 + Repository: repository, 713 + UserDID: userDID, 714 + SbomBlob: sbomBlob, 715 + Critical: int64(critical), 716 + High: int64(high), 717 + Medium: int64(medium), 718 + Low: int64(low), 719 + Total: int64(total), 720 + ScannerVersion: scannerVersion, 721 + ScannedAt: time.Now().Format(time.RFC3339), 722 + } 723 + } 724 + 725 + // ScanRecordKey generates a deterministic record key for a scan result 726 + // Uses the manifest digest (without algorithm prefix) as the rkey 727 + // This ensures one scan record per manifest, and re-scans upsert the record 728 + func ScanRecordKey(manifestDigest string) string { 729 + // Remove the "sha256:" prefix - the hex digest is already a valid rkey 730 + return strings.TrimPrefix(manifestDigest, "sha256:") 676 731 } 677 732 678 733 // TangledProfileRecord represents a Tangled profile for the hold
+59
pkg/hold/pds/scan.go
··· 1 + package pds 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + 7 + "atcr.io/pkg/atproto" 8 + "github.com/ipfs/go-cid" 9 + ) 10 + 11 + // CreateScanRecord creates or updates a scan result record in the hold's PDS 12 + // Uses a deterministic rkey based on the manifest digest, so re-scans upsert 13 + func (p *HoldPDS) CreateScanRecord(ctx context.Context, record *atproto.ScanRecord) (string, cid.Cid, error) { 14 + if record.Type != atproto.ScanCollection { 15 + return "", cid.Undef, fmt.Errorf("invalid record type: %s", record.Type) 16 + } 17 + 18 + if record.Manifest == "" { 19 + return "", cid.Undef, fmt.Errorf("manifest AT-URI is required") 20 + } 21 + 22 + // Extract the digest from the manifest AT-URI to use as rkey 23 + manifestDigest, err := atproto.ParseManifestURI(record.Manifest) 24 + if err != nil { 25 + return "", cid.Undef, fmt.Errorf("invalid manifest AT-URI: %w", err) 26 + } 27 + rkey := atproto.ScanRecordKey(manifestDigest) 28 + 29 + // Upsert: re-scans update the existing record 30 + rpath, recordCID, _, err := p.repomgr.UpsertRecord( 31 + ctx, 32 + p.uid, 33 + atproto.ScanCollection, 34 + rkey, 35 + record, 36 + ) 37 + if err != nil { 38 + return "", cid.Undef, fmt.Errorf("failed to upsert scan record: %w", err) 39 + } 40 + 41 + return rpath, recordCID, nil 42 + } 43 + 44 + // GetScanRecord retrieves a scan result record by manifest digest 45 + func (p *HoldPDS) GetScanRecord(ctx context.Context, manifestDigest string) (cid.Cid, *atproto.ScanRecord, error) { 46 + rkey := atproto.ScanRecordKey(manifestDigest) 47 + 48 + recordCID, val, err := p.repomgr.GetRecord(ctx, p.uid, atproto.ScanCollection, rkey, cid.Undef) 49 + if err != nil { 50 + return cid.Undef, nil, fmt.Errorf("failed to get scan record: %w", err) 51 + } 52 + 53 + scanRecord, ok := val.(*atproto.ScanRecord) 54 + if !ok { 55 + return cid.Undef, nil, fmt.Errorf("unexpected type for scan record: %T", val) 56 + } 57 + 58 + return recordCID, scanRecord, nil 59 + }
+29 -117
pkg/hold/pds/scan_broadcaster.go
··· 3 3 import ( 4 4 "context" 5 5 "crypto/rand" 6 - "crypto/sha256" 7 6 "database/sql" 8 7 "encoding/hex" 9 8 "encoding/json" ··· 12 11 "sync" 13 12 "time" 14 13 14 + "atcr.io/pkg/atproto" 15 + lexutil "github.com/bluesky-social/indigo/lex/util" 15 16 storagedriver "github.com/distribution/distribution/v3/registry/storage/driver" 16 17 "github.com/gorilla/websocket" 17 18 ) ··· 353 354 "subscriberId", sub.id) 354 355 } 355 356 356 - // handleResult processes a completed scan result: stores ORAS manifest + marks completed 357 + // handleResult processes a completed scan result: uploads SBOM blob + stores scan record in PDS 357 358 func (sb *ScanBroadcaster) handleResult(sub *ScanSubscriber, msg ScannerMessage) { 358 359 ctx := context.Background() 359 360 ··· 382 383 return 383 384 } 384 385 385 - // Store vulnerability report blob in S3 386 - if msg.VulnReport != "" { 387 - vulnJSON := []byte(msg.VulnReport) 388 - vulnDigest := fmt.Sprintf("sha256:%x", sha256.Sum256(vulnJSON)) 389 - 390 - if err := sb.uploadBlob(ctx, vulnDigest, vulnJSON); err != nil { 391 - slog.Error("Failed to upload vulnerability report blob", 386 + // Upload SBOM as a blob to the hold's PDS blob storage (like manifest blobs) 387 + var sbomBlob *lexutil.LexBlob 388 + if msg.SBOM != "" { 389 + blob, err := uploadBlobToStorage(ctx, sb.driver, sb.holdDID, []byte(msg.SBOM), "application/spdx+json") 390 + if err != nil { 391 + slog.Error("Failed to upload SBOM blob to PDS storage", 392 392 "seq", msg.Seq, 393 393 "error", err) 394 - } 395 - 396 - // Build and store ORAS manifest 397 - if msg.Summary != nil { 398 - if err := sb.storeORASManifest(ctx, manifestDigest, repository, userDID, vulnDigest, vulnJSON, *msg.Summary); err != nil { 399 - slog.Error("Failed to store ORAS manifest", 400 - "seq", msg.Seq, 401 - "error", err) 402 - } 394 + } else { 395 + sbomBlob = blob 403 396 } 404 397 } 405 398 406 - // Store SBOM blob if provided 407 - if msg.SBOM != "" { 408 - sbomJSON := []byte(msg.SBOM) 409 - sbomDigest := fmt.Sprintf("sha256:%x", sha256.Sum256(sbomJSON)) 399 + // Store scan result as a record in the hold's embedded PDS 400 + if msg.Summary != nil { 401 + scanRecord := atproto.NewScanRecord( 402 + manifestDigest, repository, userDID, 403 + sbomBlob, 404 + msg.Summary.Critical, msg.Summary.High, msg.Summary.Medium, msg.Summary.Low, msg.Summary.Total, 405 + "atcr-scanner-v1.0.0", 406 + ) 410 407 411 - if err := sb.uploadBlob(ctx, sbomDigest, sbomJSON); err != nil { 412 - slog.Error("Failed to upload SBOM blob", 408 + rpath, _, err := sb.pds.CreateScanRecord(ctx, scanRecord) 409 + if err != nil { 410 + slog.Error("Failed to store scan record in PDS", 413 411 "seq", msg.Seq, 414 412 "error", err) 413 + } else { 414 + slog.Info("Scan record stored in PDS", 415 + "rpath", rpath, 416 + "manifest", scanRecord.Manifest, 417 + "critical", msg.Summary.Critical, 418 + "high", msg.Summary.High, 419 + "total", msg.Summary.Total) 415 420 } 416 421 } 417 422 ··· 582 587 // ValidateScannerSecret checks if the provided secret matches 583 588 func (sb *ScanBroadcaster) ValidateScannerSecret(secret string) bool { 584 589 return sb.secret != "" && secret == sb.secret 585 - } 586 - 587 - // storeORASManifest creates an ORAS vulnerability manifest as a blob in S3 588 - // The ORAS manifest's "subject" field references the original manifest by digest, 589 - // enabling OCI referrers API discovery. 590 - func (sb *ScanBroadcaster) storeORASManifest(ctx context.Context, manifestDigest, repository, userDID, vulnDigest string, vulnJSON []byte, summary VulnerabilitySummary) error { 591 - scannerVersion := "atcr-scanner-v1.0.0" 592 - 593 - // Create ORAS manifest 594 - orasManifest := map[string]interface{}{ 595 - "schemaVersion": 2, 596 - "mediaType": "application/vnd.oci.image.manifest.v1+json", 597 - "artifactType": "application/vnd.atcr.vulnerabilities+json", 598 - "config": map[string]interface{}{ 599 - "mediaType": "application/vnd.oci.empty.v1+json", 600 - "digest": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", 601 - "size": 2, 602 - }, 603 - "subject": map[string]interface{}{ 604 - "mediaType": "application/vnd.oci.image.manifest.v1+json", 605 - "digest": manifestDigest, 606 - "size": 0, 607 - }, 608 - "layers": []map[string]interface{}{ 609 - { 610 - "mediaType": "application/json", 611 - "digest": vulnDigest, 612 - "size": len(vulnJSON), 613 - "annotations": map[string]string{ 614 - "org.opencontainers.image.title": "vulnerability-report.json", 615 - }, 616 - }, 617 - }, 618 - "annotations": map[string]string{ 619 - "io.atcr.vuln.critical": fmt.Sprintf("%d", summary.Critical), 620 - "io.atcr.vuln.high": fmt.Sprintf("%d", summary.High), 621 - "io.atcr.vuln.medium": fmt.Sprintf("%d", summary.Medium), 622 - "io.atcr.vuln.low": fmt.Sprintf("%d", summary.Low), 623 - "io.atcr.vuln.total": fmt.Sprintf("%d", summary.Total), 624 - "io.atcr.vuln.scannedAt": time.Now().Format(time.RFC3339), 625 - "io.atcr.vuln.scannerVersion": scannerVersion, 626 - "io.atcr.vuln.repository": repository, 627 - "io.atcr.vuln.ownerDid": userDID, 628 - "io.atcr.vuln.holdDid": sb.holdDID, 629 - }, 630 - } 631 - 632 - orasManifestJSON, err := json.Marshal(orasManifest) 633 - if err != nil { 634 - return fmt.Errorf("failed to encode ORAS manifest: %w", err) 635 - } 636 - 637 - orasHash := sha256.Sum256(orasManifestJSON) 638 - orasDigest := fmt.Sprintf("sha256:%x", orasHash) 639 - 640 - // Upload ORAS manifest blob to S3 641 - if err := sb.uploadBlob(ctx, orasDigest, orasManifestJSON); err != nil { 642 - return fmt.Errorf("failed to upload ORAS manifest blob: %w", err) 643 - } 644 - 645 - slog.Info("ORAS manifest stored", 646 - "digest", orasDigest, 647 - "repository", repository, 648 - "userDid", userDID, 649 - "critical", summary.Critical, 650 - "high", summary.High, 651 - "total", summary.Total) 652 - 653 - return nil 654 - } 655 - 656 - // uploadBlob uploads a blob to S3 storage 657 - func (sb *ScanBroadcaster) uploadBlob(ctx context.Context, digest string, data []byte) error { 658 - digestHex := digest[len("sha256:"):] 659 - if len(digestHex) < 2 { 660 - return fmt.Errorf("invalid digest: %s", digest) 661 - } 662 - 663 - blobPath := fmt.Sprintf("/docker/registry/v2/blobs/sha256/%s/%s/data", 664 - digestHex[:2], digestHex) 665 - 666 - writer, err := sb.driver.Writer(ctx, blobPath, false) 667 - if err != nil { 668 - return fmt.Errorf("failed to create storage writer: %w", err) 669 - } 670 - defer writer.Close() 671 - 672 - if _, err := writer.Write(data); err != nil { 673 - writer.Cancel(ctx) 674 - return fmt.Errorf("failed to write blob data: %w", err) 675 - } 676 - 677 - return writer.Commit(ctx) 678 590 } 679 591 680 592 func generateSubscriberID() string {
+1
pkg/hold/pds/server.go
··· 29 29 lexutil.RegisterType(atproto.LayerCollection, &atproto.LayerRecord{}) 30 30 lexutil.RegisterType(atproto.TangledProfileCollection, &atproto.TangledProfileRecord{}) 31 31 lexutil.RegisterType(atproto.StatsCollection, &atproto.StatsRecord{}) 32 + lexutil.RegisterType(atproto.ScanCollection, &atproto.ScanRecord{}) 32 33 } 33 34 34 35 // HoldPDS is a minimal ATProto PDS implementation for a hold service