Compare commits
2 Commits
label-serv
...
vulnerabil
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b5b89b378 | ||
|
|
8c5f9da2cf |
@@ -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)
|
||||
|
||||
@@ -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_*)
|
||||
|
||||
322
go.mod
322
go.mod
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
270
pkg/hold/scanner/extractor.go
Normal file
270
pkg/hold/scanner/extractor.go
Normal file
@@ -0,0 +1,270 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// extractLayers extracts all image layers from storage to a temporary directory
|
||||
// Returns the directory path and a cleanup function
|
||||
func (w *Worker) extractLayers(ctx context.Context, job *ScanJob) (string, func(), error) {
|
||||
// Create temp directory for extraction
|
||||
// Use the database directory as the base (since we're in a scratch container with no /tmp)
|
||||
scanTmpBase := filepath.Join(w.config.Database.Path, "scanner-tmp")
|
||||
if err := os.MkdirAll(scanTmpBase, 0755); err != nil {
|
||||
return "", nil, fmt.Errorf("failed to create scanner temp base: %w", err)
|
||||
}
|
||||
|
||||
tmpDir, err := os.MkdirTemp(scanTmpBase, "scan-*")
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("failed to create temp directory: %w", err)
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
if err := os.RemoveAll(tmpDir); err != nil {
|
||||
slog.Warn("Failed to clean up temp directory", "dir", tmpDir, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create image directory structure
|
||||
imageDir := filepath.Join(tmpDir, "image")
|
||||
if err := os.MkdirAll(imageDir, 0755); err != nil {
|
||||
cleanup()
|
||||
return "", nil, fmt.Errorf("failed to create image directory: %w", err)
|
||||
}
|
||||
|
||||
// Download and extract config blob
|
||||
slog.Info("Downloading config blob", "digest", job.Config.Digest)
|
||||
configPath := filepath.Join(imageDir, "config.json")
|
||||
if err := w.downloadBlob(ctx, job.Config.Digest, configPath); err != nil {
|
||||
cleanup()
|
||||
return "", nil, fmt.Errorf("failed to download config blob: %w", err)
|
||||
}
|
||||
|
||||
// Validate config is valid JSON
|
||||
configData, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return "", nil, fmt.Errorf("failed to read config: %w", err)
|
||||
}
|
||||
var configObj map[string]interface{}
|
||||
if err := json.Unmarshal(configData, &configObj); err != nil {
|
||||
cleanup()
|
||||
return "", nil, fmt.Errorf("invalid config JSON: %w", err)
|
||||
}
|
||||
|
||||
// Create layers directory for extracted content
|
||||
layersDir := filepath.Join(imageDir, "layers")
|
||||
if err := os.MkdirAll(layersDir, 0755); err != nil {
|
||||
cleanup()
|
||||
return "", nil, fmt.Errorf("failed to create layers directory: %w", err)
|
||||
}
|
||||
|
||||
// Download and extract each layer in order (creating overlayfs-style filesystem)
|
||||
rootfsDir := filepath.Join(imageDir, "rootfs")
|
||||
if err := os.MkdirAll(rootfsDir, 0755); err != nil {
|
||||
cleanup()
|
||||
return "", nil, fmt.Errorf("failed to create rootfs directory: %w", err)
|
||||
}
|
||||
|
||||
for i, layer := range job.Layers {
|
||||
slog.Info("Extracting layer", "index", i, "digest", layer.Digest, "size", layer.Size)
|
||||
|
||||
// Download layer blob to temp file
|
||||
layerPath := filepath.Join(layersDir, fmt.Sprintf("layer-%d.tar.gz", i))
|
||||
if err := w.downloadBlob(ctx, layer.Digest, layerPath); err != nil {
|
||||
cleanup()
|
||||
return "", nil, fmt.Errorf("failed to download layer %d: %w", i, err)
|
||||
}
|
||||
|
||||
// Extract layer on top of rootfs (overlayfs style)
|
||||
if err := w.extractTarGz(layerPath, rootfsDir); err != nil {
|
||||
cleanup()
|
||||
return "", nil, fmt.Errorf("failed to extract layer %d: %w", i, err)
|
||||
}
|
||||
|
||||
// Remove layer tar.gz to save space
|
||||
os.Remove(layerPath)
|
||||
}
|
||||
|
||||
// Check what was extracted
|
||||
entries, err := os.ReadDir(rootfsDir)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to read rootfs directory", "error", err)
|
||||
} else {
|
||||
slog.Info("Successfully extracted image",
|
||||
"layers", len(job.Layers),
|
||||
"rootfs", rootfsDir,
|
||||
"topLevelEntries", len(entries),
|
||||
"sampleEntries", func() []string {
|
||||
var samples []string
|
||||
for i, e := range entries {
|
||||
if i >= 10 {
|
||||
break
|
||||
}
|
||||
samples = append(samples, e.Name())
|
||||
}
|
||||
return samples
|
||||
}())
|
||||
}
|
||||
|
||||
return rootfsDir, cleanup, nil
|
||||
}
|
||||
|
||||
// downloadBlob downloads a blob from storage to a local file
|
||||
func (w *Worker) downloadBlob(ctx context.Context, digest, destPath string) error {
|
||||
// Convert digest to storage path using distribution's sharding scheme
|
||||
// Format: /docker/registry/v2/blobs/sha256/47/4734bc89.../data
|
||||
// where 47 is the first 2 characters of the hash for directory sharding
|
||||
blobPath := blobPathForDigest(digest)
|
||||
|
||||
// Open blob from storage driver
|
||||
reader, err := w.driver.Reader(ctx, blobPath, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open blob %s: %w", digest, err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
// Create destination file
|
||||
dest, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create destination file: %w", err)
|
||||
}
|
||||
defer dest.Close()
|
||||
|
||||
// Copy blob data to file
|
||||
if _, err := io.Copy(dest, reader); err != nil {
|
||||
return fmt.Errorf("failed to copy blob data: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractTarGz extracts a tar.gz file to a destination directory (overlayfs style)
|
||||
func (w *Worker) extractTarGz(tarGzPath, destDir string) error {
|
||||
// Open tar.gz file
|
||||
file, err := os.Open(tarGzPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open tar.gz: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Create gzip reader
|
||||
gzr, err := gzip.NewReader(file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create gzip reader: %w", err)
|
||||
}
|
||||
defer gzr.Close()
|
||||
|
||||
// Create tar reader
|
||||
tr := tar.NewReader(gzr)
|
||||
|
||||
// Extract each file
|
||||
for {
|
||||
header, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read tar header: %w", err)
|
||||
}
|
||||
|
||||
// Build target path (clean to prevent path traversal)
|
||||
target := filepath.Join(destDir, filepath.Clean(header.Name))
|
||||
|
||||
// Ensure target is within destDir (security check)
|
||||
if !strings.HasPrefix(target, filepath.Clean(destDir)+string(os.PathSeparator)) {
|
||||
slog.Warn("Skipping path outside destination", "path", header.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
switch header.Typeflag {
|
||||
case tar.TypeDir:
|
||||
// Create directory
|
||||
if err := os.MkdirAll(target, os.FileMode(header.Mode)); err != nil {
|
||||
return fmt.Errorf("failed to create directory %s: %w", target, err)
|
||||
}
|
||||
|
||||
case tar.TypeReg:
|
||||
// Create parent directory
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
||||
return fmt.Errorf("failed to create parent directory: %w", err)
|
||||
}
|
||||
|
||||
// Create file
|
||||
outFile, err := os.OpenFile(target, os.O_CREATE|os.O_RDWR|os.O_TRUNC, os.FileMode(header.Mode))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file %s: %w", target, err)
|
||||
}
|
||||
|
||||
// Copy file contents
|
||||
if _, err := io.Copy(outFile, tr); err != nil {
|
||||
outFile.Close()
|
||||
return fmt.Errorf("failed to write file %s: %w", target, err)
|
||||
}
|
||||
outFile.Close()
|
||||
|
||||
case tar.TypeSymlink:
|
||||
// Create symlink
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
||||
return fmt.Errorf("failed to create parent directory for symlink: %w", err)
|
||||
}
|
||||
|
||||
// Remove existing file/symlink if it exists
|
||||
os.Remove(target)
|
||||
|
||||
if err := os.Symlink(header.Linkname, target); err != nil {
|
||||
slog.Warn("Failed to create symlink", "target", target, "link", header.Linkname, "error", err)
|
||||
}
|
||||
|
||||
case tar.TypeLink:
|
||||
// Create hard link
|
||||
linkTarget := filepath.Join(destDir, filepath.Clean(header.Linkname))
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
||||
return fmt.Errorf("failed to create parent directory for hardlink: %w", err)
|
||||
}
|
||||
|
||||
// Remove existing file if it exists
|
||||
os.Remove(target)
|
||||
|
||||
if err := os.Link(linkTarget, target); err != nil {
|
||||
slog.Warn("Failed to create hardlink", "target", target, "link", linkTarget, "error", err)
|
||||
}
|
||||
|
||||
default:
|
||||
slog.Debug("Skipping unsupported tar entry type", "type", header.Typeflag, "name", header.Name)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// blobPathForDigest converts a digest to a storage path using distribution's sharding scheme
|
||||
// Format: /docker/registry/v2/blobs/sha256/47/4734bc89.../data
|
||||
// where 47 is the first 2 characters of the hash for directory sharding
|
||||
func blobPathForDigest(digest string) string {
|
||||
// Split digest into algorithm and hash
|
||||
parts := strings.SplitN(digest, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
// Fallback for malformed digest
|
||||
return fmt.Sprintf("/docker/registry/v2/blobs/%s/data", digest)
|
||||
}
|
||||
|
||||
algorithm := parts[0]
|
||||
hash := parts[1]
|
||||
|
||||
// Use first 2 characters for sharding
|
||||
if len(hash) < 2 {
|
||||
return fmt.Sprintf("/docker/registry/v2/blobs/%s/%s/data", algorithm, hash)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("/docker/registry/v2/blobs/%s/%s/%s/data", algorithm, hash[:2], hash)
|
||||
}
|
||||
351
pkg/hold/scanner/grype.go
Normal file
351
pkg/hold/scanner/grype.go
Normal file
@@ -0,0 +1,351 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/anchore/grype/grype"
|
||||
"github.com/anchore/grype/grype/db/v6/distribution"
|
||||
"github.com/anchore/grype/grype/db/v6/installation"
|
||||
"github.com/anchore/grype/grype/distro"
|
||||
"github.com/anchore/grype/grype/match"
|
||||
"github.com/anchore/grype/grype/matcher"
|
||||
"github.com/anchore/grype/grype/matcher/dotnet"
|
||||
"github.com/anchore/grype/grype/matcher/golang"
|
||||
"github.com/anchore/grype/grype/matcher/java"
|
||||
"github.com/anchore/grype/grype/matcher/javascript"
|
||||
"github.com/anchore/grype/grype/matcher/python"
|
||||
"github.com/anchore/grype/grype/matcher/ruby"
|
||||
"github.com/anchore/grype/grype/matcher/stock"
|
||||
grypePkg "github.com/anchore/grype/grype/pkg"
|
||||
"github.com/anchore/grype/grype/vulnerability"
|
||||
"github.com/anchore/syft/syft/sbom"
|
||||
)
|
||||
|
||||
// Global vulnerability database (shared across workers)
|
||||
var (
|
||||
vulnDB vulnerability.Provider
|
||||
vulnDBLock sync.RWMutex
|
||||
)
|
||||
|
||||
// scanVulnerabilities scans an SBOM for vulnerabilities using Grype
|
||||
// Returns vulnerability report JSON, digest, summary, and any error
|
||||
func (w *Worker) scanVulnerabilities(ctx context.Context, s *sbom.SBOM) ([]byte, string, VulnerabilitySummary, error) {
|
||||
slog.Info("Scanning for vulnerabilities with Grype")
|
||||
|
||||
// Load vulnerability database (cached globally)
|
||||
store, err := w.loadVulnDatabase(ctx)
|
||||
if err != nil {
|
||||
return nil, "", VulnerabilitySummary{}, fmt.Errorf("failed to load vulnerability database: %w", err)
|
||||
}
|
||||
|
||||
// Create package context from SBOM (need distro for synthesis)
|
||||
var grypeDistro *distro.Distro
|
||||
if s.Artifacts.LinuxDistribution != nil {
|
||||
grypeDistro = distro.FromRelease(s.Artifacts.LinuxDistribution, nil)
|
||||
if grypeDistro != nil {
|
||||
slog.Info("Using distro for package synthesis",
|
||||
"name", grypeDistro.Name(),
|
||||
"version", grypeDistro.Version,
|
||||
"type", grypeDistro.Type,
|
||||
"codename", grypeDistro.Codename)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert Syft packages to Grype packages WITH distro info
|
||||
synthesisConfig := grypePkg.SynthesisConfig{
|
||||
GenerateMissingCPEs: true,
|
||||
Distro: grypePkg.DistroConfig{
|
||||
Override: grypeDistro,
|
||||
},
|
||||
}
|
||||
grypePackages := grypePkg.FromCollection(s.Artifacts.Packages, synthesisConfig)
|
||||
|
||||
slog.Info("Converted packages for vulnerability scanning",
|
||||
"syftPackages", s.Artifacts.Packages.PackageCount(),
|
||||
"grypePackages", len(grypePackages),
|
||||
"distro", func() string {
|
||||
if s.Artifacts.LinuxDistribution != nil {
|
||||
return fmt.Sprintf("%s %s", s.Artifacts.LinuxDistribution.Name, s.Artifacts.LinuxDistribution.Version)
|
||||
}
|
||||
return "none"
|
||||
}())
|
||||
|
||||
// Create matchers
|
||||
matchers := matcher.NewDefaultMatchers(matcher.Config{
|
||||
Java: java.MatcherConfig{},
|
||||
Ruby: ruby.MatcherConfig{},
|
||||
Python: python.MatcherConfig{},
|
||||
Dotnet: dotnet.MatcherConfig{},
|
||||
Javascript: javascript.MatcherConfig{},
|
||||
Golang: golang.MatcherConfig{},
|
||||
Stock: stock.MatcherConfig{},
|
||||
})
|
||||
|
||||
// Create package context with the same distro we used for synthesis
|
||||
pkgContext := grypePkg.Context{
|
||||
Source: &s.Source,
|
||||
Distro: grypeDistro,
|
||||
}
|
||||
|
||||
// Create vulnerability matcher
|
||||
vulnerabilityMatcher := &grype.VulnerabilityMatcher{
|
||||
VulnerabilityProvider: store,
|
||||
Matchers: matchers,
|
||||
NormalizeByCVE: true,
|
||||
}
|
||||
|
||||
// Find vulnerabilities
|
||||
slog.Info("Matching vulnerabilities",
|
||||
"packages", len(grypePackages),
|
||||
"distro", func() string {
|
||||
if grypeDistro != nil {
|
||||
return fmt.Sprintf("%s %s", grypeDistro.Name(), grypeDistro.Version)
|
||||
}
|
||||
return "none"
|
||||
}())
|
||||
allMatches, _, err := vulnerabilityMatcher.FindMatches(grypePackages, pkgContext)
|
||||
if err != nil {
|
||||
return nil, "", VulnerabilitySummary{}, fmt.Errorf("failed to find vulnerabilities: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("Vulnerability matching complete",
|
||||
"totalMatches", allMatches.Count())
|
||||
|
||||
// If we found 0 matches, log some diagnostic info
|
||||
if allMatches.Count() == 0 {
|
||||
slog.Warn("No vulnerability matches found - this may indicate an issue",
|
||||
"distro", func() string {
|
||||
if grypeDistro != nil {
|
||||
return fmt.Sprintf("%s %s", grypeDistro.Name(), grypeDistro.Version)
|
||||
}
|
||||
return "none"
|
||||
}(),
|
||||
"packages", len(grypePackages),
|
||||
"databaseBuilt", func() string {
|
||||
vulnDBLock.RLock()
|
||||
defer vulnDBLock.RUnlock()
|
||||
if vulnDB == nil {
|
||||
return "not loaded"
|
||||
}
|
||||
// We can't easily get the build date here without exposing internal state
|
||||
return "loaded"
|
||||
}())
|
||||
}
|
||||
|
||||
// Count vulnerabilities by severity
|
||||
summary := w.countVulnerabilitiesBySeverity(*allMatches)
|
||||
|
||||
slog.Info("Vulnerability scan complete",
|
||||
"critical", summary.Critical,
|
||||
"high", summary.High,
|
||||
"medium", summary.Medium,
|
||||
"low", summary.Low,
|
||||
"total", summary.Total)
|
||||
|
||||
// Create vulnerability report JSON
|
||||
report := map[string]interface{}{
|
||||
"matches": allMatches.Sorted(),
|
||||
"source": s.Source,
|
||||
"distro": s.Artifacts.LinuxDistribution,
|
||||
"descriptor": map[string]interface{}{
|
||||
"name": "grype",
|
||||
"version": "v0.102.0", // TODO: Get actual Grype version
|
||||
},
|
||||
"summary": summary,
|
||||
}
|
||||
|
||||
// Encode report to JSON
|
||||
reportJSON, err := json.MarshalIndent(report, "", " ")
|
||||
if err != nil {
|
||||
return nil, "", VulnerabilitySummary{}, fmt.Errorf("failed to encode vulnerability report: %w", err)
|
||||
}
|
||||
|
||||
// Calculate digest
|
||||
hash := sha256.Sum256(reportJSON)
|
||||
digest := fmt.Sprintf("sha256:%x", hash)
|
||||
|
||||
slog.Info("Vulnerability report generated", "size", len(reportJSON), "digest", digest)
|
||||
|
||||
// Upload report blob to storage
|
||||
if err := w.uploadBlob(ctx, digest, reportJSON); err != nil {
|
||||
return nil, "", VulnerabilitySummary{}, fmt.Errorf("failed to upload vulnerability report: %w", err)
|
||||
}
|
||||
|
||||
return reportJSON, digest, summary, nil
|
||||
}
|
||||
|
||||
// loadVulnDatabase loads the Grype vulnerability database (with caching)
|
||||
func (w *Worker) loadVulnDatabase(ctx context.Context) (vulnerability.Provider, error) {
|
||||
// Check if database is already loaded
|
||||
vulnDBLock.RLock()
|
||||
if vulnDB != nil {
|
||||
vulnDBLock.RUnlock()
|
||||
return vulnDB, nil
|
||||
}
|
||||
vulnDBLock.RUnlock()
|
||||
|
||||
// Acquire write lock to load database
|
||||
vulnDBLock.Lock()
|
||||
defer vulnDBLock.Unlock()
|
||||
|
||||
// Check again (another goroutine might have loaded it)
|
||||
if vulnDB != nil {
|
||||
return vulnDB, nil
|
||||
}
|
||||
|
||||
slog.Info("Loading Grype vulnerability database", "path", w.config.Scanner.VulnDBPath)
|
||||
|
||||
// Ensure database directory exists
|
||||
if err := ensureDir(w.config.Scanner.VulnDBPath); err != nil {
|
||||
return nil, fmt.Errorf("failed to create vulnerability database directory: %w", err)
|
||||
}
|
||||
|
||||
// Configure database distribution
|
||||
distConfig := distribution.DefaultConfig()
|
||||
|
||||
// Configure database installation
|
||||
installConfig := installation.Config{
|
||||
DBRootDir: w.config.Scanner.VulnDBPath,
|
||||
ValidateAge: true,
|
||||
ValidateChecksum: true,
|
||||
MaxAllowedBuiltAge: w.config.Scanner.VulnDBUpdateInterval,
|
||||
}
|
||||
|
||||
// Load database (should already be downloaded by initializeVulnDatabase)
|
||||
store, status, err := grype.LoadVulnerabilityDB(distConfig, installConfig, false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load vulnerability database (status=%v): %w (hint: database may still be downloading)", status, err)
|
||||
}
|
||||
|
||||
slog.Info("Vulnerability database loaded",
|
||||
"status", status,
|
||||
"built", status.Built,
|
||||
"location", status.Path,
|
||||
"schemaVersion", status.SchemaVersion)
|
||||
|
||||
// Check database file size to verify it has content
|
||||
if stat, err := os.Stat(status.Path); err == nil {
|
||||
slog.Info("Vulnerability database file stats",
|
||||
"size", stat.Size(),
|
||||
"sizeMB", stat.Size()/1024/1024)
|
||||
}
|
||||
|
||||
// Cache database globally
|
||||
vulnDB = store
|
||||
|
||||
slog.Info("Vulnerability database loaded successfully")
|
||||
return vulnDB, nil
|
||||
}
|
||||
|
||||
// countVulnerabilitiesBySeverity counts vulnerabilities by severity level
|
||||
func (w *Worker) countVulnerabilitiesBySeverity(matches match.Matches) VulnerabilitySummary {
|
||||
summary := VulnerabilitySummary{}
|
||||
|
||||
for m := range matches.Enumerate() {
|
||||
summary.Total++
|
||||
|
||||
// Get severity from vulnerability metadata
|
||||
if m.Vulnerability.Metadata != nil {
|
||||
severity := m.Vulnerability.Metadata.Severity
|
||||
switch severity {
|
||||
case "Critical":
|
||||
summary.Critical++
|
||||
case "High":
|
||||
summary.High++
|
||||
case "Medium":
|
||||
summary.Medium++
|
||||
case "Low":
|
||||
summary.Low++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return summary
|
||||
}
|
||||
|
||||
// initializeVulnDatabase downloads and initializes the vulnerability database on startup
|
||||
func (w *Worker) initializeVulnDatabase(ctx context.Context) error {
|
||||
slog.Info("Initializing vulnerability database", "path", w.config.Scanner.VulnDBPath)
|
||||
|
||||
// Ensure database directory exists
|
||||
if err := ensureDir(w.config.Scanner.VulnDBPath); err != nil {
|
||||
return fmt.Errorf("failed to create vulnerability database directory: %w", err)
|
||||
}
|
||||
|
||||
// Create temp directory for Grype downloads (scratch container has no /tmp)
|
||||
tmpDir := filepath.Join(w.config.Database.Path, "tmp")
|
||||
if err := ensureDir(tmpDir); err != nil {
|
||||
return fmt.Errorf("failed to create temp directory: %w", err)
|
||||
}
|
||||
|
||||
// Set TMPDIR environment variable so Grype uses our temp directory
|
||||
oldTmpDir := os.Getenv("TMPDIR")
|
||||
os.Setenv("TMPDIR", tmpDir)
|
||||
defer func() {
|
||||
if oldTmpDir != "" {
|
||||
os.Setenv("TMPDIR", oldTmpDir)
|
||||
} else {
|
||||
os.Unsetenv("TMPDIR")
|
||||
}
|
||||
}()
|
||||
|
||||
// Configure database distribution
|
||||
distConfig := distribution.DefaultConfig()
|
||||
|
||||
// Configure database installation
|
||||
installConfig := installation.Config{
|
||||
DBRootDir: w.config.Scanner.VulnDBPath,
|
||||
ValidateAge: true,
|
||||
ValidateChecksum: true,
|
||||
MaxAllowedBuiltAge: w.config.Scanner.VulnDBUpdateInterval,
|
||||
}
|
||||
|
||||
// Create distribution client for downloading
|
||||
downloader, err := distribution.NewClient(distConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create database downloader: %w", err)
|
||||
}
|
||||
|
||||
// Create curator to manage database
|
||||
curator, err := installation.NewCurator(installConfig, downloader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create database curator: %w", err)
|
||||
}
|
||||
|
||||
// Check if database already exists
|
||||
status := curator.Status()
|
||||
if !status.Built.IsZero() && status.Error == nil {
|
||||
slog.Info("Vulnerability database already exists", "built", status.Built, "schema", status.SchemaVersion)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Download database (this may take several minutes)
|
||||
slog.Info("Downloading vulnerability database (this may take 5-10 minutes)...")
|
||||
updated, err := curator.Update()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download vulnerability database: %w", err)
|
||||
}
|
||||
|
||||
if updated {
|
||||
slog.Info("Vulnerability database downloaded successfully")
|
||||
} else {
|
||||
slog.Info("Vulnerability database is up to date")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureDir creates a directory if it doesn't exist
|
||||
func ensureDir(path string) error {
|
||||
if err := os.MkdirAll(path, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create directory %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
67
pkg/hold/scanner/job.go
Normal file
67
pkg/hold/scanner/job.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
)
|
||||
|
||||
// ScanJob represents a vulnerability scanning job for a container image
|
||||
type ScanJob struct {
|
||||
// ManifestDigest is the digest of the manifest to scan
|
||||
ManifestDigest string
|
||||
|
||||
// Repository is the repository name (e.g., "alice/myapp")
|
||||
Repository string
|
||||
|
||||
// Tag is the tag name (e.g., "latest")
|
||||
Tag string
|
||||
|
||||
// UserDID is the DID of the user who owns this image
|
||||
UserDID string
|
||||
|
||||
// UserHandle is the handle of the user (for display)
|
||||
UserHandle string
|
||||
|
||||
// Config is the image config blob descriptor
|
||||
Config atproto.BlobReference
|
||||
|
||||
// Layers are the image layer blob descriptors (in order)
|
||||
Layers []atproto.BlobReference
|
||||
|
||||
// EnqueuedAt is when this job was enqueued
|
||||
EnqueuedAt time.Time
|
||||
}
|
||||
|
||||
// ScanResult represents the result of a vulnerability scan
|
||||
type ScanResult struct {
|
||||
// Job is the original scan job
|
||||
Job *ScanJob
|
||||
|
||||
// VulnerabilitiesJSON is the raw Grype JSON output
|
||||
VulnerabilitiesJSON []byte
|
||||
|
||||
// Summary contains vulnerability counts by severity
|
||||
Summary VulnerabilitySummary
|
||||
|
||||
// SBOMDigest is the digest of the SBOM blob (if SBOM was generated)
|
||||
SBOMDigest string
|
||||
|
||||
// VulnDigest is the digest of the vulnerability report blob
|
||||
VulnDigest string
|
||||
|
||||
// ScannedAt is when the scan completed
|
||||
ScannedAt time.Time
|
||||
|
||||
// ScannerVersion is the version of the scanner used
|
||||
ScannerVersion string
|
||||
}
|
||||
|
||||
// VulnerabilitySummary contains counts of vulnerabilities by severity
|
||||
type VulnerabilitySummary struct {
|
||||
Critical int `json:"critical"`
|
||||
High int `json:"high"`
|
||||
Medium int `json:"medium"`
|
||||
Low int `json:"low"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
226
pkg/hold/scanner/queue.go
Normal file
226
pkg/hold/scanner/queue.go
Normal file
@@ -0,0 +1,226 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
)
|
||||
|
||||
// Queue manages a pool of workers for scanning container images
|
||||
type Queue struct {
|
||||
jobs chan *ScanJob
|
||||
results chan *ScanResult
|
||||
workers int
|
||||
wg sync.WaitGroup
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// NewQueue creates a new scanner queue with the specified number of workers
|
||||
func NewQueue(workers int, bufferSize int) *Queue {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
return &Queue{
|
||||
jobs: make(chan *ScanJob, bufferSize),
|
||||
results: make(chan *ScanResult, bufferSize),
|
||||
workers: workers,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts the worker pool
|
||||
// The workerFunc is called for each job to perform the actual scanning
|
||||
func (q *Queue) Start(workerFunc func(context.Context, *ScanJob) (*ScanResult, error)) {
|
||||
slog.Info("Starting scanner worker pool", "workers", q.workers)
|
||||
|
||||
for i := 0; i < q.workers; i++ {
|
||||
q.wg.Add(1)
|
||||
go q.worker(i, workerFunc)
|
||||
}
|
||||
|
||||
// Start result handler goroutine
|
||||
q.wg.Add(1)
|
||||
go q.resultHandler()
|
||||
}
|
||||
|
||||
// worker processes jobs from the queue
|
||||
func (q *Queue) worker(id int, workerFunc func(context.Context, *ScanJob) (*ScanResult, error)) {
|
||||
defer q.wg.Done()
|
||||
|
||||
slog.Info("Scanner worker started", "worker_id", id)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-q.ctx.Done():
|
||||
slog.Info("Scanner worker shutting down", "worker_id", id)
|
||||
return
|
||||
|
||||
case job, ok := <-q.jobs:
|
||||
if !ok {
|
||||
slog.Info("Scanner worker: jobs channel closed", "worker_id", id)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("Scanner worker processing job",
|
||||
"worker_id", id,
|
||||
"repository", job.Repository,
|
||||
"tag", job.Tag,
|
||||
"digest", job.ManifestDigest)
|
||||
|
||||
result, err := workerFunc(q.ctx, job)
|
||||
if err != nil {
|
||||
slog.Error("Scanner worker failed to process job",
|
||||
"worker_id", id,
|
||||
"repository", job.Repository,
|
||||
"tag", job.Tag,
|
||||
"error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Send result to results channel
|
||||
select {
|
||||
case q.results <- result:
|
||||
slog.Info("Scanner worker completed job",
|
||||
"worker_id", id,
|
||||
"repository", job.Repository,
|
||||
"tag", job.Tag,
|
||||
"vulnerabilities", result.Summary.Total)
|
||||
case <-q.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// resultHandler processes scan results (for logging and metrics)
|
||||
func (q *Queue) resultHandler() {
|
||||
defer q.wg.Done()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-q.ctx.Done():
|
||||
return
|
||||
|
||||
case result, ok := <-q.results:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Log the result
|
||||
slog.Info("Scan completed",
|
||||
"repository", result.Job.Repository,
|
||||
"tag", result.Job.Tag,
|
||||
"digest", result.Job.ManifestDigest,
|
||||
"critical", result.Summary.Critical,
|
||||
"high", result.Summary.High,
|
||||
"medium", result.Summary.Medium,
|
||||
"low", result.Summary.Low,
|
||||
"total", result.Summary.Total,
|
||||
"scanner", result.ScannerVersion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enqueue adds a job to the queue
|
||||
func (q *Queue) Enqueue(jobAny any) error {
|
||||
// Type assert to ScanJob (can be map or struct from HandleNotifyManifest)
|
||||
var job *ScanJob
|
||||
|
||||
switch v := jobAny.(type) {
|
||||
case *ScanJob:
|
||||
job = v
|
||||
case map[string]interface{}:
|
||||
// Convert map to ScanJob (from HandleNotifyManifest)
|
||||
job = &ScanJob{
|
||||
ManifestDigest: v["manifestDigest"].(string),
|
||||
Repository: v["repository"].(string),
|
||||
Tag: v["tag"].(string),
|
||||
UserDID: v["userDID"].(string),
|
||||
UserHandle: v["userHandle"].(string),
|
||||
}
|
||||
|
||||
// Parse config blob reference
|
||||
if configMap, ok := v["config"].(map[string]interface{}); ok {
|
||||
job.Config = atproto.BlobReference{
|
||||
Digest: configMap["digest"].(string),
|
||||
Size: convertToInt64(configMap["size"]),
|
||||
MediaType: configMap["mediaType"].(string),
|
||||
}
|
||||
}
|
||||
|
||||
// Parse layers
|
||||
if layersSlice, ok := v["layers"].([]interface{}); ok {
|
||||
slog.Info("Parsing layers from scan job",
|
||||
"layersFound", len(layersSlice))
|
||||
job.Layers = make([]atproto.BlobReference, len(layersSlice))
|
||||
for i, layerAny := range layersSlice {
|
||||
if layerMap, ok := layerAny.(map[string]interface{}); ok {
|
||||
job.Layers[i] = atproto.BlobReference{
|
||||
Digest: layerMap["digest"].(string),
|
||||
Size: convertToInt64(layerMap["size"]),
|
||||
MediaType: layerMap["mediaType"].(string),
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
slog.Warn("No layers found in scan job map",
|
||||
"layersType", fmt.Sprintf("%T", v["layers"]),
|
||||
"layersValue", v["layers"])
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("invalid job type: %T", jobAny)
|
||||
}
|
||||
|
||||
select {
|
||||
case q.jobs <- job:
|
||||
slog.Info("Enqueued scan job",
|
||||
"repository", job.Repository,
|
||||
"tag", job.Tag,
|
||||
"digest", job.ManifestDigest)
|
||||
return nil
|
||||
case <-q.ctx.Done():
|
||||
return q.ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown gracefully shuts down the queue, waiting for all workers to finish
|
||||
func (q *Queue) Shutdown() {
|
||||
slog.Info("Shutting down scanner queue")
|
||||
|
||||
// Close the jobs channel to signal no more jobs
|
||||
close(q.jobs)
|
||||
|
||||
// Wait for all workers to finish
|
||||
q.wg.Wait()
|
||||
|
||||
// Close results channel
|
||||
close(q.results)
|
||||
|
||||
// Cancel context
|
||||
q.cancel()
|
||||
|
||||
slog.Info("Scanner queue shut down complete")
|
||||
}
|
||||
|
||||
// Len returns the number of jobs currently in the queue
|
||||
func (q *Queue) Len() int {
|
||||
return len(q.jobs)
|
||||
}
|
||||
|
||||
// convertToInt64 converts an interface{} number to int64, handling both float64 and int64
|
||||
func convertToInt64(v interface{}) int64 {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int64(n)
|
||||
case int64:
|
||||
return n
|
||||
case int:
|
||||
return int64(n)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
123
pkg/hold/scanner/storage.go
Normal file
123
pkg/hold/scanner/storage.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
)
|
||||
|
||||
// storeResults uploads scan results and creates ORAS manifest records in the hold's PDS
|
||||
func (w *Worker) storeResults(ctx context.Context, job *ScanJob, sbomDigest, vulnDigest string, vulnJSON []byte, summary VulnerabilitySummary) error {
|
||||
if !w.config.Scanner.VulnEnabled {
|
||||
slog.Info("Vulnerability scanning disabled, skipping result storage")
|
||||
return nil
|
||||
}
|
||||
|
||||
slog.Info("Storing scan results as ORAS artifact",
|
||||
"repository", job.Repository,
|
||||
"subjectDigest", job.ManifestDigest,
|
||||
"vulnDigest", vulnDigest)
|
||||
|
||||
// Create ORAS manifest for vulnerability report
|
||||
orasManifest := map[string]interface{}{
|
||||
"schemaVersion": 2,
|
||||
"mediaType": "application/vnd.oci.image.manifest.v1+json",
|
||||
"artifactType": "application/vnd.atcr.vulnerabilities+json",
|
||||
"config": map[string]interface{}{
|
||||
"mediaType": "application/vnd.oci.empty.v1+json",
|
||||
"digest": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", // Empty JSON object
|
||||
"size": 2,
|
||||
},
|
||||
"subject": map[string]interface{}{
|
||||
"mediaType": "application/vnd.oci.image.manifest.v1+json",
|
||||
"digest": job.ManifestDigest,
|
||||
"size": 0, // We don't have the size, but it's optional
|
||||
},
|
||||
"layers": []map[string]interface{}{
|
||||
{
|
||||
"mediaType": "application/json",
|
||||
"digest": vulnDigest,
|
||||
"size": len(vulnJSON),
|
||||
"annotations": map[string]string{
|
||||
"org.opencontainers.image.title": "vulnerability-report.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
"annotations": map[string]string{
|
||||
"io.atcr.vuln.critical": fmt.Sprintf("%d", summary.Critical),
|
||||
"io.atcr.vuln.high": fmt.Sprintf("%d", summary.High),
|
||||
"io.atcr.vuln.medium": fmt.Sprintf("%d", summary.Medium),
|
||||
"io.atcr.vuln.low": fmt.Sprintf("%d", summary.Low),
|
||||
"io.atcr.vuln.total": fmt.Sprintf("%d", summary.Total),
|
||||
"io.atcr.vuln.scannedAt": time.Now().Format(time.RFC3339),
|
||||
"io.atcr.vuln.scannerVersion": w.getScannerVersion(),
|
||||
},
|
||||
}
|
||||
|
||||
// Encode ORAS manifest to JSON
|
||||
orasManifestJSON, err := json.Marshal(orasManifest)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encode ORAS manifest: %w", err)
|
||||
}
|
||||
|
||||
// Calculate ORAS manifest digest
|
||||
orasDigest := fmt.Sprintf("sha256:%x", sha256Bytes(orasManifestJSON))
|
||||
|
||||
// Upload ORAS manifest blob to storage
|
||||
if err := w.uploadBlob(ctx, orasDigest, orasManifestJSON); err != nil {
|
||||
return fmt.Errorf("failed to upload ORAS manifest blob: %w", err)
|
||||
}
|
||||
|
||||
// Create manifest record in hold's PDS
|
||||
if err := w.createManifestRecord(ctx, job, orasDigest, orasManifestJSON, summary); err != nil {
|
||||
return fmt.Errorf("failed to create manifest record: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("Successfully stored scan results", "orasDigest", orasDigest)
|
||||
return nil
|
||||
}
|
||||
|
||||
// createManifestRecord creates an ORAS manifest record in the hold's PDS
|
||||
func (w *Worker) createManifestRecord(ctx context.Context, job *ScanJob, orasDigest string, orasManifestJSON []byte, summary VulnerabilitySummary) error {
|
||||
// Create ManifestRecord from ORAS manifest
|
||||
record, err := atproto.NewManifestRecord(job.Repository, orasDigest, orasManifestJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create manifest record: %w", err)
|
||||
}
|
||||
|
||||
// Set SBOM/vulnerability specific fields
|
||||
record.OwnerDID = job.UserDID
|
||||
record.ScannedAt = time.Now().Format(time.RFC3339)
|
||||
record.ScannerVersion = w.getScannerVersion()
|
||||
|
||||
// Add hold DID (this ORAS artifact is stored in the hold's PDS)
|
||||
record.HoldDID = w.pds.DID()
|
||||
|
||||
// Convert digest to record key (remove "sha256:" prefix)
|
||||
rkey := orasDigest[len("sha256:"):]
|
||||
|
||||
// Store record in hold's PDS
|
||||
slog.Info("Creating manifest record in hold's PDS",
|
||||
"collection", atproto.ManifestCollection,
|
||||
"rkey", rkey,
|
||||
"ownerDid", job.UserDID)
|
||||
|
||||
_, _, err = w.pds.CreateManifestRecord(ctx, record, rkey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to put record in PDS: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("Manifest record created successfully", "uri", fmt.Sprintf("at://%s/%s/%s", w.pds.DID(), atproto.ManifestCollection, rkey))
|
||||
return nil
|
||||
}
|
||||
|
||||
// sha256Bytes calculates SHA256 hash of byte slice
|
||||
func sha256Bytes(data []byte) []byte {
|
||||
hash := sha256.Sum256(data)
|
||||
return hash[:]
|
||||
}
|
||||
128
pkg/hold/scanner/syft.go
Normal file
128
pkg/hold/scanner/syft.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"github.com/anchore/syft/syft"
|
||||
"github.com/anchore/syft/syft/format"
|
||||
"github.com/anchore/syft/syft/format/spdxjson"
|
||||
"github.com/anchore/syft/syft/sbom"
|
||||
"github.com/anchore/syft/syft/source/directorysource"
|
||||
)
|
||||
|
||||
// generateSBOM generates an SBOM using Syft from an extracted image directory
|
||||
// Returns the SBOM object, SBOM JSON, its digest, and any error
|
||||
func (w *Worker) generateSBOM(ctx context.Context, imageDir string) (*sbom.SBOM, []byte, string, error) {
|
||||
slog.Info("Generating SBOM with Syft", "imageDir", imageDir)
|
||||
|
||||
// Check if directory exists and is accessible
|
||||
entries, err := os.ReadDir(imageDir)
|
||||
if err != nil {
|
||||
return nil, nil, "", fmt.Errorf("failed to read image directory: %w", err)
|
||||
}
|
||||
slog.Info("Image directory contents",
|
||||
"path", imageDir,
|
||||
"entries", len(entries),
|
||||
"sampleFiles", func() []string {
|
||||
var samples []string
|
||||
for i, e := range entries {
|
||||
if i >= 20 {
|
||||
break
|
||||
}
|
||||
samples = append(samples, e.Name())
|
||||
}
|
||||
return samples
|
||||
}())
|
||||
|
||||
// Create Syft source from directory
|
||||
src, err := directorysource.NewFromPath(imageDir)
|
||||
if err != nil {
|
||||
return nil, nil, "", fmt.Errorf("failed to create Syft source: %w", err)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
// Generate SBOM
|
||||
slog.Info("Running Syft cataloging")
|
||||
sbomResult, err := syft.CreateSBOM(ctx, src, nil)
|
||||
if err != nil {
|
||||
return nil, nil, "", fmt.Errorf("failed to generate SBOM: %w", err)
|
||||
}
|
||||
|
||||
if sbomResult == nil {
|
||||
return nil, nil, "", fmt.Errorf("Syft returned nil SBOM")
|
||||
}
|
||||
|
||||
slog.Info("SBOM generated",
|
||||
"packages", sbomResult.Artifacts.Packages.PackageCount(),
|
||||
"distro", func() string {
|
||||
if sbomResult.Artifacts.LinuxDistribution != nil {
|
||||
return fmt.Sprintf("%s %s", sbomResult.Artifacts.LinuxDistribution.Name, sbomResult.Artifacts.LinuxDistribution.Version)
|
||||
}
|
||||
return "none"
|
||||
}())
|
||||
|
||||
// Encode SBOM to SPDX JSON format
|
||||
encoder, err := spdxjson.NewFormatEncoderWithConfig(spdxjson.DefaultEncoderConfig())
|
||||
if err != nil {
|
||||
return nil, nil, "", fmt.Errorf("failed to create SPDX encoder: %w", err)
|
||||
}
|
||||
|
||||
sbomJSON, err := format.Encode(*sbomResult, encoder)
|
||||
if err != nil {
|
||||
return nil, nil, "", fmt.Errorf("failed to encode SBOM to SPDX JSON: %w", err)
|
||||
}
|
||||
|
||||
// Calculate digest
|
||||
hash := sha256.Sum256(sbomJSON)
|
||||
digest := fmt.Sprintf("sha256:%x", hash)
|
||||
|
||||
slog.Info("SBOM encoded", "format", "spdx-json", "size", len(sbomJSON), "digest", digest)
|
||||
|
||||
// Upload SBOM blob to storage
|
||||
if err := w.uploadBlob(ctx, digest, sbomJSON); err != nil {
|
||||
return nil, nil, "", fmt.Errorf("failed to upload SBOM blob: %w", err)
|
||||
}
|
||||
|
||||
return sbomResult, sbomJSON, digest, nil
|
||||
}
|
||||
|
||||
// uploadBlob uploads a blob to storage
|
||||
func (w *Worker) uploadBlob(ctx context.Context, digest string, data []byte) error {
|
||||
// Convert digest to storage path (same format as distribution uses)
|
||||
// Path format: /docker/registry/v2/blobs/sha256/ab/abcd1234.../data
|
||||
algorithm := "sha256"
|
||||
digestHex := digest[len("sha256:"):]
|
||||
if len(digestHex) < 2 {
|
||||
return fmt.Errorf("invalid digest: %s", digest)
|
||||
}
|
||||
|
||||
blobPath := fmt.Sprintf("/docker/registry/v2/blobs/%s/%s/%s/data",
|
||||
algorithm,
|
||||
digestHex[:2],
|
||||
digestHex)
|
||||
|
||||
slog.Info("Uploading blob to storage", "digest", digest, "size", len(data), "path", blobPath)
|
||||
|
||||
// Write blob to storage
|
||||
writer, err := w.driver.Writer(ctx, blobPath, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create storage writer: %w", err)
|
||||
}
|
||||
defer writer.Close()
|
||||
|
||||
if _, err := writer.Write(data); err != nil {
|
||||
writer.Cancel(ctx)
|
||||
return fmt.Errorf("failed to write blob data: %w", err)
|
||||
}
|
||||
|
||||
if err := writer.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("failed to commit blob: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("Successfully uploaded blob", "digest", digest)
|
||||
return nil
|
||||
}
|
||||
116
pkg/hold/scanner/worker.go
Normal file
116
pkg/hold/scanner/worker.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/hold"
|
||||
"atcr.io/pkg/hold/pds"
|
||||
"github.com/distribution/distribution/v3/registry/storage/driver"
|
||||
)
|
||||
|
||||
// Worker performs vulnerability scanning on container images
|
||||
type Worker struct {
|
||||
config *hold.Config
|
||||
driver driver.StorageDriver
|
||||
pds *pds.HoldPDS
|
||||
queue *Queue
|
||||
}
|
||||
|
||||
// NewWorker creates a new scanner worker
|
||||
func NewWorker(config *hold.Config, driver driver.StorageDriver, pds *pds.HoldPDS) *Worker {
|
||||
return &Worker{
|
||||
config: config,
|
||||
driver: driver,
|
||||
pds: pds,
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts the worker pool and initializes vulnerability database
|
||||
func (w *Worker) Start(queue *Queue) {
|
||||
w.queue = queue
|
||||
|
||||
// Initialize vulnerability database on startup if scanning is enabled
|
||||
if w.config.Scanner.VulnEnabled {
|
||||
go func() {
|
||||
ctx := context.Background()
|
||||
if err := w.initializeVulnDatabase(ctx); err != nil {
|
||||
slog.Error("Failed to initialize vulnerability database", "error", err)
|
||||
slog.Warn("Vulnerability scanning will be disabled until database is available")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
queue.Start(w.processJob)
|
||||
}
|
||||
|
||||
// processJob processes a single scan job
|
||||
func (w *Worker) processJob(ctx context.Context, job *ScanJob) (*ScanResult, error) {
|
||||
slog.Info("Processing scan job",
|
||||
"repository", job.Repository,
|
||||
"tag", job.Tag,
|
||||
"digest", job.ManifestDigest,
|
||||
"layers", len(job.Layers))
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
// Step 1: Extract image layers from storage
|
||||
slog.Info("Extracting image layers", "repository", job.Repository)
|
||||
imageDir, cleanup, err := w.extractLayers(ctx, job)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to extract layers: %w", err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
// Step 2: Generate SBOM with Syft
|
||||
slog.Info("Generating SBOM", "repository", job.Repository)
|
||||
sbomResult, _, sbomDigest, err := w.generateSBOM(ctx, imageDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate SBOM: %w", err)
|
||||
}
|
||||
|
||||
// Step 3: Scan SBOM with Grype (if enabled)
|
||||
var vulnJSON []byte
|
||||
var vulnDigest string
|
||||
var summary VulnerabilitySummary
|
||||
|
||||
if w.config.Scanner.VulnEnabled {
|
||||
slog.Info("Scanning for vulnerabilities", "repository", job.Repository)
|
||||
vulnJSON, vulnDigest, summary, err = w.scanVulnerabilities(ctx, sbomResult)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan vulnerabilities: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Upload results to storage and create ORAS manifests
|
||||
slog.Info("Storing scan results", "repository", job.Repository)
|
||||
err = w.storeResults(ctx, job, sbomDigest, vulnDigest, vulnJSON, summary)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to store results: %w", err)
|
||||
}
|
||||
|
||||
duration := time.Since(startTime)
|
||||
slog.Info("Scan job completed",
|
||||
"repository", job.Repository,
|
||||
"tag", job.Tag,
|
||||
"duration", duration,
|
||||
"vulnerabilities", summary.Total)
|
||||
|
||||
return &ScanResult{
|
||||
Job: job,
|
||||
VulnerabilitiesJSON: vulnJSON,
|
||||
Summary: summary,
|
||||
SBOMDigest: sbomDigest,
|
||||
VulnDigest: vulnDigest,
|
||||
ScannedAt: time.Now(),
|
||||
ScannerVersion: w.getScannerVersion(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// getScannerVersion returns the version string for the scanner
|
||||
func (w *Worker) getScannerVersion() string {
|
||||
// TODO: Get actual Syft and Grype versions dynamically
|
||||
return "syft-v1.36.0/grype-v0.102.0"
|
||||
}
|
||||
Reference in New Issue
Block a user