2 Commits

Author SHA1 Message Date
Evan Jarrett
3b5b89b378 actually add scanner implementation 2025-10-30 23:00:56 -05:00
Evan Jarrett
8c5f9da2cf implement a POC for vulnerability scans using syft and grype 2025-10-30 23:00:55 -05:00
33 changed files with 6309 additions and 185 deletions

View File

@@ -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
# ==============================================================================

View File

@@ -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)

View File

@@ -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

View File

@@ -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
# ==============================================================================

View File

@@ -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}

View File

@@ -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
View File

@@ -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
)

1444
go.sum

File diff suppressed because it is too large Load Diff

View File

@@ -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
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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,
}

View File

@@ -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 {

View File

@@ -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()">&times;</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

View File

@@ -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

View File

@@ -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.*)

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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

View File

@@ -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: &copySource,
})
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

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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

View File

@@ -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)
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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"
}