A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
80
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