feat(lint): enforce mandatory image signatures before publish

This commit is contained in:
copilot-swe-agent[bot]
2026-05-28 18:16:54 +00:00
committed by GitHub
parent 904936935c
commit 9cef15aa13
6 changed files with 414 additions and 10 deletions
+4 -4
View File
@@ -13,8 +13,8 @@
"extensions": {
"lint": {
"enable": true,
"mandatoryAnnotations": ["annot1", "annot2", "annot3"]
}
}
"mandatoryAnnotations": ["annot1", "annot2", "annot3"],
"mandatorySignatures": ["repo1", "repo2"]
}
}
}
+1 -1
View File
@@ -885,7 +885,7 @@ func TestServeLintExtension(t *testing.T) {
So(err, ShouldBeNil)
So(string(data), ShouldContainSubstring,
"\"Extensions\":{\"Search\":null,\"Sync\":null,\"Metrics\":null,\"Scrub\":null,\"Lint\":{\"Enable\":false,\"MandatoryAnnotations\":null}") //nolint:lll // gofumpt conflicts with lll
"\"Extensions\":{\"Search\":null,\"Sync\":null,\"Metrics\":null,\"Scrub\":null,\"Lint\":{\"Enable\":false,\"MandatoryAnnotations\":null,\"MandatorySignatures\":null}") //nolint:lll // gofumpt conflicts with lll
})
}
+1
View File
@@ -44,6 +44,7 @@ type LintConfig struct {
BaseConfig `mapstructure:",squash"`
MandatoryAnnotations []string
MandatorySignatures []string
}
type SearchConfig struct {
+71 -1
View File
@@ -3,9 +3,15 @@
package extensions
import (
"os"
"path/filepath"
"zotregistry.dev/zot/v2/pkg/api/config"
"zotregistry.dev/zot/v2/pkg/extensions/imagetrust"
"zotregistry.dev/zot/v2/pkg/extensions/lint"
"zotregistry.dev/zot/v2/pkg/log"
mTypes "zotregistry.dev/zot/v2/pkg/meta/types"
sconstants "zotregistry.dev/zot/v2/pkg/storage/constants"
)
func GetLinter(config *config.Config, log log.Logger) *lint.Linter {
@@ -13,5 +19,69 @@ func GetLinter(config *config.Config, log log.Logger) *lint.Linter {
return lint.NewLinter(nil, log)
}
return lint.NewLinter(config.Extensions.Lint, log)
linter := lint.NewLinter(config.Extensions.Lint, log)
if config.Extensions.Lint == nil || len(config.Extensions.Lint.MandatorySignatures) == 0 {
return linter
}
extensionsConfig := config.CopyExtensionsConfig()
if !IsBuiltWithImageTrustExtension() || !extensionsConfig.IsImageTrustEnabled() {
log.Warn().Msg("mandatory signatures lint requires image trust and trust store configuration")
linter.SetSignatureVerifier(nil, false)
return linter
}
var (
imageTrustStore mTypes.ImageTrustStore
err error
)
if config.Storage.RemoteCache && config.Storage.CacheDriver["name"] == sconstants.DynamoDBDriverName {
endpoint, _ := config.Storage.CacheDriver["endpoint"].(string)
region, _ := config.Storage.CacheDriver["region"].(string)
imageTrustStore, err = imagetrust.NewAWSImageTrustStore(region, endpoint)
} else {
imageTrustStore, err = imagetrust.NewLocalImageTrustStore(config.Storage.RootDirectory)
}
if err != nil {
log.Warn().Err(err).Msg("mandatory signatures lint could not initialize trust store")
linter.SetSignatureVerifier(nil, false)
return linter
}
trustStoreReady := true
if !config.Storage.RemoteCache && !hasLocalTrustStoreMaterial(config.Storage.RootDirectory) {
log.Warn().Msg("mandatory signatures lint is enabled, but no trust store certificates or keys are configured")
trustStoreReady = false
}
linter.SetSignatureVerifier(imageTrustStore, trustStoreReady)
return linter
}
func hasLocalTrustStoreMaterial(rootDir string) bool {
return hasFile(filepath.Join(rootDir, "_cosign")) ||
hasFile(filepath.Join(rootDir, "_notation", "truststore", "x509"))
}
func hasFile(root string) bool {
stat, err := os.Stat(root)
if err != nil || !stat.IsDir() {
return false
}
hasMaterial := false
_ = filepath.WalkDir(root, func(_ string, d os.DirEntry, err error) error {
if err == nil && !d.IsDir() {
hasMaterial = true
}
return nil
})
return hasMaterial
}
+165 -4
View File
@@ -5,19 +5,26 @@ package lint
import (
"encoding/json"
"fmt"
"strings"
godigest "github.com/opencontainers/go-digest"
ispec "github.com/opencontainers/image-spec/specs-go/v1"
zerr "zotregistry.dev/zot/v2/errors"
zcommon "zotregistry.dev/zot/v2/pkg/common"
"zotregistry.dev/zot/v2/pkg/extensions/config"
"zotregistry.dev/zot/v2/pkg/log"
"zotregistry.dev/zot/v2/pkg/meta"
mTypes "zotregistry.dev/zot/v2/pkg/meta/types"
storageCommon "zotregistry.dev/zot/v2/pkg/storage/common"
storageTypes "zotregistry.dev/zot/v2/pkg/storage/types"
)
type Linter struct {
config *config.LintConfig
log log.Logger
config *config.LintConfig
signatureVerifier mTypes.ImageTrustStore
trustStoreReady bool
log log.Logger
}
func NewLinter(config *config.LintConfig, log log.Logger) *Linter {
@@ -27,6 +34,15 @@ func NewLinter(config *config.LintConfig, log log.Logger) *Linter {
}
}
func (linter *Linter) SetSignatureVerifier(signatureVerifier mTypes.ImageTrustStore, trustStoreReady bool) {
linter.signatureVerifier = signatureVerifier
linter.trustStoreReady = trustStoreReady
}
func (linter *Linter) isEnabled() bool {
return linter.config != nil && (linter.config.Enable == nil || *linter.config.Enable)
}
func (linter *Linter) CheckMandatoryAnnotations(repo string, manifestDigest godigest.Digest,
imgStore storageTypes.ImageStore,
) (bool, error) {
@@ -34,7 +50,7 @@ func (linter *Linter) CheckMandatoryAnnotations(repo string, manifestDigest godi
return true, nil
}
if (linter.config != nil && !*linter.config.Enable) || len(linter.config.MandatoryAnnotations) == 0 {
if !linter.isEnabled() || len(linter.config.MandatoryAnnotations) == 0 {
return true, nil
}
@@ -110,10 +126,155 @@ func (linter *Linter) CheckMandatoryAnnotations(repo string, manifestDigest godi
return true, nil
}
func (linter *Linter) CheckMandatorySignatures(repo string, manifestDigest godigest.Digest,
imgStore storageTypes.ImageStore,
) (bool, error) {
if linter.config == nil || !linter.isEnabled() || len(linter.config.MandatorySignatures) == 0 {
return true, nil
}
mandatory := false
for _, mandatoryRepo := range linter.config.MandatorySignatures {
if repo == mandatoryRepo {
mandatory = true
break
}
}
if !mandatory {
return true, nil
}
if linter.signatureVerifier == nil || !linter.trustStoreReady {
msg := fmt.Sprintf("mandatory signatures lint for repository %q requires a configured trust store", repo)
return false, zerr.NewError(zerr.ErrImageLintAnnotations).AddDetail("missingSignatures", msg)
}
isTrusted, err := linter.hasTrustedSignature(repo, manifestDigest, imgStore)
if err != nil {
return false, err
}
if !isTrusted {
msg := fmt.Sprintf("manifest %s in repository %s does not have a trusted signature", manifestDigest, repo)
return false, zerr.NewError(zerr.ErrImageLintAnnotations).AddDetail("missingSignatures", msg)
}
return true, nil
}
func (linter *Linter) hasTrustedSignature(repo string, manifestDigest godigest.Digest,
imgStore storageTypes.ImageStore,
) (bool, error) {
index, err := storageCommon.GetIndex(imgStore, repo, linter.log)
if err != nil {
return false, err
}
manifestBlob, err := imgStore.GetBlobContent(repo, manifestDigest)
if err != nil {
return false, err
}
imageMeta := mTypes.ImageMeta{
MediaType: ispec.MediaTypeImageManifest,
Digest: manifestDigest,
Size: int64(len(manifestBlob)),
}
for _, descriptor := range index.Manifests {
if descriptor.Digest == manifestDigest {
continue
}
signatureBlob, err := imgStore.GetBlobContent(repo, descriptor.Digest)
if err != nil {
continue
}
var signatureManifest ispec.Manifest
if err := json.Unmarshal(signatureBlob, &signatureManifest); err != nil {
continue
}
signatureType, isImageSignature := getSignatureType(descriptor, signatureManifest, manifestDigest)
if !isImageSignature {
continue
}
signatureLayers, err := meta.GetSignatureLayersInfo(repo,
descriptor.Annotations[ispec.AnnotationRefName], descriptor.Digest.String(), signatureType,
signatureBlob, imgStore, linter.log)
if err != nil {
continue
}
for _, signatureLayer := range signatureLayers {
_, _, trusted, err := linter.signatureVerifier.VerifySignature(signatureType,
signatureLayer.LayerContent, signatureLayer.SignatureKey, manifestDigest, imageMeta, repo)
if err != nil {
continue
}
if trusted {
return true, nil
}
}
}
return false, nil
}
func getSignatureType(descriptor ispec.Descriptor, signatureManifest ispec.Manifest, manifestDigest godigest.Digest) (string, bool) {
artifactType := zcommon.GetManifestArtifactType(signatureManifest)
if signatureManifest.Subject != nil && signatureManifest.Subject.Digest == manifestDigest {
switch {
case zcommon.IsArtifactTypeCosign(artifactType):
return zcommon.CosignSignature, true
case artifactType == zcommon.ArtifactTypeNotation:
return zcommon.NotationSignature, true
}
}
tag := descriptor.Annotations[ispec.AnnotationRefName]
if zcommon.IsCosignSignature(tag) {
signedDigest, err := getDigestFromCosignTag(tag)
if err == nil && signedDigest == manifestDigest {
return zcommon.CosignSignature, true
}
}
return "", false
}
func getDigestFromCosignTag(tag string) (godigest.Digest, error) {
const (
cosignPrefix = "sha256-"
cosignSuffix = ".sig"
)
if !strings.HasPrefix(tag, cosignPrefix) || !strings.HasSuffix(tag, cosignSuffix) {
return "", zerr.ErrBadManifest
}
encodedDigest := strings.TrimSuffix(strings.TrimPrefix(tag, cosignPrefix), cosignSuffix)
return godigest.NewDigestFromEncoded(godigest.SHA256, encodedDigest), nil
}
func (linter *Linter) Lint(repo string, manifestDigest godigest.Digest,
imageStore storageTypes.ImageStore,
) (bool, error) {
return linter.CheckMandatoryAnnotations(repo, manifestDigest, imageStore)
pass, err := linter.CheckMandatoryAnnotations(repo, manifestDigest, imageStore)
if err != nil || !pass {
return pass, err
}
return linter.CheckMandatorySignatures(repo, manifestDigest, imageStore)
}
func getMissingAnnotations(mandatoryAnnotationsMap map[string]bool) []string {
+172
View File
@@ -0,0 +1,172 @@
//go:build lint
package lint_test
import (
"encoding/json"
"os"
"path"
"path/filepath"
"testing"
"time"
godigest "github.com/opencontainers/go-digest"
ispec "github.com/opencontainers/image-spec/specs-go/v1"
. "github.com/smartystreets/goconvey/convey"
zcommon "zotregistry.dev/zot/v2/pkg/common"
extconf "zotregistry.dev/zot/v2/pkg/extensions/config"
"zotregistry.dev/zot/v2/pkg/extensions/lint"
"zotregistry.dev/zot/v2/pkg/extensions/monitoring"
"zotregistry.dev/zot/v2/pkg/log"
mTypes "zotregistry.dev/zot/v2/pkg/meta/types"
"zotregistry.dev/zot/v2/pkg/storage/local"
. "zotregistry.dev/zot/v2/pkg/test/image-utils"
ociutils "zotregistry.dev/zot/v2/pkg/test/oci-utils"
)
func TestMandatorySignaturesFunction(t *testing.T) {
Convey("mandatory signatures check passes with trusted signature", t, func() {
enable := true
lintConfig := &extconf.LintConfig{
BaseConfig: extconf.BaseConfig{Enable: &enable},
MandatorySignatures: []string{"zot-test"},
}
dir := t.TempDir()
testStoreCtlr := ociutils.GetDefaultStoreController(dir, log.NewTestLogger())
err := WriteImageToFileSystem(CreateRandomImage(), "zot-test", "0.0.1", testStoreCtlr)
So(err, ShouldBeNil)
manifestDigest, err := appendCosignSignatureManifest(dir, "zot-test")
So(err, ShouldBeNil)
linter := lint.NewLinter(lintConfig, log.NewTestLogger())
linter.SetSignatureVerifier(mockImageTrustStore{trusted: true}, true)
imgStore := local.NewImageStore(dir, false, false,
log.NewTestLogger(), monitoring.NewMetricsServer(false, log.NewTestLogger()), linter, nil, nil, nil)
pass, err := linter.CheckMandatorySignatures("zot-test", manifestDigest, imgStore)
So(err, ShouldBeNil)
So(pass, ShouldBeTrue)
})
Convey("mandatory signatures check rejects unsigned images", t, func() {
enable := true
lintConfig := &extconf.LintConfig{
BaseConfig: extconf.BaseConfig{Enable: &enable},
MandatorySignatures: []string{"zot-test"},
}
dir := t.TempDir()
testStoreCtlr := ociutils.GetDefaultStoreController(dir, log.NewTestLogger())
err := WriteImageToFileSystem(CreateRandomImage(), "zot-test", "0.0.1", testStoreCtlr)
So(err, ShouldBeNil)
indexContent, err := os.ReadFile(path.Join(dir, "zot-test", "index.json"))
So(err, ShouldBeNil)
var index ispec.Index
err = json.Unmarshal(indexContent, &index)
So(err, ShouldBeNil)
linter := lint.NewLinter(lintConfig, log.NewTestLogger())
linter.SetSignatureVerifier(mockImageTrustStore{trusted: true}, true)
imgStore := local.NewImageStore(dir, false, false,
log.NewTestLogger(), monitoring.NewMetricsServer(false, log.NewTestLogger()), linter, nil, nil, nil)
pass, err := linter.CheckMandatorySignatures("zot-test", index.Manifests[0].Digest, imgStore)
So(err, ShouldNotBeNil)
So(pass, ShouldBeFalse)
})
}
type mockImageTrustStore struct {
trusted bool
}
func (its mockImageTrustStore) VerifySignature(signatureType string, rawSignature []byte, sigKey string,
manifestDigest godigest.Digest, imageMeta mTypes.ImageMeta, repo string,
) (mTypes.Author, mTypes.ExpiryDate, mTypes.Validity, error) {
return "author", time.Time{}, its.trusted, nil
}
func appendCosignSignatureManifest(rootDir, repo string) (godigest.Digest, error) {
indexPath := path.Join(rootDir, repo, "index.json")
indexContent, err := os.ReadFile(indexPath)
if err != nil {
return "", err
}
var index ispec.Index
if err = json.Unmarshal(indexContent, &index); err != nil {
return "", err
}
manifestDigest := index.Manifests[0].Digest
sigLayerContent := []byte("signature")
sigLayerDigest := godigest.FromBytes(sigLayerContent)
sigLayerPath := filepath.Join(rootDir, repo, "blobs", sigLayerDigest.Algorithm().String(), sigLayerDigest.Encoded())
if err = os.WriteFile(sigLayerPath, sigLayerContent, 0o600); err != nil {
return "", err
}
signatureManifest := ispec.Manifest{
MediaType: ispec.MediaTypeImageManifest,
Config: ispec.Descriptor{
MediaType: ispec.MediaTypeImageConfig,
Digest: godigest.FromBytes([]byte("sig-config")),
Size: int64(len([]byte("sig-config"))),
},
Layers: []ispec.Descriptor{
{
MediaType: "application/vnd.dev.cosign.simplesigning.v1+json",
Digest: sigLayerDigest,
Size: int64(len(sigLayerContent)),
Annotations: map[string]string{
zcommon.CosignSigKey: "c2lnbmF0dXJl",
},
},
},
Subject: &ispec.Descriptor{
MediaType: ispec.MediaTypeImageManifest,
Digest: manifestDigest,
},
ArtifactType: zcommon.ArtifactTypeCosign,
}
signatureManifest.SchemaVersion = 2
signatureManifestContent, err := json.Marshal(signatureManifest)
if err != nil {
return "", err
}
signatureManifestDigest := godigest.FromBytes(signatureManifestContent)
signatureManifestPath := filepath.Join(rootDir, repo, "blobs",
signatureManifestDigest.Algorithm().String(), signatureManifestDigest.Encoded())
if err = os.WriteFile(signatureManifestPath, signatureManifestContent, 0o600); err != nil {
return "", err
}
index.Manifests = append(index.Manifests, ispec.Descriptor{
MediaType: ispec.MediaTypeImageManifest,
Digest: signatureManifestDigest,
Size: int64(len(signatureManifestContent)),
ArtifactType: zcommon.ArtifactTypeCosign,
})
indexContent, err = json.Marshal(index)
if err != nil {
return "", err
}
if err = os.WriteFile(indexPath, indexContent, 0o600); err != nil {
return "", err
}
return manifestDigest, nil
}