mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-29 04:06:58 +00:00
implement a POC for vulnerability scans using syft and grype
This commit is contained in:
@@ -111,6 +111,33 @@ HOLD_DATABASE_DIR=/var/lib/atcr-hold
|
||||
#
|
||||
HOLD_OWNER=did:plc:your-did-here
|
||||
|
||||
# ==============================================================================
|
||||
# Scanner Configuration (SBOM & Vulnerability Scanning)
|
||||
# ==============================================================================
|
||||
|
||||
# Enable automatic SBOM generation and vulnerability scanning on image push
|
||||
# Default: true
|
||||
HOLD_SBOM_ENABLED=true
|
||||
|
||||
# Number of concurrent scanner worker threads
|
||||
# Default: 2
|
||||
HOLD_SBOM_WORKERS=2
|
||||
|
||||
# Enable vulnerability scanning with Grype
|
||||
# If false, only SBOM generation (Syft) will run
|
||||
# Default: true
|
||||
HOLD_VULN_ENABLED=true
|
||||
|
||||
# Path to Grype vulnerability database
|
||||
# Database is auto-downloaded and cached at this location
|
||||
# Default: /var/lib/atcr-hold/grype-db
|
||||
# HOLD_VULN_DB_PATH=/var/lib/atcr-hold/grype-db
|
||||
|
||||
# How often to update vulnerability database
|
||||
# Examples: 24h, 12h, 48h
|
||||
# Default: 24h
|
||||
# HOLD_VULN_DB_UPDATE_INTERVAL=24h
|
||||
|
||||
# ==============================================================================
|
||||
# Logging Configuration
|
||||
# ==============================================================================
|
||||
|
||||
@@ -376,6 +376,12 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
|
||||
slog.Info("UI enabled", "home", "/", "settings", "/settings")
|
||||
}
|
||||
|
||||
// API endpoint for vulnerability details
|
||||
if uiSessionStore != nil {
|
||||
repoHandler := &uihandlers.RepositoryPageHandler{}
|
||||
mainRouter.Get("/api/vulnerabilities", repoHandler.HandleVulnerabilityDetails)
|
||||
}
|
||||
|
||||
// Mount OAuth endpoints
|
||||
mainRouter.Get("/auth/oauth/authorize", oauthServer.ServeAuthorize)
|
||||
mainRouter.Get("/auth/oauth/callback", oauthServer.ServeCallback)
|
||||
|
||||
+27
-1
@@ -13,6 +13,7 @@ import (
|
||||
"atcr.io/pkg/hold"
|
||||
"atcr.io/pkg/hold/oci"
|
||||
"atcr.io/pkg/hold/pds"
|
||||
"atcr.io/pkg/hold/scanner"
|
||||
"atcr.io/pkg/logging"
|
||||
"atcr.io/pkg/s3"
|
||||
|
||||
@@ -111,8 +112,33 @@ func main() {
|
||||
// Create PDS XRPC handler (ATProto endpoints)
|
||||
xrpcHandler = pds.NewXRPCHandler(holdPDS, *s3Service, driver, broadcaster, nil)
|
||||
|
||||
// Initialize scanner queue if scanning is enabled
|
||||
// Use interface type to ensure proper nil checking (avoid typed nil pointer issue)
|
||||
var scanQueue oci.ScanQueue
|
||||
if cfg.Scanner.Enabled {
|
||||
slog.Info("Initializing vulnerability scanner",
|
||||
"workers", cfg.Scanner.Workers,
|
||||
"vulnEnabled", cfg.Scanner.VulnEnabled,
|
||||
"vulnDBPath", cfg.Scanner.VulnDBPath)
|
||||
|
||||
// Create scanner worker
|
||||
scanWorker := scanner.NewWorker(cfg, driver, holdPDS)
|
||||
|
||||
// Create and start scanner queue (buffer size = workers * 2 for some headroom)
|
||||
bufferSize := cfg.Scanner.Workers * 2
|
||||
concreteQueue := scanner.NewQueue(cfg.Scanner.Workers, bufferSize)
|
||||
scanWorker.Start(concreteQueue)
|
||||
|
||||
// Assign to interface variable (ensures proper nil behavior)
|
||||
scanQueue = concreteQueue
|
||||
|
||||
slog.Info("Scanner queue initialized successfully")
|
||||
} else {
|
||||
slog.Info("SBOM/vulnerability scanning disabled")
|
||||
}
|
||||
|
||||
// Create OCI XRPC handler (multipart upload endpoints)
|
||||
ociHandler = oci.NewXRPCHandler(holdPDS, *s3Service, driver, cfg.Server.DisablePresignedURLs, cfg.Registration.EnableBlueskyPosts, nil)
|
||||
ociHandler = oci.NewXRPCHandler(holdPDS, *s3Service, driver, cfg.Server.DisablePresignedURLs, cfg.Registration.EnableBlueskyPosts, nil, scanQueue)
|
||||
}
|
||||
|
||||
// Setup HTTP routes with chi router
|
||||
|
||||
@@ -100,6 +100,40 @@ HOLD_ALLOW_ALL_CREW=false
|
||||
# Default: false
|
||||
HOLD_BLUESKY_POSTS_ENABLED=true
|
||||
|
||||
# ==============================================================================
|
||||
# Scanner Configuration (SBOM & Vulnerability Scanning)
|
||||
# ==============================================================================
|
||||
|
||||
# Enable automatic SBOM generation and vulnerability scanning on image push
|
||||
# When enabled, the hold service will:
|
||||
# 1. Generate SBOM (Software Bill of Materials) using Syft
|
||||
# 2. Scan for vulnerabilities using Grype
|
||||
# 3. Store results as ORAS artifacts (OCI referrers pattern)
|
||||
# 4. Display vulnerability counts on repository pages in AppView
|
||||
#
|
||||
# Default: true
|
||||
HOLD_SBOM_ENABLED=true
|
||||
|
||||
# Number of concurrent scanner worker threads
|
||||
# Increase for faster scanning on multi-core systems
|
||||
# Default: 2
|
||||
HOLD_SBOM_WORKERS=2
|
||||
|
||||
# Enable vulnerability scanning with Grype
|
||||
# If false, only SBOM generation (Syft) will run
|
||||
# Default: true
|
||||
HOLD_VULN_ENABLED=true
|
||||
|
||||
# Path to Grype vulnerability database
|
||||
# Database is auto-downloaded and cached at this location on first run
|
||||
# Default: /var/lib/atcr-hold/grype-db
|
||||
HOLD_VULN_DB_PATH=/var/lib/atcr-hold/grype-db
|
||||
|
||||
# How often to update vulnerability database
|
||||
# Examples: 24h, 12h, 48h
|
||||
# Default: 24h
|
||||
HOLD_VULN_DB_UPDATE_INTERVAL=24h
|
||||
|
||||
# ==============================================================================
|
||||
# S3/UpCloud Object Storage Configuration
|
||||
# ==============================================================================
|
||||
|
||||
@@ -114,6 +114,13 @@ services:
|
||||
S3_ENDPOINT: ${S3_ENDPOINT:-}
|
||||
S3_REGION_ENDPOINT: ${S3_REGION_ENDPOINT:-}
|
||||
|
||||
# Scanner configuration (SBOM & Vulnerability Scanning)
|
||||
HOLD_SBOM_ENABLED: ${HOLD_SBOM_ENABLED:-true}
|
||||
HOLD_SBOM_WORKERS: ${HOLD_SBOM_WORKERS:-2}
|
||||
HOLD_VULN_ENABLED: ${HOLD_VULN_ENABLED:-true}
|
||||
HOLD_VULN_DB_PATH: ${HOLD_VULN_DB_PATH:-/var/lib/atcr-hold/grype-db}
|
||||
HOLD_VULN_DB_UPDATE_INTERVAL: ${HOLD_VULN_DB_UPDATE_INTERVAL:-24h}
|
||||
|
||||
# Logging
|
||||
ATCR_LOG_LEVEL: ${ATCR_LOG_LEVEL:-debug}
|
||||
ATCR_LOG_FORMATTER: ${ATCR_LOG_FORMATTER:-text}
|
||||
|
||||
@@ -50,6 +50,10 @@ services:
|
||||
# STORAGE_ROOT_DIR: /var/lib/atcr/hold
|
||||
TEST_MODE: true
|
||||
# DISABLE_PRESIGNED_URLS: true
|
||||
# Scanner configuration
|
||||
HOLD_SBOM_ENABLED: true
|
||||
HOLD_SBOM_WORKERS: 2
|
||||
HOLD_VULN_ENABLED: true
|
||||
# Logging
|
||||
ATCR_LOG_LEVEL: debug
|
||||
# Storage config comes from env_file (STORAGE_DRIVER, AWS_*, S3_*)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
module atcr.io
|
||||
|
||||
go 1.24.7
|
||||
go 1.24.9
|
||||
|
||||
require (
|
||||
github.com/anchore/grype v0.102.0
|
||||
github.com/anchore/syft v1.36.0
|
||||
github.com/aws/aws-sdk-go v1.55.5
|
||||
github.com/bluesky-social/indigo v0.0.0-20251021193747-543ab1124beb
|
||||
github.com/distribution/distribution/v3 v3.0.0
|
||||
@@ -23,49 +25,191 @@ require (
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/multiformats/go-multihash v0.2.3
|
||||
github.com/opencontainers/go-digest v1.0.0
|
||||
github.com/spf13/cobra v1.8.0
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/spf13/cobra v1.10.1
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/whyrusleeping/cbor-gen v0.3.1
|
||||
github.com/yuin/goldmark v1.7.13
|
||||
go.opentelemetry.io/otel v1.32.0
|
||||
go.opentelemetry.io/otel v1.37.0
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.2
|
||||
golang.org/x/crypto v0.39.0
|
||||
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028
|
||||
gorm.io/gorm v1.25.9
|
||||
golang.org/x/crypto v0.43.0
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
|
||||
gorm.io/gorm v1.31.0
|
||||
)
|
||||
|
||||
require (
|
||||
cel.dev/expr v0.24.0 // indirect
|
||||
cloud.google.com/go v0.121.3 // indirect
|
||||
cloud.google.com/go/auth v0.16.2 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.7.0 // indirect
|
||||
cloud.google.com/go/iam v1.5.2 // indirect
|
||||
cloud.google.com/go/monitoring v1.24.2 // indirect
|
||||
cloud.google.com/go/storage v1.55.0 // indirect
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 // indirect
|
||||
github.com/AdamKorcz/go-118-fuzz-build v0.0.0-20250520111509-a70c2aa677fa // indirect
|
||||
github.com/BurntSushi/toml v1.5.0 // indirect
|
||||
github.com/CycloneDX/cyclonedx-go v0.9.3 // indirect
|
||||
github.com/DataDog/zstd v1.5.7 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect
|
||||
github.com/Intevation/gval v1.3.0 // indirect
|
||||
github.com/Intevation/jsonpath v0.2.1 // indirect
|
||||
github.com/Masterminds/goutils v1.1.1 // indirect
|
||||
github.com/Masterminds/semver/v3 v3.4.0 // indirect
|
||||
github.com/Masterminds/sprig/v3 v3.3.0 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/Microsoft/hcsshim v0.13.0 // indirect
|
||||
github.com/OneOfOne/xxhash v1.2.8 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
||||
github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b // indirect
|
||||
github.com/STARRY-S/zip v0.2.3 // indirect
|
||||
github.com/acobaugh/osrelease v0.1.0 // indirect
|
||||
github.com/adrg/xdg v0.5.3 // indirect
|
||||
github.com/agext/levenshtein v1.2.3 // indirect
|
||||
github.com/anchore/archiver/v3 v3.5.3-0.20241210171143-5b1d8d1c7c51 // indirect
|
||||
github.com/anchore/clio v0.0.0-20250715152405-a0fa658e5084 // indirect
|
||||
github.com/anchore/fangs v0.0.0-20250716230140-94c22408c232 // indirect
|
||||
github.com/anchore/go-collections v0.0.0-20251016125210-a3c352120e8c // indirect
|
||||
github.com/anchore/go-homedir v0.0.0-20250319154043-c29668562e4d // indirect
|
||||
github.com/anchore/go-logger v0.0.0-20250318195838-07ae343dd722 // indirect
|
||||
github.com/anchore/go-lzo v0.1.0 // indirect
|
||||
github.com/anchore/go-macholibre v0.0.0-20250320151634-807da7ad2331 // indirect
|
||||
github.com/anchore/go-rpmdb v0.0.0-20250516171929-f77691e1faec // indirect
|
||||
github.com/anchore/go-struct-converter v0.0.0-20250211213226-cce56d595160 // indirect
|
||||
github.com/anchore/go-sync v0.0.0-20250714163430-add63db73ad1 // indirect
|
||||
github.com/anchore/go-version v1.2.2-0.20210903204242-51efa5b487c4 // indirect
|
||||
github.com/anchore/packageurl-go v0.1.1-0.20250220190351-d62adb6e1115 // indirect
|
||||
github.com/anchore/stereoscope v0.1.11 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
|
||||
github.com/aquasecurity/go-pep440-version v0.0.1 // indirect
|
||||
github.com/aquasecurity/go-version v0.0.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.36.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.29.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.70 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.32 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.36 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.36 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.80.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.25.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.34.0 // indirect
|
||||
github.com/aws/smithy-go v1.22.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/becheran/wildmatch-go v1.0.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect
|
||||
github.com/bitnami/go-version v0.0.0-20250505154626-452e8c5ee607 // indirect
|
||||
github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb // indirect
|
||||
github.com/bmatcuk/doublestar/v2 v2.0.4 // indirect
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
||||
github.com/bodgit/plumbing v1.3.0 // indirect
|
||||
github.com/bodgit/sevenzip v1.6.1 // indirect
|
||||
github.com/bodgit/windows v1.0.1 // indirect
|
||||
github.com/bshuster-repo/logrus-logstash-hook v1.0.0 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.3.1 // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.0 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.10.1 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/cloudflare/circl v1.6.1 // indirect
|
||||
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect
|
||||
github.com/containerd/cgroups/v3 v3.0.3 // indirect
|
||||
github.com/containerd/containerd v1.7.28 // indirect
|
||||
github.com/containerd/containerd/api v1.9.0 // indirect
|
||||
github.com/containerd/continuity v0.4.5 // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||
github.com/containerd/fifo v1.1.0 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/containerd/platforms v0.2.1 // indirect
|
||||
github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect
|
||||
github.com/containerd/ttrpc v1.2.7 // indirect
|
||||
github.com/containerd/typeurl/v2 v2.2.3 // indirect
|
||||
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/deitch/magic v0.0.0-20240306090643-c67ab88f10cb // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/docker/docker-credential-helpers v0.8.2 // indirect
|
||||
github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c // indirect
|
||||
github.com/diskfs/go-diskfs v1.7.0 // indirect
|
||||
github.com/docker/cli v28.5.1+incompatible // indirect
|
||||
github.com/docker/distribution v2.8.3+incompatible // indirect
|
||||
github.com/docker/docker v28.5.1+incompatible // indirect
|
||||
github.com/docker/docker-credential-helpers v0.9.3 // indirect
|
||||
github.com/docker/go-connections v0.6.0 // indirect
|
||||
github.com/docker/go-events v0.0.0-20250114142523-c867878c5e32 // indirect
|
||||
github.com/docker/go-metrics v0.0.1 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/elliotchance/phpserialize v1.4.0 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect
|
||||
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
|
||||
github.com/facebookincubator/nvdtools v0.1.5 // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/felixge/fgprof v0.9.5 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
|
||||
github.com/github/go-spdx/v2 v2.3.4 // indirect
|
||||
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
||||
github.com/glebarez/sqlite v1.11.0 // indirect
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||
github.com/go-git/go-billy/v5 v5.6.2 // indirect
|
||||
github.com/go-git/go-git/v5 v5.16.3 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.2 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-restruct/restruct v1.2.0-alpha // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/gocql/gocql v1.7.0 // indirect
|
||||
github.com/gocsaf/csaf/v3 v3.3.0 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/gohugoio/hashstructure v0.6.0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/golang/snappy v1.0.0 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/go-containerregistry v0.20.6 // indirect
|
||||
github.com/google/go-querystring v1.1.0 // indirect
|
||||
github.com/google/licensecheck v0.3.1 // indirect
|
||||
github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
|
||||
github.com/gookit/color v1.6.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/gorilla/handlers v1.5.2 // indirect
|
||||
github.com/gorilla/mux v1.8.1 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 // indirect
|
||||
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed // indirect
|
||||
github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b // indirect
|
||||
github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.65 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/hashicorp/go-getter v1.8.2 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/hashicorp/go-retryablehttp v0.7.5 // indirect
|
||||
github.com/hashicorp/go-version v1.7.0 // indirect
|
||||
github.com/hashicorp/golang-lru v1.0.2 // indirect
|
||||
github.com/hashicorp/golang-lru/arc/v2 v2.0.6 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/hashicorp/hcl/v2 v2.24.0 // indirect
|
||||
github.com/huandu/xstrings v1.5.0 // indirect
|
||||
github.com/iancoleman/strcase v0.3.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/ipfs/bbloom v0.0.4 // indirect
|
||||
github.com/ipfs/go-blockservice v0.5.2 // indirect
|
||||
@@ -86,22 +230,70 @@ require (
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||
github.com/jackc/pgx/v5 v5.5.0 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||
github.com/jbenet/goprocess v0.1.4 // indirect
|
||||
github.com/jinzhu/copier v0.4.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||
github.com/kastenhq/goversion v0.0.0-20230811215019-93b2f8823953 // indirect
|
||||
github.com/kevinburke/ssh_config v1.2.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/klauspost/pgzip v1.2.6 // indirect
|
||||
github.com/knqyf263/go-apk-version v0.0.0-20200609155635-041fdbb8563f // indirect
|
||||
github.com/knqyf263/go-deb-version v0.0.0-20241115132648-6f4aee6ccd23 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/masahiro331/go-mvn-version v0.0.0-20250131095131-f4974fa13b8a // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
|
||||
github.com/mholt/archives v0.1.5 // indirect
|
||||
github.com/mikelolasagasti/xz v1.0.1 // indirect
|
||||
github.com/minio/minlz v1.0.1 // indirect
|
||||
github.com/minio/sha256-simd v1.0.1 // indirect
|
||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
|
||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/locker v1.0.1 // indirect
|
||||
github.com/moby/sys/mountinfo v0.7.2 // indirect
|
||||
github.com/moby/sys/sequential v0.6.0 // indirect
|
||||
github.com/moby/sys/signal v0.7.1 // indirect
|
||||
github.com/moby/sys/user v0.4.0 // indirect
|
||||
github.com/moby/sys/userns v0.1.0 // indirect
|
||||
github.com/mr-tron/base58 v1.2.0 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/multiformats/go-base32 v0.1.0 // indirect
|
||||
github.com/multiformats/go-base36 v0.2.0 // indirect
|
||||
github.com/multiformats/go-multibase v0.2.0 // indirect
|
||||
github.com/multiformats/go-varint v0.0.7 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.0 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/nix-community/go-nix v0.0.0-20250101154619-4bdde671e0a1 // indirect
|
||||
github.com/nwaples/rardecode v1.1.3 // indirect
|
||||
github.com/nwaples/rardecode/v2 v2.2.0 // indirect
|
||||
github.com/olekukonko/errors v1.1.0 // indirect
|
||||
github.com/olekukonko/ll v0.0.9 // indirect
|
||||
github.com/olekukonko/tablewriter v1.1.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/opencontainers/runtime-spec v1.2.1 // indirect
|
||||
github.com/opencontainers/selinux v1.12.0 // indirect
|
||||
github.com/opentracing/opentracing-go v1.2.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/openvex/go-vex v0.2.7 // indirect
|
||||
github.com/package-url/packageurl-go v0.1.3 // indirect
|
||||
github.com/pandatix/go-cvss v0.6.2 // indirect
|
||||
github.com/pborman/indent v1.2.1 // indirect
|
||||
github.com/pelletier/go-toml v1.9.5 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.22 // indirect
|
||||
github.com/pjbgf/sha1cd v0.4.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pkg/profile v1.7.0 // indirect
|
||||
github.com/pkg/xattr v0.4.12 // indirect
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect
|
||||
github.com/prometheus/client_golang v1.20.5 // indirect
|
||||
github.com/prometheus/client_model v0.6.1 // indirect
|
||||
@@ -110,47 +302,107 @@ require (
|
||||
github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5 // indirect
|
||||
github.com/redis/go-redis/extra/redisotel/v9 v9.0.5 // indirect
|
||||
github.com/redis/go-redis/v9 v9.7.3 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/rust-secure-code/go-rustaudit v0.0.0-20250226111315-e20ec32e963c // indirect
|
||||
github.com/sagikazarmark/locafero v0.9.0 // indirect
|
||||
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||
github.com/sassoftware/go-rpmutils v0.4.0 // indirect
|
||||
github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e // indirect
|
||||
github.com/sergi/go-diff v1.4.0 // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af // indirect
|
||||
github.com/skeema/knownhosts v1.3.1 // indirect
|
||||
github.com/sorairolake/lzip-go v0.3.8 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/spdx/gordf v0.0.0-20250128162952-000978ccd6fb // indirect
|
||||
github.com/spdx/tools-golang v0.5.5 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.9.2 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
github.com/spf13/viper v1.20.1 // indirect
|
||||
github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/sylabs/sif/v2 v2.22.0 // indirect
|
||||
github.com/sylabs/squashfs v1.0.6 // indirect
|
||||
github.com/therootcompany/xz v1.0.1 // indirect
|
||||
github.com/ulikunitz/xz v0.5.15 // indirect
|
||||
github.com/vbatts/go-mtree v0.6.0 // indirect
|
||||
github.com/vbatts/tar-split v0.12.1 // indirect
|
||||
github.com/vifraa/gopom v1.0.0 // indirect
|
||||
github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651 // indirect
|
||||
github.com/wagoodman/go-progress v0.0.0-20230925121702-07e42b3cdba0 // indirect
|
||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/zclconf/go-cty v1.16.3 // indirect
|
||||
github.com/zeebo/errs v1.4.0 // indirect
|
||||
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
|
||||
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
|
||||
go.etcd.io/bbolt v1.4.2 // indirect
|
||||
go.opencensus.io v0.24.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/contrib/bridges/prometheus v0.57.0 // indirect
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.37.0 // indirect
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.57.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.32.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.32.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.54.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.8.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.32.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0 // indirect
|
||||
go.opentelemetry.io/otel/log v0.8.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.32.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.32.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/log v0.8.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.32.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.32.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.3.1 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.37.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.4.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.26.0 // indirect
|
||||
golang.org/x/net v0.37.0 // indirect
|
||||
golang.org/x/sync v0.15.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/text v0.26.0 // indirect
|
||||
golang.org/x/time v0.6.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241104194629-dd2ea8efbc28 // indirect
|
||||
google.golang.org/grpc v1.68.0 // indirect
|
||||
google.golang.org/protobuf v1.35.1 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
go4.org v0.0.0-20230225012048-214862532bf5 // indirect
|
||||
golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc // indirect
|
||||
golang.org/x/mod v0.29.0 // indirect
|
||||
golang.org/x/net v0.46.0 // indirect
|
||||
golang.org/x/oauth2 v0.30.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
golang.org/x/term v0.36.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
golang.org/x/tools v0.38.0 // indirect
|
||||
google.golang.org/api v0.242.0 // indirect
|
||||
google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250715232539-7130f93afb79 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250715232539-7130f93afb79 // indirect
|
||||
google.golang.org/grpc v1.74.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
gorm.io/driver/postgres v1.5.7 // indirect
|
||||
lukechampine.com/blake3 v1.2.1 // indirect
|
||||
modernc.org/libc v1.66.10 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.39.1 // indirect
|
||||
)
|
||||
|
||||
// Pin OpenTelemetry SDK to v1.32.0 for compatibility with distribution/distribution
|
||||
replace (
|
||||
go.opentelemetry.io/otel/sdk => go.opentelemetry.io/otel/sdk v1.32.0
|
||||
go.opentelemetry.io/otel/sdk/log => go.opentelemetry.io/otel/sdk/log v0.8.0
|
||||
go.opentelemetry.io/otel/sdk/metric => go.opentelemetry.io/otel/sdk/metric v1.32.0
|
||||
)
|
||||
|
||||
@@ -151,13 +151,24 @@ type TagWithPlatforms struct {
|
||||
IsMultiArch bool
|
||||
}
|
||||
|
||||
// VulnerabilitySummary represents vulnerability counts by severity
|
||||
type VulnerabilitySummary struct {
|
||||
Critical int
|
||||
High int
|
||||
Medium int
|
||||
Low int
|
||||
Total int
|
||||
}
|
||||
|
||||
// ManifestWithMetadata extends Manifest with tags and platform information
|
||||
type ManifestWithMetadata struct {
|
||||
Manifest
|
||||
Tags []string
|
||||
Platforms []PlatformInfo
|
||||
PlatformCount int
|
||||
IsManifestList bool
|
||||
Reachable bool // Whether the hold endpoint is reachable
|
||||
Pending bool // Whether health check is still in progress
|
||||
Tags []string
|
||||
Platforms []PlatformInfo
|
||||
PlatformCount int
|
||||
IsManifestList bool
|
||||
Reachable bool // Whether the hold endpoint is reachable
|
||||
Pending bool // Whether health check is still in progress
|
||||
Vulnerabilities *VulnerabilitySummary
|
||||
HasVulnerabilities bool
|
||||
}
|
||||
|
||||
@@ -3,9 +3,14 @@ package handlers
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -30,6 +35,176 @@ type RepositoryPageHandler struct {
|
||||
ReadmeCache *readme.Cache
|
||||
}
|
||||
|
||||
// queryVulnerabilities queries the hold service for vulnerability scan results
|
||||
func (h *RepositoryPageHandler) queryVulnerabilities(ctx context.Context, holdEndpoint string, digest string) (*db.VulnerabilitySummary, error) {
|
||||
// Skip if no hold endpoint
|
||||
if holdEndpoint == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Query referrers endpoint for vulnerability scan results
|
||||
// Match the artifactType used by the scanner in pkg/hold/scanner/storage.go
|
||||
artifactType := "application/vnd.atcr.vulnerabilities+json"
|
||||
|
||||
// Properly encode query parameters (especially the + in the media type)
|
||||
queryParams := url.Values{}
|
||||
queryParams.Set("digest", digest)
|
||||
queryParams.Set("artifactType", artifactType)
|
||||
requestURL := fmt.Sprintf("%s/xrpc/io.atcr.hold.getReferrers?%s", holdEndpoint, queryParams.Encode())
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", requestURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
// No scan results found
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("failed to query referrers: %s - %s", resp.Status, string(body))
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var result struct {
|
||||
Referrers []struct {
|
||||
Annotations map[string]string `json:"annotations"`
|
||||
} `json:"referrers"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode referrers response: %w", err)
|
||||
}
|
||||
|
||||
slog.Debug("Received referrers response",
|
||||
"digest", digest,
|
||||
"referrerCount", len(result.Referrers))
|
||||
|
||||
// Find the most recent vulnerability scan result
|
||||
if len(result.Referrers) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Parse vulnerability counts from annotations
|
||||
// Match the annotation keys used by the scanner in pkg/hold/scanner/storage.go
|
||||
annotations := result.Referrers[0].Annotations
|
||||
slog.Debug("First referrer annotations",
|
||||
"digest", digest,
|
||||
"annotations", annotations,
|
||||
"annotationsLen", len(annotations))
|
||||
|
||||
summary := &db.VulnerabilitySummary{}
|
||||
|
||||
if critical, ok := annotations["io.atcr.vuln.critical"]; ok {
|
||||
summary.Critical, _ = strconv.Atoi(critical)
|
||||
}
|
||||
if high, ok := annotations["io.atcr.vuln.high"]; ok {
|
||||
summary.High, _ = strconv.Atoi(high)
|
||||
}
|
||||
if medium, ok := annotations["io.atcr.vuln.medium"]; ok {
|
||||
summary.Medium, _ = strconv.Atoi(medium)
|
||||
}
|
||||
if low, ok := annotations["io.atcr.vuln.low"]; ok {
|
||||
summary.Low, _ = strconv.Atoi(low)
|
||||
}
|
||||
if total, ok := annotations["io.atcr.vuln.total"]; ok {
|
||||
summary.Total, _ = strconv.Atoi(total)
|
||||
}
|
||||
|
||||
// If Total is missing or 0, calculate from individual counts
|
||||
if summary.Total == 0 {
|
||||
summary.Total = summary.Critical + summary.High + summary.Medium + summary.Low
|
||||
}
|
||||
|
||||
slog.Debug("Parsed vulnerability summary",
|
||||
"digest", digest,
|
||||
"critical", summary.Critical,
|
||||
"high", summary.High,
|
||||
"medium", summary.Medium,
|
||||
"low", summary.Low,
|
||||
"total", summary.Total)
|
||||
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
// HandleVulnerabilityDetails returns the full vulnerability report for a manifest
|
||||
func (h *RepositoryPageHandler) HandleVulnerabilityDetails(w http.ResponseWriter, r *http.Request) {
|
||||
digest := r.URL.Query().Get("digest")
|
||||
holdEndpoint := r.URL.Query().Get("holdEndpoint")
|
||||
|
||||
if digest == "" || holdEndpoint == "" {
|
||||
http.Error(w, "digest and holdEndpoint required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Query referrers to get the vulnerability report digest
|
||||
artifactType := "application/vnd.atcr.vulnerabilities+json"
|
||||
queryParams := url.Values{}
|
||||
queryParams.Set("digest", digest)
|
||||
queryParams.Set("artifactType", artifactType)
|
||||
requestURL := fmt.Sprintf("%s/xrpc/io.atcr.hold.getReferrers?%s", holdEndpoint, queryParams.Encode())
|
||||
|
||||
req, err := http.NewRequestWithContext(r.Context(), "GET", requestURL, nil)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
http.Error(w, "No vulnerability scan found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
http.Error(w, "Failed to query referrers", resp.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse response - now includes the vulnerability report data directly
|
||||
var result struct {
|
||||
Referrers []struct {
|
||||
Digest string `json:"digest"`
|
||||
Annotations map[string]string `json:"annotations"`
|
||||
ReportData map[string]interface{} `json:"reportData"` // The actual vulnerability report
|
||||
} `json:"referrers"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
http.Error(w, "Failed to decode referrers response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if len(result.Referrers) == 0 {
|
||||
http.Error(w, "No vulnerability scan found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if reportData is included
|
||||
if result.Referrers[0].ReportData == nil {
|
||||
http.Error(w, "Vulnerability report data not available", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Return the vulnerability report JSON directly
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(result.Referrers[0].ReportData)
|
||||
}
|
||||
|
||||
func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
handle := chi.URLParam(r, "handle")
|
||||
repository := chi.URLParam(r, "repository")
|
||||
@@ -60,6 +235,44 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
// Query vulnerability scan results for each manifest (concurrent with 2s timeout)
|
||||
{
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
|
||||
for i := range manifests {
|
||||
// Skip manifest lists - only query for image manifests
|
||||
if manifests[i].IsManifestList {
|
||||
continue
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
|
||||
vulnerabilities, err := h.queryVulnerabilities(ctx, manifests[idx].Manifest.HoldEndpoint, manifests[idx].Manifest.Digest)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to query vulnerabilities",
|
||||
"digest", manifests[idx].Manifest.Digest,
|
||||
"error", err)
|
||||
return
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
if vulnerabilities != nil && vulnerabilities.Total > 0 {
|
||||
manifests[idx].Vulnerabilities = vulnerabilities
|
||||
manifests[idx].HasVulnerabilities = true
|
||||
}
|
||||
mu.Unlock()
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// Check health status for each manifest's hold endpoint (concurrent with 1s timeout)
|
||||
if h.HealthChecker != nil {
|
||||
// Create context with 1 second deadline for fast-fail
|
||||
|
||||
@@ -107,15 +107,22 @@ func (p *Processor) ProcessManifest(ctx context.Context, did string, recordData
|
||||
// Detect manifest type
|
||||
isManifestList := len(manifestRecord.Manifests) > 0
|
||||
|
||||
// Parse CreatedAt timestamp
|
||||
createdAt, err := time.Parse(time.RFC3339, manifestRecord.CreatedAt)
|
||||
if err != nil {
|
||||
// If parsing fails, use current time
|
||||
createdAt = time.Now()
|
||||
}
|
||||
|
||||
// Prepare manifest for insertion (WITHOUT annotation fields)
|
||||
manifest := &db.Manifest{
|
||||
DID: did,
|
||||
Repository: manifestRecord.Repository,
|
||||
Digest: manifestRecord.Digest,
|
||||
MediaType: manifestRecord.MediaType,
|
||||
SchemaVersion: manifestRecord.SchemaVersion,
|
||||
SchemaVersion: int(manifestRecord.SchemaVersion),
|
||||
HoldEndpoint: manifestRecord.HoldEndpoint,
|
||||
CreatedAt: manifestRecord.CreatedAt,
|
||||
CreatedAt: createdAt,
|
||||
// Annotations removed - stored separately in repository_annotations table
|
||||
}
|
||||
|
||||
|
||||
@@ -148,7 +148,7 @@ func TestProcessManifest_ImageManifest(t *testing.T) {
|
||||
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
||||
SchemaVersion: 2,
|
||||
HoldEndpoint: "did:web:hold01.atcr.io",
|
||||
CreatedAt: time.Now(),
|
||||
CreatedAt: time.Now().String(),
|
||||
Config: &atproto.BlobReference{
|
||||
Digest: "sha256:config123",
|
||||
Size: 1234,
|
||||
@@ -247,7 +247,7 @@ func TestProcessManifest_ManifestList(t *testing.T) {
|
||||
MediaType: "application/vnd.oci.image.index.v1+json",
|
||||
SchemaVersion: 2,
|
||||
HoldEndpoint: "did:web:hold01.atcr.io",
|
||||
CreatedAt: time.Now(),
|
||||
CreatedAt: time.Now().String(),
|
||||
Manifests: []atproto.ManifestReference{
|
||||
{
|
||||
Digest: "sha256:amd64manifest",
|
||||
@@ -471,7 +471,7 @@ func TestProcessManifest_Duplicate(t *testing.T) {
|
||||
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
||||
SchemaVersion: 2,
|
||||
HoldEndpoint: "did:web:hold01.atcr.io",
|
||||
CreatedAt: time.Now(),
|
||||
CreatedAt: time.Now().String(),
|
||||
}
|
||||
|
||||
// Marshal to bytes for ProcessManifest
|
||||
@@ -523,7 +523,7 @@ func TestProcessManifest_EmptyAnnotations(t *testing.T) {
|
||||
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
||||
SchemaVersion: 2,
|
||||
HoldEndpoint: "did:web:hold01.atcr.io",
|
||||
CreatedAt: time.Now(),
|
||||
CreatedAt: time.Now().String(),
|
||||
Annotations: nil,
|
||||
}
|
||||
|
||||
|
||||
@@ -307,6 +307,11 @@ func (s *ManifestStore) notifyHoldAboutManifest(ctx context.Context, manifestRec
|
||||
}
|
||||
|
||||
// Add layers if present
|
||||
slog.Debug("Preparing manifest notification",
|
||||
"repository", s.ctx.Repository,
|
||||
"tag", tag,
|
||||
"manifestLayers", len(manifestRecord.Layers))
|
||||
|
||||
if len(manifestRecord.Layers) > 0 {
|
||||
layers := make([]map[string]any, len(manifestRecord.Layers))
|
||||
for i, layer := range manifestRecord.Layers {
|
||||
@@ -317,16 +322,29 @@ func (s *ManifestStore) notifyHoldAboutManifest(ctx context.Context, manifestRec
|
||||
}
|
||||
}
|
||||
manifestData["layers"] = layers
|
||||
slog.Debug("Added layers to notification", "layerCount", len(layers))
|
||||
} else {
|
||||
slog.Warn("Manifest has no layers",
|
||||
"repository", s.ctx.Repository,
|
||||
"tag", tag,
|
||||
"digest", manifestDigest,
|
||||
"mediaType", manifestRecord.MediaType)
|
||||
}
|
||||
|
||||
notifyReq := map[string]any{
|
||||
"repository": s.ctx.Repository,
|
||||
"tag": tag,
|
||||
"userDid": s.ctx.DID,
|
||||
"userHandle": s.ctx.Handle,
|
||||
"manifest": manifestData,
|
||||
"repository": s.ctx.Repository,
|
||||
"tag": tag,
|
||||
"manifestDigest": manifestDigest,
|
||||
"userDid": s.ctx.DID,
|
||||
"userHandle": s.ctx.Handle,
|
||||
"manifest": manifestData,
|
||||
}
|
||||
|
||||
slog.Debug("Sending manifest notification",
|
||||
"repository", s.ctx.Repository,
|
||||
"holdEndpoint", holdEndpoint,
|
||||
"hasLayers", manifestData["layers"] != nil)
|
||||
|
||||
// Marshal request
|
||||
reqBody, err := json.Marshal(notifyReq)
|
||||
if err != nil {
|
||||
|
||||
@@ -176,6 +176,33 @@
|
||||
{{ else }}
|
||||
<span class="manifest-type"><i data-lucide="file-text"></i> Image</span>
|
||||
{{ end }}
|
||||
{{ if .HasVulnerabilities }}
|
||||
<div class="vuln-badges-link"
|
||||
onclick="showVulnerabilities('{{ .Manifest.Digest }}', '{{ .Manifest.HoldEndpoint }}')"
|
||||
style="cursor: pointer;"
|
||||
title="Click to view vulnerability details">
|
||||
{{ if gt .Vulnerabilities.Critical 0 }}
|
||||
<span class="vuln-badge vuln-critical" title="Critical vulnerabilities">
|
||||
<i data-lucide="alert-octagon"></i> {{ .Vulnerabilities.Critical }} Critical
|
||||
</span>
|
||||
{{ end }}
|
||||
{{ if gt .Vulnerabilities.High 0 }}
|
||||
<span class="vuln-badge vuln-high" title="High severity vulnerabilities">
|
||||
<i data-lucide="alert-triangle"></i> {{ .Vulnerabilities.High }} High
|
||||
</span>
|
||||
{{ end }}
|
||||
{{ if gt .Vulnerabilities.Medium 0 }}
|
||||
<span class="vuln-badge vuln-medium" title="Medium severity vulnerabilities">
|
||||
<i data-lucide="alert-circle"></i> {{ .Vulnerabilities.Medium }} Medium
|
||||
</span>
|
||||
{{ end }}
|
||||
{{ if gt .Vulnerabilities.Low 0 }}
|
||||
<span class="vuln-badge vuln-low" title="Low severity vulnerabilities">
|
||||
<i data-lucide="info"></i> {{ .Vulnerabilities.Low }} Low
|
||||
</span>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ if .Pending }}
|
||||
<span class="checking-badge"
|
||||
hx-get="/api/manifest-health?endpoint={{ .Manifest.HoldEndpoint | urlquery }}"
|
||||
@@ -242,6 +269,22 @@
|
||||
<!-- Modal container for HTMX -->
|
||||
<div id="modal"></div>
|
||||
|
||||
<!-- Vulnerability Details Modal -->
|
||||
<div id="vuln-modal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-dialog vuln-modal-dialog">
|
||||
<div class="modal-header">
|
||||
<h3>Vulnerability Report</h3>
|
||||
<button class="modal-close" onclick="closeVulnModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body" id="vuln-modal-body">
|
||||
<div class="loading">Loading vulnerabilities...</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeVulnModal()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manifest Delete Confirmation Modal -->
|
||||
<div id="manifest-delete-modal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-dialog">
|
||||
@@ -357,7 +400,215 @@
|
||||
.btn-danger:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
/* Vulnerability badges */
|
||||
.vuln-badges-link {
|
||||
display: inline-flex;
|
||||
gap: 0.5rem;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vuln-badges-link:hover .vuln-badge {
|
||||
opacity: 0.8;
|
||||
transform: translateY(-1px);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.vuln-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vuln-badge svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.vuln-critical {
|
||||
background: #dc3545;
|
||||
border: 1px solid #bd2130;
|
||||
}
|
||||
|
||||
.vuln-high {
|
||||
background: #fd7e14;
|
||||
border: 1px solid #dc6502;
|
||||
}
|
||||
|
||||
.vuln-medium {
|
||||
background: #ffc107;
|
||||
border: 1px solid #e0a800;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.vuln-low {
|
||||
background: #6c757d;
|
||||
border: 1px solid #5a6268;
|
||||
}
|
||||
|
||||
/* Vulnerability modal specific styles */
|
||||
.vuln-modal-dialog {
|
||||
max-width: 900px;
|
||||
max-height: 80vh;
|
||||
}
|
||||
|
||||
.vuln-modal-dialog .modal-body {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.vuln-summary {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: var(--bg-tertiary, #2a2a2a);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.vuln-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.vuln-table th {
|
||||
text-align: left;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-tertiary, #2a2a2a);
|
||||
border-bottom: 2px solid var(--border-color, #333);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vuln-table td {
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid var(--border-color, #333);
|
||||
}
|
||||
|
||||
.vuln-table tr:hover {
|
||||
background: var(--bg-hover, #3a3a3a);
|
||||
}
|
||||
|
||||
.vuln-id {
|
||||
font-family: monospace;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vuln-severity-critical {
|
||||
color: #dc3545;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vuln-severity-high {
|
||||
color: #fd7e14;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vuln-severity-medium {
|
||||
color: #ffc107;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vuln-severity-low {
|
||||
color: #6c757d;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-muted, #999);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
function showVulnerabilities(digest, holdEndpoint) {
|
||||
const modal = document.getElementById('vuln-modal');
|
||||
const modalBody = document.getElementById('vuln-modal-body');
|
||||
|
||||
// Show modal with loading state
|
||||
modal.style.display = 'flex';
|
||||
modalBody.innerHTML = '<div class="loading">Loading vulnerabilities...</div>';
|
||||
|
||||
// Fetch vulnerability data
|
||||
const url = `/api/vulnerabilities?digest=${encodeURIComponent(digest)}&holdEndpoint=${encodeURIComponent(holdEndpoint)}`;
|
||||
|
||||
fetch(url)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
renderVulnerabilities(data, modalBody);
|
||||
})
|
||||
.catch(error => {
|
||||
modalBody.innerHTML = `<div class="loading" style="color: #dc3545;">Failed to load vulnerabilities: ${error.message}</div>`;
|
||||
});
|
||||
}
|
||||
|
||||
function renderVulnerabilities(data, container) {
|
||||
const summary = data.summary || {};
|
||||
const matches = data.matches || [];
|
||||
|
||||
let html = '';
|
||||
|
||||
// Summary
|
||||
html += '<div class="vuln-summary">';
|
||||
if (summary.critical > 0) html += `<span class="vuln-badge vuln-critical">${summary.critical} Critical</span>`;
|
||||
if (summary.high > 0) html += `<span class="vuln-badge vuln-high">${summary.high} High</span>`;
|
||||
if (summary.medium > 0) html += `<span class="vuln-badge vuln-medium">${summary.medium} Medium</span>`;
|
||||
if (summary.low > 0) html += `<span class="vuln-badge vuln-low">${summary.low} Low</span>`;
|
||||
html += `<span style="margin-left: auto;">Total: ${summary.total || matches.length}</span>`;
|
||||
html += '</div>';
|
||||
|
||||
// Vulnerabilities table
|
||||
if (matches.length === 0) {
|
||||
html += '<p>No vulnerabilities found.</p>';
|
||||
} else {
|
||||
html += '<table class="vuln-table">';
|
||||
html += '<thead><tr><th>CVE</th><th>Severity</th><th>Package</th><th>Installed</th><th>Fixed In</th></tr></thead>';
|
||||
html += '<tbody>';
|
||||
|
||||
matches.forEach(match => {
|
||||
const vuln = match.Vulnerability || {};
|
||||
const pkg = match.Package || {};
|
||||
const severity = (vuln.Metadata?.Severity || 'Unknown').toLowerCase();
|
||||
const severityClass = `vuln-severity-${severity}`;
|
||||
|
||||
html += '<tr>';
|
||||
html += `<td class="vuln-id">${vuln.ID || 'N/A'}</td>`;
|
||||
html += `<td class="${severityClass}">${vuln.Metadata?.Severity || 'Unknown'}</td>`;
|
||||
html += `<td>${pkg.Name || 'N/A'}</td>`;
|
||||
html += `<td>${pkg.Version || 'N/A'}</td>`;
|
||||
html += `<td>${vuln.Fix?.Versions?.join(', ') || 'No fix available'}</td>`;
|
||||
html += '</tr>';
|
||||
});
|
||||
|
||||
html += '</tbody></table>';
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function closeVulnModal() {
|
||||
document.getElementById('vuln-modal').style.display = 'none';
|
||||
}
|
||||
|
||||
// Close modal when clicking outside
|
||||
document.getElementById('vuln-modal')?.addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeVulnModal();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{ end }}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -266,15 +266,15 @@ func (c *Client) ListRecords(ctx context.Context, collection string, limit int)
|
||||
// ATProtoBlobRef represents a reference to a blob in ATProto's native blob storage
|
||||
// This is different from OCIBlobDescriptor which describes OCI image layers
|
||||
type ATProtoBlobRef struct {
|
||||
Type string `json:"$type"`
|
||||
Ref Link `json:"ref"`
|
||||
MimeType string `json:"mimeType"`
|
||||
Size int64 `json:"size"`
|
||||
Type string `json:"$type" cborgen:"$type"`
|
||||
Ref Link `json:"ref" cborgen:"ref"`
|
||||
MimeType string `json:"mimeType" cborgen:"mimeType"`
|
||||
Size int64 `json:"size" cborgen:"size"`
|
||||
}
|
||||
|
||||
// Link represents an IPFS link to blob content
|
||||
type Link struct {
|
||||
Link string `json:"$link"`
|
||||
Link string `json:"$link" cborgen:"$link"`
|
||||
}
|
||||
|
||||
// UploadBlob uploads binary data to the PDS and returns a blob reference
|
||||
|
||||
@@ -45,6 +45,12 @@ const (
|
||||
// Request: {"repository": "...", "tag": "...", "userDid": "...", "userHandle": "...", "manifest": {...}}
|
||||
// Response: {"success": true, "layersCreated": 5, "postCreated": true, "postUri": "at://..."}
|
||||
HoldNotifyManifest = "/xrpc/io.atcr.hold.notifyManifest"
|
||||
|
||||
// HoldGetReferrers queries for ORAS artifacts that reference a subject manifest (SBOM, signatures, scan reports).
|
||||
// Method: GET
|
||||
// Query: digest={sha256:...}&artifactType={optional-filter}
|
||||
// Response: {"referrers": [{"digest": "...", "artifactType": "...", "annotations": {...}}, ...]}
|
||||
HoldGetReferrers = "/xrpc/io.atcr.hold.getReferrers"
|
||||
)
|
||||
|
||||
// Hold service crew management endpoints (io.atcr.hold.*)
|
||||
|
||||
@@ -25,12 +25,18 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Generate map-style encoders for CrewRecord, CaptainRecord, LayerRecord, and TangledProfileRecord
|
||||
// Generate map-style encoders for all ATProto records and nested types
|
||||
if err := cbg.WriteMapEncodersToFile("cbor_gen.go", "atproto",
|
||||
atproto.CrewRecord{},
|
||||
atproto.CaptainRecord{},
|
||||
atproto.LayerRecord{},
|
||||
atproto.TangledProfileRecord{},
|
||||
atproto.ManifestRecord{},
|
||||
atproto.BlobReference{},
|
||||
atproto.ManifestReference{},
|
||||
atproto.Platform{},
|
||||
atproto.ATProtoBlobRef{},
|
||||
atproto.Link{},
|
||||
); err != nil {
|
||||
fmt.Printf("Failed to generate CBOR encoders: %v\n", err)
|
||||
os.Exit(1)
|
||||
|
||||
+50
-32
@@ -53,109 +53,125 @@ const (
|
||||
// This follows the OCI image manifest specification but stored as an ATProto record
|
||||
type ManifestRecord struct {
|
||||
// Type should be "io.atcr.manifest"
|
||||
Type string `json:"$type"`
|
||||
Type string `json:"$type" cborgen:"$type"`
|
||||
|
||||
// Repository is the name of the repository (e.g., "myapp")
|
||||
Repository string `json:"repository"`
|
||||
Repository string `json:"repository" cborgen:"repository"`
|
||||
|
||||
// Digest is the content digest (e.g., "sha256:abc123...")
|
||||
Digest string `json:"digest"`
|
||||
Digest string `json:"digest" cborgen:"digest"`
|
||||
|
||||
// HoldDID is the DID of the hold service where blobs are stored
|
||||
// This is the primary reference for hold resolution
|
||||
// e.g., "did:web:hold01.atcr.io"
|
||||
HoldDID string `json:"holdDid,omitempty"`
|
||||
HoldDID string `json:"holdDid,omitempty" cborgen:"holdDid,omitempty"`
|
||||
|
||||
// HoldEndpoint is the hold service endpoint URL where blobs are stored (DEPRECATED)
|
||||
// Kept for backward compatibility with manifests created before DID migration
|
||||
// New manifests should use HoldDID instead
|
||||
// This is a historical reference that doesn't change even if user's default hold changes
|
||||
HoldEndpoint string `json:"holdEndpoint,omitempty"`
|
||||
HoldEndpoint string `json:"holdEndpoint,omitempty" cborgen:"holdEndpoint,omitempty"`
|
||||
|
||||
// MediaType is the OCI media type (e.g., "application/vnd.oci.image.manifest.v1+json")
|
||||
MediaType string `json:"mediaType"`
|
||||
MediaType string `json:"mediaType" cborgen:"mediaType"`
|
||||
|
||||
// ArtifactType distinguishes ORAS artifacts (SBOMs, signatures, scan reports)
|
||||
// e.g., "application/spdx+json", "application/vnd.atcr.vulnerabilities+json"
|
||||
// Empty for regular image manifests
|
||||
ArtifactType string `json:"artifactType,omitempty" cborgen:"artifactType,omitempty"`
|
||||
|
||||
// OwnerDID is the DID of the user who owns this manifest (for multi-tenant holds)
|
||||
// Used for ORAS artifacts stored in hold's PDS to track ownership
|
||||
OwnerDID string `json:"ownerDid,omitempty" cborgen:"ownerDid,omitempty"`
|
||||
|
||||
// ScannedAt is the timestamp when this artifact was scanned (for SBOM/vuln artifacts)
|
||||
ScannedAt string `json:"scannedAt,omitempty" cborgen:"scannedAt,omitempty"`
|
||||
|
||||
// ScannerVersion is the version of the scanner that generated this artifact
|
||||
// e.g., "syft-v1.36.0", "grype-v0.102.0"
|
||||
ScannerVersion string `json:"scannerVersion,omitempty" cborgen:"scannerVersion,omitempty"`
|
||||
|
||||
// SchemaVersion is the OCI schema version (typically 2)
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
SchemaVersion int64 `json:"schemaVersion" cborgen:"schemaVersion"`
|
||||
|
||||
// Config references the image configuration blob (for image manifests)
|
||||
// Nil for manifest lists/indexes
|
||||
Config *BlobReference `json:"config,omitempty"`
|
||||
Config *BlobReference `json:"config,omitempty" cborgen:"config,omitempty"`
|
||||
|
||||
// Layers references the filesystem layers (for image manifests)
|
||||
// Empty for manifest lists/indexes
|
||||
Layers []BlobReference `json:"layers,omitempty"`
|
||||
Layers []BlobReference `json:"layers,omitempty" cborgen:"layers,omitempty"`
|
||||
|
||||
// Manifests references other manifests (for manifest lists/indexes)
|
||||
// Empty for image manifests
|
||||
Manifests []ManifestReference `json:"manifests,omitempty"`
|
||||
Manifests []ManifestReference `json:"manifests,omitempty" cborgen:"manifests,omitempty"`
|
||||
|
||||
// Annotations contains arbitrary metadata
|
||||
Annotations map[string]string `json:"annotations,omitempty"`
|
||||
Annotations map[string]string `json:"annotations,omitempty" cborgen:"annotations"`
|
||||
|
||||
// Subject references another manifest (for attestations, signatures, etc.)
|
||||
Subject *BlobReference `json:"subject,omitempty"`
|
||||
Subject *BlobReference `json:"subject,omitempty" cborgen:"subject,omitempty"`
|
||||
|
||||
// ManifestBlob is a reference to the manifest blob stored in ATProto blob storage
|
||||
ManifestBlob *ATProtoBlobRef `json:"manifestBlob,omitempty"`
|
||||
ManifestBlob *ATProtoBlobRef `json:"manifestBlob,omitempty" cborgen:"manifestBlob,omitempty"`
|
||||
|
||||
// CreatedAt timestamp
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
// CreatedAt timestamp (RFC3339)
|
||||
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
|
||||
}
|
||||
|
||||
// BlobReference represents a reference to a blob (layer or config)
|
||||
// Blobs are stored in S3 and referenced by digest
|
||||
type BlobReference struct {
|
||||
// MediaType of the blob
|
||||
MediaType string `json:"mediaType"`
|
||||
MediaType string `json:"mediaType" cborgen:"mediaType"`
|
||||
|
||||
// Digest is the content digest (e.g., "sha256:abc123...")
|
||||
Digest string `json:"digest"`
|
||||
Digest string `json:"digest" cborgen:"digest"`
|
||||
|
||||
// Size in bytes
|
||||
Size int64 `json:"size"`
|
||||
Size int64 `json:"size" cborgen:"size"`
|
||||
|
||||
// URLs where the blob can be retrieved (S3 URLs)
|
||||
URLs []string `json:"urls,omitempty"`
|
||||
URLs []string `json:"urls,omitempty" cborgen:"urls,omitempty"`
|
||||
|
||||
// Annotations for the blob
|
||||
Annotations map[string]string `json:"annotations,omitempty"`
|
||||
Annotations map[string]string `json:"annotations,omitempty" cborgen:"annotations"`
|
||||
}
|
||||
|
||||
// ManifestReference represents a reference to a manifest in a manifest list/index
|
||||
type ManifestReference struct {
|
||||
// MediaType of the referenced manifest
|
||||
MediaType string `json:"mediaType"`
|
||||
MediaType string `json:"mediaType" cborgen:"mediaType"`
|
||||
|
||||
// Digest is the content digest (e.g., "sha256:abc123...")
|
||||
Digest string `json:"digest"`
|
||||
Digest string `json:"digest" cborgen:"digest"`
|
||||
|
||||
// Size in bytes
|
||||
Size int64 `json:"size"`
|
||||
Size int64 `json:"size" cborgen:"size"`
|
||||
|
||||
// Platform describes the platform/architecture this manifest is for
|
||||
Platform *Platform `json:"platform,omitempty"`
|
||||
Platform *Platform `json:"platform,omitempty" cborgen:"platform,omitempty"`
|
||||
|
||||
// Annotations for the manifest reference
|
||||
Annotations map[string]string `json:"annotations,omitempty"`
|
||||
Annotations map[string]string `json:"annotations,omitempty" cborgen:"annotations"`
|
||||
}
|
||||
|
||||
// Platform describes the platform (OS/architecture) for a manifest
|
||||
type Platform struct {
|
||||
// Architecture is the CPU architecture (e.g., "amd64", "arm64", "arm")
|
||||
Architecture string `json:"architecture"`
|
||||
Architecture string `json:"architecture" cborgen:"architecture"`
|
||||
|
||||
// OS is the operating system (e.g., "linux", "windows", "darwin")
|
||||
OS string `json:"os"`
|
||||
OS string `json:"os" cborgen:"os"`
|
||||
|
||||
// OSVersion is the optional OS version
|
||||
OSVersion string `json:"os.version,omitempty"`
|
||||
OSVersion string `json:"os.version,omitempty" cborgen:"os.version,omitempty"`
|
||||
|
||||
// OSFeatures is an optional list of OS features
|
||||
OSFeatures []string `json:"os.features,omitempty"`
|
||||
OSFeatures []string `json:"os.features,omitempty" cborgen:"os.features,omitempty"`
|
||||
|
||||
// Variant is the optional CPU variant (e.g., "v7" for ARM)
|
||||
Variant string `json:"variant,omitempty"`
|
||||
Variant string `json:"variant,omitempty" cborgen:"variant,omitempty"`
|
||||
}
|
||||
|
||||
// NewManifestRecord creates a new manifest record from OCI manifest JSON
|
||||
@@ -164,6 +180,7 @@ func NewManifestRecord(repository, digest string, ociManifest []byte) (*Manifest
|
||||
var ociData struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
MediaType string `json:"mediaType"`
|
||||
ArtifactType string `json:"artifactType,omitempty"`
|
||||
Config json.RawMessage `json:"config,omitempty"`
|
||||
Layers []json.RawMessage `json:"layers,omitempty"`
|
||||
Manifests []json.RawMessage `json:"manifests,omitempty"`
|
||||
@@ -195,10 +212,11 @@ func NewManifestRecord(repository, digest string, ociManifest []byte) (*Manifest
|
||||
Repository: repository,
|
||||
Digest: digest,
|
||||
MediaType: ociData.MediaType,
|
||||
SchemaVersion: ociData.SchemaVersion,
|
||||
ArtifactType: ociData.ArtifactType,
|
||||
SchemaVersion: int64(ociData.SchemaVersion),
|
||||
Annotations: ociData.Annotations,
|
||||
// ManifestBlob will be set by the caller after uploading to blob storage
|
||||
CreatedAt: time.Now(),
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
if isManifestList {
|
||||
|
||||
@@ -146,7 +146,7 @@ func TestNewManifestRecord(t *testing.T) {
|
||||
if record.Annotations["org.opencontainers.image.created"] != "2025-01-01T00:00:00Z" {
|
||||
t.Errorf("Annotations missing expected key")
|
||||
}
|
||||
if record.CreatedAt.IsZero() {
|
||||
if record.CreatedAt == "0" {
|
||||
t.Error("CreatedAt should not be zero")
|
||||
}
|
||||
if record.Subject != nil {
|
||||
|
||||
@@ -22,6 +22,7 @@ type Config struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Registration RegistrationConfig `yaml:"registration"`
|
||||
Database DatabaseConfig `yaml:"database"`
|
||||
Scanner ScannerConfig `yaml:"scanner"`
|
||||
}
|
||||
|
||||
// RegistrationConfig defines auto-registration settings
|
||||
@@ -85,6 +86,27 @@ type DatabaseConfig struct {
|
||||
KeyPath string `yaml:"key_path"`
|
||||
}
|
||||
|
||||
// ScannerConfig defines SBOM and vulnerability scanning settings
|
||||
type ScannerConfig struct {
|
||||
// Enabled controls whether SBOM scanning is enabled (from env: HOLD_SBOM_ENABLED)
|
||||
Enabled bool `yaml:"enabled"`
|
||||
|
||||
// Workers is the number of concurrent scan workers (from env: HOLD_SBOM_WORKERS)
|
||||
Workers int `yaml:"workers"`
|
||||
|
||||
// VulnEnabled controls whether vulnerability scanning is enabled (from env: HOLD_VULN_ENABLED)
|
||||
VulnEnabled bool `yaml:"vuln_enabled"`
|
||||
|
||||
// VulnDBPath is the path to store Grype vulnerability database (from env: HOLD_VULN_DB_PATH)
|
||||
// Defaults to {DatabasePath}/grype-db
|
||||
VulnDBPath string `yaml:"vuln_db_path"`
|
||||
|
||||
// VulnDBUpdateInterval is how often to update the vulnerability database (from env: HOLD_VULN_DB_UPDATE_INTERVAL)
|
||||
// Format: duration string (e.g., "24h", "1h30m")
|
||||
// Defaults to 24 hours
|
||||
VulnDBUpdateInterval time.Duration `yaml:"vuln_db_update_interval"`
|
||||
}
|
||||
|
||||
// LoadConfigFromEnv loads all configuration from environment variables
|
||||
func LoadConfigFromEnv() (*Config, error) {
|
||||
cfg := &Config{
|
||||
@@ -121,6 +143,24 @@ func LoadConfigFromEnv() (*Config, error) {
|
||||
cfg.Database.KeyPath = filepath.Join(cfg.Database.Path, "signing.key")
|
||||
}
|
||||
|
||||
// Scanner configuration (optional - enables SBOM/vulnerability scanning)
|
||||
cfg.Scanner.Enabled = os.Getenv("HOLD_SBOM_ENABLED") == "true"
|
||||
cfg.Scanner.Workers = 2 // Default
|
||||
if workersStr := os.Getenv("HOLD_SBOM_WORKERS"); workersStr != "" {
|
||||
var workersInt int
|
||||
if _, err := fmt.Sscanf(workersStr, "%d", &workersInt); err == nil && workersInt > 0 {
|
||||
cfg.Scanner.Workers = workersInt
|
||||
}
|
||||
}
|
||||
cfg.Scanner.VulnEnabled = os.Getenv("HOLD_VULN_ENABLED") == "true"
|
||||
cfg.Scanner.VulnDBPath = getEnvOrDefault("HOLD_VULN_DB_PATH", filepath.Join(cfg.Database.Path, "grype-db"))
|
||||
cfg.Scanner.VulnDBUpdateInterval = 24 * time.Hour // Default
|
||||
if intervalStr := os.Getenv("HOLD_VULN_DB_UPDATE_INTERVAL"); intervalStr != "" {
|
||||
if interval, err := time.ParseDuration(intervalStr); err == nil {
|
||||
cfg.Scanner.VulnDBUpdateInterval = interval
|
||||
}
|
||||
}
|
||||
|
||||
// Storage configuration - build from env vars based on storage type
|
||||
storageType := getEnvOrDefault("STORAGE_DRIVER", "s3")
|
||||
var err error
|
||||
|
||||
+134
-5
@@ -4,7 +4,9 @@ import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -387,14 +389,33 @@ func (h *XRPCHandler) CompleteMultipartUploadWithManager(ctx context.Context, up
|
||||
}
|
||||
slog.Debug("Source blob verified", "path", sourcePath)
|
||||
|
||||
// Move from temp to final digest location using driver
|
||||
// Driver handles path management correctly (including S3 prefix)
|
||||
if err := h.driver.Move(ctx, sourcePath, destPath); err != nil {
|
||||
slog.Error("Failed to move blob",
|
||||
// Move from temp to final digest location
|
||||
// Strategy: Use S3 CopyObject directly instead of driver's Move() to avoid
|
||||
// UploadPartCopy issues with S3-compatible services (Storj, MinIO, etc.)
|
||||
//
|
||||
// Fallback order:
|
||||
// 1. S3 CopyObject (server-side, works up to 5GB, universally supported)
|
||||
// 2. Manual copy-and-delete (slower but works for any size/backend)
|
||||
//
|
||||
// Note: We skip driver.Move() because it uses UploadPartCopy for files >100MB,
|
||||
// which many S3-compatible services don't support (returns 501 Not Implemented)
|
||||
|
||||
if err := h.s3CopyObject(ctx, sourcePath, destPath); err != nil {
|
||||
// S3 CopyObject failed (not S3, file too large, or other error)
|
||||
// Fall back to manual copy-and-delete
|
||||
slog.Warn("S3 CopyObject failed, attempting read/write fallback",
|
||||
"source", sourcePath,
|
||||
"dest", destPath,
|
||||
"error", err)
|
||||
return fmt.Errorf("failed to move blob to final location: %w", err)
|
||||
|
||||
if fallbackErr := h.copyAndDelete(ctx, sourcePath, destPath); fallbackErr != nil {
|
||||
slog.Error("All move strategies failed",
|
||||
"source", sourcePath,
|
||||
"dest", destPath,
|
||||
"s3CopyErr", err,
|
||||
"fallbackErr", fallbackErr)
|
||||
return fmt.Errorf("failed to move blob to final location: %w", fallbackErr)
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("Moved blob to final location",
|
||||
@@ -500,6 +521,114 @@ func normalizeETag(etag string) string {
|
||||
return fmt.Sprintf("\"%s\"", etag)
|
||||
}
|
||||
|
||||
// s3CopyObject uses S3's CopyObject API directly for server-side copy.
|
||||
// This works for objects up to 5GB and is supported by most S3-compatible services.
|
||||
// Falls back to copyAndDelete if S3 is not available or the operation fails.
|
||||
func (h *XRPCHandler) s3CopyObject(ctx context.Context, sourcePath, destPath string) error {
|
||||
// Check if S3 is configured
|
||||
if h.s3Service.Client == nil {
|
||||
return fmt.Errorf("S3 not configured")
|
||||
}
|
||||
|
||||
// Convert paths to S3 keys (remove leading slash and add prefix if configured)
|
||||
sourceKey := strings.TrimPrefix(sourcePath, "/")
|
||||
destKey := strings.TrimPrefix(destPath, "/")
|
||||
|
||||
if h.s3Service.PathPrefix != "" {
|
||||
sourceKey = h.s3Service.PathPrefix + "/" + sourceKey
|
||||
destKey = h.s3Service.PathPrefix + "/" + destKey
|
||||
}
|
||||
|
||||
// Construct the copy source (must be in format: bucket/key)
|
||||
copySource := h.s3Service.Bucket + "/" + sourceKey
|
||||
|
||||
// Perform server-side copy using S3 CopyObject API
|
||||
// This works for objects up to 5GB in a single operation
|
||||
_, err := h.s3Service.Client.CopyObjectWithContext(ctx, &s3.CopyObjectInput{
|
||||
Bucket: &h.s3Service.Bucket,
|
||||
Key: &destKey,
|
||||
CopySource: ©Source,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("S3 CopyObject failed: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("Successfully copied blob using S3 CopyObject (server-side)",
|
||||
"source", sourcePath,
|
||||
"dest", destPath)
|
||||
|
||||
// Delete the source after successful copy
|
||||
if err := h.driver.Delete(ctx, sourcePath); err != nil {
|
||||
slog.Warn("Failed to delete source after S3 copy (destination created successfully)",
|
||||
"source", sourcePath,
|
||||
"error", err)
|
||||
// Don't return error - the copy succeeded, cleanup is best-effort
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyAndDelete implements a fallback for Move() when the storage driver doesn't support
|
||||
// efficient server-side copy operations (e.g., S3 UploadPartCopy not available).
|
||||
// It reads from source, writes to destination, then deletes source.
|
||||
func (h *XRPCHandler) copyAndDelete(ctx context.Context, sourcePath, destPath string) error {
|
||||
// Read from source
|
||||
reader, err := h.driver.Reader(ctx, sourcePath, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open source for reading: %w", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
// Create writer for destination
|
||||
writer, err := h.driver.Writer(ctx, destPath, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create destination writer: %w", err)
|
||||
}
|
||||
|
||||
// Copy data from source to destination
|
||||
// Use a buffered approach to avoid loading entire blob into memory
|
||||
buf := make([]byte, 32*1024*1024) // 32MB buffer
|
||||
var totalCopied int64
|
||||
for {
|
||||
n, readErr := reader.Read(buf)
|
||||
if n > 0 {
|
||||
written, writeErr := writer.Write(buf[:n])
|
||||
if writeErr != nil {
|
||||
writer.Cancel(ctx)
|
||||
return fmt.Errorf("failed to write to destination: %w", writeErr)
|
||||
}
|
||||
totalCopied += int64(written)
|
||||
}
|
||||
if readErr != nil {
|
||||
if errors.Is(readErr, io.EOF) {
|
||||
break
|
||||
}
|
||||
writer.Cancel(ctx)
|
||||
return fmt.Errorf("failed to read from source: %w", readErr)
|
||||
}
|
||||
}
|
||||
|
||||
// Commit the destination
|
||||
if err := writer.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("failed to commit destination: %w", err)
|
||||
}
|
||||
|
||||
// Delete the source
|
||||
if err := h.driver.Delete(ctx, sourcePath); err != nil {
|
||||
slog.Warn("Failed to delete source after copy (destination committed successfully)",
|
||||
"source", sourcePath,
|
||||
"error", err)
|
||||
// Don't return error - the copy succeeded, cleanup is best-effort
|
||||
}
|
||||
|
||||
slog.Info("Successfully copied blob using fallback method",
|
||||
"source", sourcePath,
|
||||
"dest", destPath,
|
||||
"size", totalCopied)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// blobPath converts a digest (e.g., "sha256:abc123...") or temp path to a storage path
|
||||
// Distribution stores blobs as: /docker/registry/v2/blobs/{algorithm}/{xx}/{hash}/data
|
||||
// where xx is the first 2 characters of the hash for directory sharding
|
||||
|
||||
+78
-10
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
"atcr.io/pkg/hold/pds"
|
||||
"atcr.io/pkg/hold/scanner"
|
||||
"atcr.io/pkg/s3"
|
||||
storagedriver "github.com/distribution/distribution/v3/registry/storage/driver"
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -23,10 +24,17 @@ type XRPCHandler struct {
|
||||
pds *pds.HoldPDS
|
||||
httpClient pds.HTTPClient
|
||||
enableBlueskyPosts bool
|
||||
scanQueue ScanQueue // Scanner queue interface (nil if scanning disabled)
|
||||
}
|
||||
|
||||
// ScanQueue is an interface for enqueuing scan jobs
|
||||
// This allows us to pass in a scanner queue without importing the scanner package (avoiding circular deps)
|
||||
type ScanQueue interface {
|
||||
Enqueue(job any) error
|
||||
}
|
||||
|
||||
// NewXRPCHandler creates a new OCI XRPC handler
|
||||
func NewXRPCHandler(holdPDS *pds.HoldPDS, s3Service s3.S3Service, driver storagedriver.StorageDriver, disablePresignedURLs bool, enableBlueskyPosts bool, httpClient pds.HTTPClient) *XRPCHandler {
|
||||
func NewXRPCHandler(holdPDS *pds.HoldPDS, s3Service s3.S3Service, driver storagedriver.StorageDriver, disablePresignedURLs bool, enableBlueskyPosts bool, httpClient pds.HTTPClient, scanQueue ScanQueue) *XRPCHandler {
|
||||
return &XRPCHandler{
|
||||
driver: driver,
|
||||
disablePresignedURLs: disablePresignedURLs,
|
||||
@@ -35,6 +43,7 @@ func NewXRPCHandler(holdPDS *pds.HoldPDS, s3Service s3.S3Service, driver storage
|
||||
pds: holdPDS,
|
||||
httpClient: httpClient,
|
||||
enableBlueskyPosts: enableBlueskyPosts,
|
||||
scanQueue: scanQueue,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,11 +224,12 @@ func (h *XRPCHandler) HandleNotifyManifest(w http.ResponseWriter, r *http.Reques
|
||||
|
||||
// Parse request
|
||||
var req struct {
|
||||
Repository string `json:"repository"`
|
||||
Tag string `json:"tag"`
|
||||
UserDID string `json:"userDid"`
|
||||
UserHandle string `json:"userHandle"`
|
||||
Manifest struct {
|
||||
Repository string `json:"repository"`
|
||||
Tag string `json:"tag"`
|
||||
ManifestDigest string `json:"manifestDigest"`
|
||||
UserDID string `json:"userDid"`
|
||||
UserHandle string `json:"userHandle"`
|
||||
Manifest struct {
|
||||
MediaType string `json:"mediaType"`
|
||||
Config struct {
|
||||
Digest string `json:"digest"`
|
||||
@@ -238,6 +248,12 @@ func (h *XRPCHandler) HandleNotifyManifest(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("Received manifest notification",
|
||||
"repository", req.Repository,
|
||||
"tag", req.Tag,
|
||||
"layerCount", len(req.Manifest.Layers),
|
||||
"hasConfig", req.Manifest.Config.Digest != "")
|
||||
|
||||
// Verify user DID matches token
|
||||
if req.UserDID != validatedUser.DID {
|
||||
RespondError(w, http.StatusForbidden, "user DID mismatch")
|
||||
@@ -287,10 +303,11 @@ func (h *XRPCHandler) HandleNotifyManifest(w http.ResponseWriter, r *http.Reques
|
||||
var postURI string
|
||||
postCreated := false
|
||||
if postsEnabled {
|
||||
// Extract manifest digest from first layer (or use config digest as fallback)
|
||||
manifestDigest := req.Manifest.Config.Digest
|
||||
if len(req.Manifest.Layers) > 0 {
|
||||
manifestDigest = req.Manifest.Layers[0].Digest
|
||||
// Use the actual manifest digest from the request
|
||||
manifestDigest := req.ManifestDigest
|
||||
if manifestDigest == "" {
|
||||
// Fallback to config digest for backward compatibility (shouldn't happen with updated AppView)
|
||||
manifestDigest = req.Manifest.Config.Digest
|
||||
}
|
||||
|
||||
postURI, err = h.pds.CreateManifestPost(
|
||||
@@ -309,6 +326,57 @@ func (h *XRPCHandler) HandleNotifyManifest(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
}
|
||||
|
||||
// Enqueue vulnerability scan if scanner is enabled
|
||||
if h.scanQueue != nil {
|
||||
// Use the actual manifest digest from the request
|
||||
manifestDigest := req.ManifestDigest
|
||||
if manifestDigest == "" {
|
||||
// Fallback to config digest for backward compatibility (shouldn't happen with updated AppView)
|
||||
manifestDigest = req.Manifest.Config.Digest
|
||||
slog.Warn("Manifest digest not provided in notification, using config digest as fallback",
|
||||
"repository", req.Repository, "configDigest", manifestDigest)
|
||||
}
|
||||
|
||||
// Convert request layers to atproto.BlobReference
|
||||
layers := make([]atproto.BlobReference, len(req.Manifest.Layers))
|
||||
for i, layer := range req.Manifest.Layers {
|
||||
layers[i] = atproto.BlobReference{
|
||||
Digest: layer.Digest,
|
||||
Size: layer.Size,
|
||||
MediaType: layer.MediaType,
|
||||
}
|
||||
}
|
||||
|
||||
// Create properly typed scan job
|
||||
scanJob := &scanner.ScanJob{
|
||||
ManifestDigest: manifestDigest,
|
||||
Repository: req.Repository,
|
||||
Tag: req.Tag,
|
||||
UserDID: req.UserDID,
|
||||
UserHandle: req.UserHandle,
|
||||
Config: atproto.BlobReference{
|
||||
Digest: req.Manifest.Config.Digest,
|
||||
Size: req.Manifest.Config.Size,
|
||||
MediaType: "application/vnd.oci.image.config.v1+json",
|
||||
},
|
||||
Layers: layers,
|
||||
}
|
||||
|
||||
slog.Info("Enqueueing scan job",
|
||||
"repository", req.Repository,
|
||||
"layersInJob", len(layers))
|
||||
|
||||
if err := h.scanQueue.Enqueue(scanJob); err != nil {
|
||||
slog.Error("Failed to enqueue scan job", "error", err, "repository", req.Repository)
|
||||
} else {
|
||||
slog.Info("Enqueued vulnerability scan",
|
||||
"repository", req.Repository,
|
||||
"tag", req.Tag,
|
||||
"digest", manifestDigest,
|
||||
"layers", len(req.Manifest.Layers))
|
||||
}
|
||||
}
|
||||
|
||||
// Return response
|
||||
resp := map[string]any{
|
||||
"success": layersCreated > 0 || postCreated,
|
||||
|
||||
@@ -127,7 +127,7 @@ func setupTestOCIHandler(t *testing.T) (*XRPCHandler, context.Context) {
|
||||
|
||||
// Create OCI handler with buffered mode (no S3)
|
||||
mockS3 := s3.S3Service{}
|
||||
handler := NewXRPCHandler(holdPDS, mockS3, driver, true, false, mockClient)
|
||||
handler := NewXRPCHandler(holdPDS, mockS3, driver, true, false, mockClient, nil)
|
||||
|
||||
return handler, ctx
|
||||
}
|
||||
|
||||
@@ -267,6 +267,12 @@ func (p *HoldPDS) ListCollections(ctx context.Context) ([]string, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// CreateManifestRecord creates a manifest record with a specific rkey
|
||||
// Used by the scanner to store ORAS artifacts (SBOMs, vulnerability reports)
|
||||
func (p *HoldPDS) CreateManifestRecord(ctx context.Context, record *atproto.ManifestRecord, rkey string) (string, cid.Cid, error) {
|
||||
return p.repomgr.PutRecord(ctx, p.uid, atproto.ManifestCollection, rkey, record)
|
||||
}
|
||||
|
||||
// Close closes the carstore
|
||||
func (p *HoldPDS) Close() error {
|
||||
// TODO: Close session properly
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
"atcr.io/pkg/s3"
|
||||
@@ -171,6 +172,9 @@ func (h *XRPCHandler) RegisterHandlers(r chi.Router) {
|
||||
r.Get(atproto.IdentityResolveHandle, h.HandleResolveHandle)
|
||||
r.Get(atproto.ActorGetProfile, h.HandleGetProfile)
|
||||
r.Get(atproto.ActorGetProfiles, h.HandleGetProfiles)
|
||||
|
||||
// ORAS/Scanner endpoints
|
||||
r.Get(atproto.HoldGetReferrers, h.HandleGetReferrers)
|
||||
})
|
||||
|
||||
// Blob read endpoints (conditional auth based on captain.public)
|
||||
@@ -1415,3 +1419,234 @@ func getProxyURL(publicURL string, digest, did string, operation string) string
|
||||
// Clients should use multipart upload flow via com.atproto.repo.uploadBlob
|
||||
return ""
|
||||
}
|
||||
|
||||
// HandleGetReferrers queries for ORAS artifacts (SBOMs, signatures, scan reports) that reference a subject manifest
|
||||
// GET /xrpc/io.atcr.hold.getReferrers?digest=sha256:abc123&artifactType=application/vnd.atcr.vulnerabilities+json
|
||||
func (h *XRPCHandler) HandleGetReferrers(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
// Parse query parameters
|
||||
digest := r.URL.Query().Get("digest")
|
||||
artifactType := r.URL.Query().Get("artifactType")
|
||||
|
||||
if digest == "" {
|
||||
http.Error(w, "digest parameter required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("Querying referrers", "digest", digest, "artifactType", artifactType)
|
||||
|
||||
// Query all manifest records from the hold's PDS using carstore
|
||||
session, err := h.pds.carstore.ReadOnlySession(h.pds.uid)
|
||||
if err != nil {
|
||||
slog.Error("Failed to create session", "error", err)
|
||||
http.Error(w, fmt.Sprintf("failed to create session: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
head, err := h.pds.carstore.GetUserRepoHead(ctx, h.pds.uid)
|
||||
if err != nil {
|
||||
slog.Error("Failed to get repo head", "error", err)
|
||||
http.Error(w, fmt.Sprintf("failed to get repo head: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if !head.Defined() {
|
||||
// Empty repo, return empty referrers list
|
||||
slog.Info("Empty repo, no referrers found")
|
||||
response := map[string]interface{}{
|
||||
"referrers": []interface{}{},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
return
|
||||
}
|
||||
|
||||
repoHandle, err := repo.OpenRepo(ctx, session, head)
|
||||
if err != nil {
|
||||
slog.Error("Failed to open repo", "error", err)
|
||||
http.Error(w, fmt.Sprintf("failed to open repo: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Filter for referrers with matching subject
|
||||
referrers := []map[string]interface{}{}
|
||||
totalManifests := 0
|
||||
|
||||
// Iterate over all records in the manifest collection
|
||||
err = repoHandle.ForEach(ctx, atproto.ManifestCollection, func(k string, v cid.Cid) error {
|
||||
totalManifests++
|
||||
|
||||
// Get the record bytes directly from the repo
|
||||
_, recBytes, err := repoHandle.GetRecordBytes(ctx, k)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to get record", "key", k, "error", err)
|
||||
return nil // Skip this record
|
||||
}
|
||||
|
||||
// Unmarshal the CBOR bytes into our concrete type
|
||||
var manifest atproto.ManifestRecord
|
||||
if err := manifest.UnmarshalCBOR(bytes.NewReader(*recBytes)); err != nil {
|
||||
slog.Warn("Failed to unmarshal ManifestRecord", "key", k, "error", err)
|
||||
return nil // Skip this record
|
||||
}
|
||||
|
||||
// Debug: log what we found
|
||||
slog.Debug("Checking manifest",
|
||||
"key", k,
|
||||
"digest", manifest.Digest,
|
||||
"hasSubject", manifest.Subject != nil,
|
||||
"subjectDigest", func() string {
|
||||
if manifest.Subject != nil {
|
||||
return manifest.Subject.Digest
|
||||
}
|
||||
return "none"
|
||||
}(),
|
||||
"artifactType", manifest.ArtifactType,
|
||||
"mediaType", manifest.MediaType)
|
||||
|
||||
// Check if this manifest has a subject that matches the requested digest
|
||||
if manifest.Subject != nil && manifest.Subject.Digest == digest {
|
||||
// If artifactType filter is specified, only include matching artifacts
|
||||
if artifactType != "" && manifest.ArtifactType != artifactType {
|
||||
slog.Debug("Skipping referrer due to artifactType mismatch",
|
||||
"key", k,
|
||||
"digest", manifest.Digest,
|
||||
"wantArtifactType", artifactType,
|
||||
"gotArtifactType", manifest.ArtifactType)
|
||||
return nil // Skip this record
|
||||
}
|
||||
|
||||
// Build referrer response
|
||||
referrer := map[string]interface{}{
|
||||
"digest": manifest.Digest,
|
||||
"mediaType": manifest.MediaType,
|
||||
"size": 0, // We don't track manifest size currently
|
||||
"artifactType": manifest.ArtifactType,
|
||||
"annotations": manifest.Annotations,
|
||||
}
|
||||
|
||||
// Add scanner metadata if available
|
||||
if manifest.ScannedAt != "" {
|
||||
referrer["scannedAt"] = manifest.ScannedAt
|
||||
}
|
||||
if manifest.ScannerVersion != "" {
|
||||
referrer["scannerVersion"] = manifest.ScannerVersion
|
||||
}
|
||||
if manifest.OwnerDID != "" {
|
||||
referrer["ownerDid"] = manifest.OwnerDID
|
||||
}
|
||||
|
||||
// Fetch the actual vulnerability report blob data
|
||||
// manifest.Digest is the ORAS manifest digest, we need to:
|
||||
// 1. Fetch the ORAS manifest blob
|
||||
// 2. Parse it to get layers[0].digest (the actual vulnerability report)
|
||||
// 3. Fetch that blob
|
||||
|
||||
// Step 1: Fetch ORAS manifest
|
||||
orasManifestPath := fmt.Sprintf("/docker/registry/v2/blobs/sha256/%s/%s/data",
|
||||
manifest.Digest[7:9], // First 2 chars after "sha256:"
|
||||
manifest.Digest[7:]) // Full hex after "sha256:"
|
||||
|
||||
orasManifestData, err := h.storageDriver.GetContent(ctx, orasManifestPath)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to fetch ORAS manifest blob",
|
||||
"digest", manifest.Digest,
|
||||
"path", orasManifestPath,
|
||||
"error", err)
|
||||
// Continue without the blob data
|
||||
} else {
|
||||
// Step 2: Parse ORAS manifest to get vulnerability report digest
|
||||
var orasManifest struct {
|
||||
Layers []struct {
|
||||
Digest string `json:"digest"`
|
||||
} `json:"layers"`
|
||||
}
|
||||
if err := json.Unmarshal(orasManifestData, &orasManifest); err != nil {
|
||||
slog.Warn("Failed to parse ORAS manifest JSON",
|
||||
"digest", manifest.Digest,
|
||||
"error", err)
|
||||
} else if len(orasManifest.Layers) > 0 {
|
||||
// Step 3: Fetch the vulnerability report blob from layers[0]
|
||||
vulnReportDigest := orasManifest.Layers[0].Digest
|
||||
vulnReportPath := fmt.Sprintf("/docker/registry/v2/blobs/sha256/%s/%s/data",
|
||||
vulnReportDigest[7:9],
|
||||
vulnReportDigest[7:])
|
||||
|
||||
vulnReportData, err := h.storageDriver.GetContent(ctx, vulnReportPath)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to fetch vulnerability report blob",
|
||||
"digest", vulnReportDigest,
|
||||
"path", vulnReportPath,
|
||||
"error", err)
|
||||
} else {
|
||||
// Parse and include the vulnerability report
|
||||
var reportData map[string]interface{}
|
||||
if err := json.Unmarshal(vulnReportData, &reportData); err != nil {
|
||||
slog.Warn("Failed to parse vulnerability report JSON",
|
||||
"digest", vulnReportDigest,
|
||||
"error", err)
|
||||
} else {
|
||||
referrer["reportData"] = reportData
|
||||
slog.Debug("Included vulnerability report data in referrer",
|
||||
"orasDigest", manifest.Digest,
|
||||
"reportDigest", vulnReportDigest,
|
||||
"reportSize", len(vulnReportData))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
slog.Debug("Found matching referrer",
|
||||
"key", k,
|
||||
"digest", manifest.Digest,
|
||||
"artifactType", manifest.ArtifactType,
|
||||
"annotations", manifest.Annotations,
|
||||
"annotationsLen", len(manifest.Annotations))
|
||||
|
||||
referrers = append(referrers, referrer)
|
||||
}
|
||||
|
||||
return nil // Continue iteration
|
||||
})
|
||||
|
||||
if err != nil && err != repo.ErrDoneIterating && !strings.Contains(err.Error(), "done iterating") {
|
||||
slog.Error("Failed to iterate records", "error", err)
|
||||
http.Error(w, fmt.Sprintf("failed to iterate records: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Sort referrers by scannedAt timestamp (descending, most recent first)
|
||||
// This ensures the AppView gets the latest scan result when it takes the first referrer
|
||||
sort.Slice(referrers, func(i, j int) bool {
|
||||
iScanned, iOk := referrers[i]["scannedAt"].(string)
|
||||
jScanned, jOk := referrers[j]["scannedAt"].(string)
|
||||
// If both have scannedAt, compare timestamps (reverse order for descending)
|
||||
if iOk && jOk {
|
||||
return iScanned > jScanned
|
||||
}
|
||||
// If only one has scannedAt, prefer that one
|
||||
if iOk {
|
||||
return true
|
||||
}
|
||||
if jOk {
|
||||
return false
|
||||
}
|
||||
// Neither has scannedAt, maintain original order
|
||||
return false
|
||||
})
|
||||
|
||||
slog.Info("Found referrers",
|
||||
"count", len(referrers),
|
||||
"totalManifests", totalManifests,
|
||||
"digest", digest,
|
||||
"artifactType", artifactType)
|
||||
|
||||
// Return response
|
||||
response := map[string]interface{}{
|
||||
"referrers": referrers,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user