diff --git a/api/v2.0/swagger.yaml b/api/v2.0/swagger.yaml index 3b7e30903..a032b4256 100644 --- a/api/v2.0/swagger.yaml +++ b/api/v2.0/swagger.yaml @@ -6450,6 +6450,10 @@ definitions: type: string description: 'Whether content trust is enabled or not. If it is enabled, user can''t pull unsigned images from this project. The valid values are "true", "false".' x-nullable: true + enable_content_trust_cosign: + type: string + description: 'Whether cosign content trust is enabled or not. If it is enabled, user can''t pull images without cosign signature from this project. The valid values are "true", "false".' + x-nullable: true prevent_vul: type: string description: 'Whether prevent the vulnerable images from running. The valid values are "true", "false".' diff --git a/src/pkg/project/models/pro_meta.go b/src/pkg/project/models/pro_meta.go index 3b663a170..bc4fe3ca3 100644 --- a/src/pkg/project/models/pro_meta.go +++ b/src/pkg/project/models/pro_meta.go @@ -16,10 +16,11 @@ package models // keys of project metadata and severity values const ( - ProMetaPublic = "public" - ProMetaEnableContentTrust = "enable_content_trust" - ProMetaPreventVul = "prevent_vul" // prevent vulnerable images from being pulled - ProMetaSeverity = "severity" - ProMetaAutoScan = "auto_scan" - ProMetaReuseSysCVEAllowlist = "reuse_sys_cve_allowlist" + ProMetaPublic = "public" + ProMetaEnableContentTrust = "enable_content_trust" + ProMetaEnableContentTrustCosign = "enable_content_trust_cosign" + ProMetaPreventVul = "prevent_vul" // prevent vulnerable images from being pulled + ProMetaSeverity = "severity" + ProMetaAutoScan = "auto_scan" + ProMetaReuseSysCVEAllowlist = "reuse_sys_cve_allowlist" ) diff --git a/src/pkg/project/models/project.go b/src/pkg/project/models/project.go index 1ce7556b9..1b2ff6311 100644 --- a/src/pkg/project/models/project.go +++ b/src/pkg/project/models/project.go @@ -103,6 +103,15 @@ func (p *Project) ContentTrustEnabled() bool { return isTrue(enabled) } +// VulPrevented ... +func (p *Project) ContentTrustCosignEnabled() bool { + enabled, exist := p.GetMetadata(ProMetaEnableContentTrustCosign) + if !exist { + return false + } + return isTrue(enabled) +} + // VulPrevented ... func (p *Project) VulPrevented() bool { prevent, exist := p.GetMetadata(ProMetaPreventVul) diff --git a/src/server/middleware/contenttrust/cosign.go b/src/server/middleware/contenttrust/cosign.go new file mode 100644 index 000000000..622e900c6 --- /dev/null +++ b/src/server/middleware/contenttrust/cosign.go @@ -0,0 +1,66 @@ +package contenttrust + +import ( + "github.com/goharbor/harbor/src/controller/artifact" + "github.com/goharbor/harbor/src/controller/project" + "github.com/goharbor/harbor/src/lib" + "github.com/goharbor/harbor/src/lib/errors" + "github.com/goharbor/harbor/src/lib/log" + "github.com/goharbor/harbor/src/pkg/accessory/model" + "github.com/goharbor/harbor/src/server/middleware" + "github.com/goharbor/harbor/src/server/middleware/util" + "net/http" +) + +// Cosign handle docker pull content trust check +func Cosign() func(http.Handler) http.Handler { + return middleware.BeforeRequest(func(r *http.Request) error { + ctx := r.Context() + + logger := log.G(ctx) + + none := lib.ArtifactInfo{} + af := lib.GetArtifactInfo(ctx) + if af == none { + return errors.New("artifactinfo middleware required before this middleware").WithCode(errors.NotFoundCode) + } + pro, err := project.Ctl.GetByName(ctx, af.ProjectName) + if err != nil { + return err + } + + if util.SkipPolicyChecking(r, pro.ProjectID) { + logger.Debugf("artifact %s@%s is pulling by the scanner/cosign, skip the checking", af.Repository, af.Digest) + return nil + } + + // If cosign policy enabled, it has to at least have one cosign signature. + if pro.ContentTrustCosignEnabled() { + art, err := artifact.Ctl.GetByReference(ctx, af.Repository, af.Reference, &artifact.Option{ + WithAccessory: true, + }) + if err != nil { + return err + } + + if len(art.Accessories) == 0 { + pkgE := errors.New(nil).WithCode(errors.PROJECTPOLICYVIOLATION).WithMessage("The image is not signed in Cosign.") + return pkgE + } + + var hasCosignSignature bool + for _, acc := range art.Accessories { + if acc.GetData().Type == model.TypeCosignSignature { + hasCosignSignature = true + break + } + } + if !hasCosignSignature { + pkgE := errors.New(nil).WithCode(errors.PROJECTPOLICYVIOLATION).WithMessage("The image is not signed in Cosign.") + return pkgE + } + } + + return nil + }) +} diff --git a/src/server/middleware/contenttrust/cosign_test.go b/src/server/middleware/contenttrust/cosign_test.go new file mode 100644 index 000000000..dd4ff5c78 --- /dev/null +++ b/src/server/middleware/contenttrust/cosign_test.go @@ -0,0 +1,209 @@ +// Copyright Project Harbor Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package contenttrust + +import ( + "fmt" + proModels "github.com/goharbor/harbor/src/pkg/project/models" + "net/http" + "net/http/httptest" + "testing" + + "github.com/goharbor/harbor/src/common/security" + "github.com/goharbor/harbor/src/controller/artifact" + "github.com/goharbor/harbor/src/controller/artifact/processor/image" + "github.com/goharbor/harbor/src/controller/project" + "github.com/goharbor/harbor/src/lib" + securitytesting "github.com/goharbor/harbor/src/testing/common/security" + artifacttesting "github.com/goharbor/harbor/src/testing/controller/artifact" + projecttesting "github.com/goharbor/harbor/src/testing/controller/project" + "github.com/goharbor/harbor/src/testing/mock" + "github.com/stretchr/testify/suite" +) + +type CosignMiddlewareTestSuite struct { + suite.Suite + + originalArtifactController artifact.Controller + artifactController *artifacttesting.Controller + + originalProjectController project.Controller + projectController *projecttesting.Controller + + artifact *artifact.Artifact + project *proModels.Project + + next http.Handler +} + +func (suite *CosignMiddlewareTestSuite) SetupTest() { + suite.originalArtifactController = artifact.Ctl + suite.artifactController = &artifacttesting.Controller{} + artifact.Ctl = suite.artifactController + + suite.originalProjectController = project.Ctl + suite.projectController = &projecttesting.Controller{} + project.Ctl = suite.projectController + + suite.artifact = &artifact.Artifact{} + suite.artifact.Type = image.ArtifactTypeImage + suite.artifact.ProjectID = 1 + suite.artifact.RepositoryName = "library/photon" + suite.artifact.Digest = "digest" + + suite.project = &proModels.Project{ + ProjectID: suite.artifact.ProjectID, + Name: "library", + Metadata: map[string]string{ + proModels.ProMetaEnableContentTrustCosign: "true", + }, + } + + suite.next = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + +} + +func (suite *CosignMiddlewareTestSuite) TearDownTest() { + artifact.Ctl = suite.originalArtifactController + project.Ctl = suite.originalProjectController +} + +func (suite *CosignMiddlewareTestSuite) makeRequest(setHeader ...bool) *http.Request { + req := httptest.NewRequest("GET", "/v1/library/photon/manifests/2.0", nil) + info := lib.ArtifactInfo{ + Repository: "library/photon", + Reference: "2.0", + Tag: "2.0", + Digest: "", + } + if len(setHeader) > 0 { + if setHeader[0] { + req.Header.Add("User-Agent", "cosign test") + } + } + return req.WithContext(lib.WithArtifactInfo(req.Context(), info)) +} + +func (suite *CosignMiddlewareTestSuite) TestGetArtifactFailed() { + mock.OnAnything(suite.projectController, "GetByName").Return(suite.project, nil) + mock.OnAnything(suite.artifactController, "GetByReference").Return(nil, fmt.Errorf("error")) + + req := suite.makeRequest() + rr := httptest.NewRecorder() + + Cosign()(suite.next).ServeHTTP(rr, req) + suite.Equal(rr.Code, http.StatusInternalServerError) +} + +func (suite *CosignMiddlewareTestSuite) TestGetProjectFailed() { + mock.OnAnything(suite.artifactController, "GetByReference").Return(suite.artifact, nil) + mock.OnAnything(suite.projectController, "GetByName").Return(nil, fmt.Errorf("err")) + + req := suite.makeRequest() + rr := httptest.NewRecorder() + + Cosign()(suite.next).ServeHTTP(rr, req) + suite.Equal(rr.Code, http.StatusInternalServerError) +} + +func (suite *CosignMiddlewareTestSuite) TestContentTrustDisabled() { + mock.OnAnything(suite.artifactController, "GetByReference").Return(suite.artifact, nil) + suite.project.Metadata[proModels.ProMetaEnableContentTrustCosign] = "false" + mock.OnAnything(suite.projectController, "GetByName").Return(suite.project, nil) + + req := suite.makeRequest() + rr := httptest.NewRecorder() + + Cosign()(suite.next).ServeHTTP(rr, req) + suite.Equal(rr.Code, http.StatusOK) +} + +func (suite *CosignMiddlewareTestSuite) TestNoneArtifact() { + req := httptest.NewRequest("GET", "/v1/library/photon/manifests/nonexist", nil) + rr := httptest.NewRecorder() + + Cosign()(suite.next).ServeHTTP(rr, req) + suite.Equal(rr.Code, http.StatusNotFound) +} + +func (suite *CosignMiddlewareTestSuite) TestAuthenticatedUserPulling() { + mock.OnAnything(suite.artifactController, "GetByReference").Return(suite.artifact, nil) + mock.OnAnything(suite.projectController, "GetByName").Return(suite.project, nil) + securityCtx := &securitytesting.Context{} + mock.OnAnything(securityCtx, "Name").Return("local") + mock.OnAnything(securityCtx, "Can").Return(true, nil) + mock.OnAnything(securityCtx, "IsAuthenticated").Return(true) + + req := suite.makeRequest() + req = req.WithContext(security.NewContext(req.Context(), securityCtx)) + rr := httptest.NewRecorder() + + Cosign()(suite.next).ServeHTTP(rr, req) + suite.Equal(rr.Code, http.StatusPreconditionFailed) +} + +func (suite *CosignMiddlewareTestSuite) TestScannerPulling() { + mock.OnAnything(suite.artifactController, "GetByReference").Return(suite.artifact, nil) + mock.OnAnything(suite.projectController, "GetByName").Return(suite.project, nil) + securityCtx := &securitytesting.Context{} + mock.OnAnything(securityCtx, "Name").Return("v2token") + mock.OnAnything(securityCtx, "Can").Return(true, nil) + mock.OnAnything(securityCtx, "IsAuthenticated").Return(true) + + req := suite.makeRequest() + req = req.WithContext(security.NewContext(req.Context(), securityCtx)) + rr := httptest.NewRecorder() + + Cosign()(suite.next).ServeHTTP(rr, req) + suite.Equal(rr.Code, http.StatusOK) +} + +func (suite *CosignMiddlewareTestSuite) TestCosignPulling() { + mock.OnAnything(suite.artifactController, "GetByReference").Return(suite.artifact, nil) + mock.OnAnything(suite.projectController, "GetByName").Return(suite.project, nil) + securityCtx := &securitytesting.Context{} + mock.OnAnything(securityCtx, "Name").Return("v2token") + mock.OnAnything(securityCtx, "Can").Return(true, nil) + mock.OnAnything(securityCtx, "IsAuthenticated").Return(true) + + req := suite.makeRequest(true) + req = req.WithContext(security.NewContext(req.Context(), securityCtx)) + rr := httptest.NewRecorder() + + Cosign()(suite.next).ServeHTTP(rr, req) + suite.Equal(rr.Code, http.StatusOK) +} + +// pull a public project a un-signed image when policy checker is enabled. +func (suite *CosignMiddlewareTestSuite) TestUnAuthenticatedUserPulling() { + mock.OnAnything(suite.artifactController, "GetByReference").Return(suite.artifact, nil) + mock.OnAnything(suite.projectController, "GetByName").Return(suite.project, nil) + securityCtx := &securitytesting.Context{} + mock.OnAnything(securityCtx, "Name").Return("local") + mock.OnAnything(securityCtx, "Can").Return(true, nil) + mock.OnAnything(securityCtx, "IsAuthenticated").Return(false) + + req := suite.makeRequest() + rr := httptest.NewRecorder() + + Cosign()(suite.next).ServeHTTP(rr, req) + suite.Equal(rr.Code, http.StatusPreconditionFailed) +} + +func TestCosignMiddlewareTestSuite(t *testing.T) { + suite.Run(t, &CosignMiddlewareTestSuite{}) +} diff --git a/src/server/middleware/contenttrust/contenttrust.go b/src/server/middleware/contenttrust/notary.go similarity index 92% rename from src/server/middleware/contenttrust/contenttrust.go rename to src/server/middleware/contenttrust/notary.go index 152a89728..65a67d0d2 100644 --- a/src/server/middleware/contenttrust/contenttrust.go +++ b/src/server/middleware/contenttrust/notary.go @@ -28,8 +28,8 @@ var ( } ) -// Middleware handle docker pull content trust check -func Middleware() func(http.Handler) http.Handler { +// Notary handle docker pull content trust check +func Notary() func(http.Handler) http.Handler { return middleware.BeforeRequest(func(r *http.Request) error { ctx := r.Context() @@ -52,7 +52,7 @@ func Middleware() func(http.Handler) http.Handler { return err } - if util.SkipPolicyChecking(ctx, pro.ProjectID) { + if util.SkipPolicyChecking(r, pro.ProjectID) { // the artifact is pulling by the scanner, skip the checking logger.Debugf("artifact %s@%s is pulling by the scanner, skip the checking", af.Repository, af.Digest) return nil diff --git a/src/server/middleware/contenttrust/contenttrust_test.go b/src/server/middleware/contenttrust/notary_test.go similarity index 95% rename from src/server/middleware/contenttrust/contenttrust_test.go rename to src/server/middleware/contenttrust/notary_test.go index 9e87adea3..1102ee13b 100644 --- a/src/server/middleware/contenttrust/contenttrust_test.go +++ b/src/server/middleware/contenttrust/notary_test.go @@ -104,7 +104,7 @@ func (suite *MiddlewareTestSuite) TestGetArtifactFailed() { req := suite.makeRequest() rr := httptest.NewRecorder() - Middleware()(suite.next).ServeHTTP(rr, req) + Notary()(suite.next).ServeHTTP(rr, req) suite.Equal(rr.Code, http.StatusInternalServerError) } @@ -115,7 +115,7 @@ func (suite *MiddlewareTestSuite) TestGetProjectFailed() { req := suite.makeRequest() rr := httptest.NewRecorder() - Middleware()(suite.next).ServeHTTP(rr, req) + Notary()(suite.next).ServeHTTP(rr, req) suite.Equal(rr.Code, http.StatusInternalServerError) } @@ -127,7 +127,7 @@ func (suite *MiddlewareTestSuite) TestContentTrustDisabled() { req := suite.makeRequest() rr := httptest.NewRecorder() - Middleware()(suite.next).ServeHTTP(rr, req) + Notary()(suite.next).ServeHTTP(rr, req) suite.Equal(rr.Code, http.StatusOK) } @@ -135,7 +135,7 @@ func (suite *MiddlewareTestSuite) TestNoneArtifact() { req := httptest.NewRequest("GET", "/v1/library/photon/manifests/nonexist", nil) rr := httptest.NewRecorder() - Middleware()(suite.next).ServeHTTP(rr, req) + Notary()(suite.next).ServeHTTP(rr, req) suite.Equal(rr.Code, http.StatusNotFound) } @@ -151,7 +151,7 @@ func (suite *MiddlewareTestSuite) TestAuthenticatedUserPulling() { req = req.WithContext(security.NewContext(req.Context(), securityCtx)) rr := httptest.NewRecorder() - Middleware()(suite.next).ServeHTTP(rr, req) + Notary()(suite.next).ServeHTTP(rr, req) suite.Equal(rr.Code, http.StatusPreconditionFailed) } @@ -167,7 +167,7 @@ func (suite *MiddlewareTestSuite) TestScannerPulling() { req = req.WithContext(security.NewContext(req.Context(), securityCtx)) rr := httptest.NewRecorder() - Middleware()(suite.next).ServeHTTP(rr, req) + Notary()(suite.next).ServeHTTP(rr, req) suite.Equal(rr.Code, http.StatusOK) } @@ -183,7 +183,7 @@ func (suite *MiddlewareTestSuite) TestUnAuthenticatedUserPulling() { req := suite.makeRequest() rr := httptest.NewRecorder() - Middleware()(suite.next).ServeHTTP(rr, req) + Notary()(suite.next).ServeHTTP(rr, req) suite.Equal(rr.Code, http.StatusPreconditionFailed) } diff --git a/src/server/middleware/util/util.go b/src/server/middleware/util/util.go index 8bd11a4d6..d9c70450f 100644 --- a/src/server/middleware/util/util.go +++ b/src/server/middleware/util/util.go @@ -15,7 +15,6 @@ package util import ( - "context" "fmt" "github.com/goharbor/harbor/src/common/rbac/project" "net/http" @@ -57,13 +56,17 @@ func ParseProjectName(r *http.Request) string { } // SkipPolicyChecking ... -func SkipPolicyChecking(ctx context.Context, projectID int64) bool { - secCtx, ok := security.FromContext(ctx) +func SkipPolicyChecking(r *http.Request, projectID int64) bool { + secCtx, ok := security.FromContext(r.Context()) - // only scanner pull access can bypass. - if ok && secCtx.Name() == "v2token" && - secCtx.Can(ctx, rbac.ActionScannerPull, project.NewNamespace(projectID).Resource(rbac.ResourceRepository)) { - return true + // 1, scanner pull access can bypass. + // 2, cosign pull can bypass, it needs to pull the manifest before pushing the signature. + if ok && secCtx.Name() == "v2token" { + if secCtx.Can(r.Context(), rbac.ActionScannerPull, project.NewNamespace(projectID).Resource(rbac.ResourceRepository)) || + (secCtx.Can(r.Context(), rbac.ActionPush, project.NewNamespace(projectID).Resource(rbac.ResourceRepository)) && + strings.Contains(r.UserAgent(), "cosign")) { + return true + } } return false diff --git a/src/server/middleware/vulnerable/vulnerable.go b/src/server/middleware/vulnerable/vulnerable.go index e6c430d7c..1f6ca5f7f 100644 --- a/src/server/middleware/vulnerable/vulnerable.go +++ b/src/server/middleware/vulnerable/vulnerable.go @@ -69,7 +69,7 @@ func Middleware() func(http.Handler) http.Handler { return nil } - if util.SkipPolicyChecking(ctx, proj.ProjectID) { + if util.SkipPolicyChecking(r, proj.ProjectID) { // the artifact is pulling by the scanner, skip the checking logger.Debugf("artifact %s@%s is pulling by the scanner, skip the checking", art.RepositoryName, art.Digest) return nil diff --git a/src/server/registry/route.go b/src/server/registry/route.go index 5dae94da3..7feee5597 100644 --- a/src/server/registry/route.go +++ b/src/server/registry/route.go @@ -52,7 +52,8 @@ func RegisterRoutes() { Path("/*/manifests/:reference"). Middleware(metric.InjectOpIDMiddleware(metric.ManifestOperationID)). Middleware(repoproxy.ManifestMiddleware()). - Middleware(contenttrust.Middleware()). + Middleware(contenttrust.Notary()). + Middleware(contenttrust.Cosign()). Middleware(vulnerable.Middleware()). HandlerFunc(getManifest) root.NewRoute(). @@ -60,7 +61,8 @@ func RegisterRoutes() { Path("/*/manifests/:reference"). Middleware(metric.InjectOpIDMiddleware(metric.ManifestOperationID)). Middleware(repoproxy.ManifestMiddleware()). - Middleware(contenttrust.Middleware()). + Middleware(contenttrust.Notary()). + Middleware(contenttrust.Cosign()). Middleware(vulnerable.Middleware()). HandlerFunc(getManifest) root.NewRoute(). diff --git a/src/server/v2.0/handler/project_metadata.go b/src/server/v2.0/handler/project_metadata.go index db2efda48..97398674f 100644 --- a/src/server/v2.0/handler/project_metadata.go +++ b/src/server/v2.0/handler/project_metadata.go @@ -140,7 +140,7 @@ func (p *projectMetadataAPI) validate(metas map[string]string) (map[string]strin } switch key { - case proModels.ProMetaPublic, proModels.ProMetaEnableContentTrust, + case proModels.ProMetaPublic, proModels.ProMetaEnableContentTrust, proModels.ProMetaEnableContentTrustCosign, proModels.ProMetaPreventVul, proModels.ProMetaAutoScan: v, err := strconv.ParseBool(value) if err != nil {