diff --git a/examples/config-lint.json b/examples/config-lint.json index 6ae7a1f1..4f28def8 100644 --- a/examples/config-lint.json +++ b/examples/config-lint.json @@ -13,8 +13,8 @@ "extensions": { "lint": { "enable": true, - "mandatoryAnnotations": ["annot1", "annot2", "annot3"] - - } - } + "mandatoryAnnotations": ["annot1", "annot2", "annot3"], + "mandatorySignatures": ["repo1", "repo2"] + } + } } diff --git a/pkg/cli/server/extensions_test.go b/pkg/cli/server/extensions_test.go index 62dbc2b2..dadcd717 100644 --- a/pkg/cli/server/extensions_test.go +++ b/pkg/cli/server/extensions_test.go @@ -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 }) } diff --git a/pkg/extensions/config/config.go b/pkg/extensions/config/config.go index 94bf291f..5e800592 100644 --- a/pkg/extensions/config/config.go +++ b/pkg/extensions/config/config.go @@ -44,6 +44,7 @@ type LintConfig struct { BaseConfig `mapstructure:",squash"` MandatoryAnnotations []string + MandatorySignatures []string } type SearchConfig struct { diff --git a/pkg/extensions/extensions_lint.go b/pkg/extensions/extensions_lint.go index 21e65eef..5fd73517 100644 --- a/pkg/extensions/extensions_lint.go +++ b/pkg/extensions/extensions_lint.go @@ -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 } diff --git a/pkg/extensions/lint/lint.go b/pkg/extensions/lint/lint.go index 1902f922..599243ee 100644 --- a/pkg/extensions/lint/lint.go +++ b/pkg/extensions/lint/lint.go @@ -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 { diff --git a/pkg/extensions/lint/lint_signatures_test.go b/pkg/extensions/lint/lint_signatures_test.go new file mode 100644 index 00000000..c56d8a85 --- /dev/null +++ b/pkg/extensions/lint/lint_signatures_test.go @@ -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 +}