mirror of
https://github.com/project-zot/zot.git
synced 2026-06-17 12:58:02 +08:00
feat(lint): enforce mandatory image signatures before publish
This commit is contained in:
committed by
GitHub
parent
904936935c
commit
9cef15aa13
@@ -13,8 +13,8 @@
|
||||
"extensions": {
|
||||
"lint": {
|
||||
"enable": true,
|
||||
"mandatoryAnnotations": ["annot1", "annot2", "annot3"]
|
||||
|
||||
}
|
||||
}
|
||||
"mandatoryAnnotations": ["annot1", "annot2", "annot3"],
|
||||
"mandatorySignatures": ["repo1", "repo2"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ type LintConfig struct {
|
||||
BaseConfig `mapstructure:",squash"`
|
||||
|
||||
MandatoryAnnotations []string
|
||||
MandatorySignatures []string
|
||||
}
|
||||
|
||||
type SearchConfig struct {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user