diff --git a/.github/workflows/kind-cluster-image-policy.yaml b/.github/workflows/kind-cluster-image-policy.yaml new file mode 100644 index 00000000000..463e22764d1 --- /dev/null +++ b/.github/workflows/kind-cluster-image-policy.yaml @@ -0,0 +1,215 @@ +# Copyright 2022 The Sigstore 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. + +name: Test cosigned with ClusterImagePolicy + +on: + pull_request: + branches: [ 'main', 'release-*' ] + +defaults: + run: + shell: bash + +permissions: read-all + +jobs: + cip-test: + name: ClusterImagePolicy e2e tests + runs-on: ubuntu-latest + + strategy: + matrix: + k8s-version: + - v1.21.x + + env: + KNATIVE_VERSION: "1.1.0" + KO_DOCKER_REPO: "registry.local:5000/knative" + SCAFFOLDING_RELEASE_VERSION: "v0.2.2" + GO111MODULE: on + GOFLAGS: -ldflags=-s -ldflags=-w + KOCACHE: ~/ko + COSIGN_EXPERIMENTAL: true + + steps: + - name: Configure DockerHub mirror + run: | + tmp=$(mktemp) + jq '."registry-mirrors" = ["https://mirror.gcr.io"]' /etc/docker/daemon.json > "$tmp" + sudo mv "$tmp" /etc/docker/daemon.json + sudo service docker restart + + - uses: actions/checkout@a12a3943b4bdde767164f792f33f40b04645d846 # v2.4.0 + - uses: actions/setup-go@f6164bd8c8acb4a71fb2791a8b6c4024ff038dab # v2.2.0 + with: + go-version: '1.17.x' + + # will use the latest release available for ko + - uses: imjasonh/setup-ko@2c3450ca27f6e6f2b02e72a40f2163c281a1f675 # v0.4 + + - name: Setup Cluster + run: | + curl -Lo ./setup-kind.sh https://github.com/sigstore/scaffolding/releases/download/${{ env.SCAFFOLDING_RELEASE_VERSION }}/setup-kind.sh + chmod u+x ./setup-kind.sh + ./setup-kind.sh \ + --registry-url $(echo ${KO_DOCKER_REPO} | cut -d'/' -f 1) \ + --cluster-suffix cluster.local \ + --k8s-version ${{ matrix.k8s-version }} \ + --knative-version ${KNATIVE_VERSION} + + - name: Install all the everythings + timeout-minutes: 10 + run: | + curl -L https://github.com/sigstore/scaffolding/releases/download/${{ env.SCAFFOLDING_RELEASE_VERSION }}/release.yaml | kubectl apply -f - + + # Wait for all the ksvc to be up. + kubectl wait --timeout 10m -A --for=condition=Ready ksvc --all + + - name: Run Scaffolding Tests + run: | + # Grab the secret from the ctlog-system namespace and make a copy + # in our namespace so we can get access to the CT Log public key + # so we can verify the SCT coming from there. + kubectl -n ctlog-system get secrets ctlog-public-key -oyaml | sed 's/namespace: .*/namespace: default/' | kubectl apply -f - + + # Also grab the secret from the fulcio-system namespace and make a copy + # in our namespace so we can get access to the Fulcio public key + # so we can verify against it. + kubectl -n fulcio-system get secrets fulcio-secret -oyaml | sed 's/namespace: .*/namespace: default/' | kubectl apply -f - + + curl -L https://github.com/sigstore/scaffolding/releases/download/${{ env.SCAFFOLDING_RELEASE_VERSION }}/testrelease.yaml | kubectl create -f - + + kubectl wait --for=condition=Complete --timeout=180s job/sign-job job/checktree job/verify-job + + - name: Install cosigned + env: + GIT_HASH: $GITHUB_SHA + GIT_VERSION: ci + LDFLAGS: "" + run: | + ko apply -Bf config/ + + # Wait for the webhook to come up and become Ready + kubectl rollout status --timeout 5m --namespace cosign-system deployments/webhook + + - name: Create sample image - demoimage + run: | + pushd $(mktemp -d) + go mod init example.com/demo + cat < main.go + package main + import "fmt" + func main() { + fmt.Println("hello world") + } + EOF + demoimage=`ko publish -B example.com/demo` + echo "demoimage=$demoimage" >> $GITHUB_ENV + echo Created image $demoimage + popd + + - name: Create sample image2 - demoimage2 + run: | + pushd $(mktemp -d) + go mod init example.com/demo2 + cat < main.go + package main + import "fmt" + func main() { + fmt.Println("hello world 2") + } + EOF + demoimage2=`ko publish -B example.com/demo2` + echo "demoimage2=$demoimage2" >> $GITHUB_ENV + echo Created image $demoimage2 + popd + + # TODO(vaikas): There should be a fake issuer on the cluster. This one + # fetches a k8s auth token from the kind cluster that we spin up above. We + # do not want to use the Github OIDC token, but do want PRs to run this + # flow. + - name: Install a Knative service for fetch tokens off the cluster + run: | + ko apply -f ./test/config/gettoken + sleep 2 + kubectl wait --for=condition=Ready --timeout=15s ksvc gettoken + + # These set up the env variables so that + - name: Set the endpoints on the cluster and grab secrets + run: | + REKOR_URL=`kubectl -n rekor-system get --no-headers ksvc rekor | cut -d ' ' -f 4` + echo "REKOR_URL=$REKOR_URL" >> $GITHUB_ENV + curl -s $REKOR_URL/api/v1/log/publicKey > ./rekor-public.pem + + FULCIO_URL=`kubectl -n fulcio-system get --no-headers ksvc fulcio | cut -d ' ' -f 4` + echo "FULCIO_URL=$FULCIO_URL" >> $GITHUB_ENV + CTLOG_URL=`kubectl -n ctlog-system get --no-headers ksvc ctlog | cut -d ' ' -f 4` + echo "CTLOG_URL=$CTLOG_URL" >> $GITHUB_ENV + + ISSUER_URL=`kubectl get --no-headers ksvc gettoken | cut -d ' ' -f 4` + echo "ISSUER_URL=$ISSUER_URL" >> $GITHUB_ENV + OIDC_TOKEN=`curl -s $ISSUER_URL` + echo "OIDC_TOKEN=$OIDC_TOKEN" >> $GITHUB_ENV + + kubectl -n ctlog-system get secrets ctlog-public-key -o=jsonpath='{.data.public}' | base64 -d > ./ctlog-public.pem + echo "SIGSTORE_CT_LOG_PUBLIC_KEY_FILE=./ctlog-public.pem" >> $GITHUB_ENV + + kubectl -n fulcio-system get secrets fulcio-secret -ojsonpath='{.data.cert}' | base64 -d > ./fulcio-root.pem + echo "SIGSTORE_ROOT_FILE=./fulcio-root.pem" >> $GITHUB_ENV + + - name: Deploy ClusterImagePolicy + run: | + kubectl apply -f ./test/testdata/cosigned/e2e/cip.yaml + + - name: build cosign + run: | + make cosign + + - name: Sign demoimage with cosign + run: | + ./cosign sign --rekor-url ${{ env.REKOR_URL }} --fulcio-url ${{ env.FULCIO_URL }} --force --allow-insecure-registry ${{ env.demoimage }} --identity-token ${{ env.OIDC_TOKEN }} + + - name: Verify with cosign + run: | + SIGSTORE_TRUST_REKOR_API_PUBLIC_KEY=1 COSIGN_EXPERIMENTAL=1 ./cosign verify --rekor-url ${{ env.REKOR_URL }} --allow-insecure-registry ${{ env.demoimage }} + + - name: Deploy jobs and verify signed works, unsigned fails + run: | + kubectl create namespace demo + kubectl label namespace demo cosigned.sigstore.dev/include=true + + echo '::group:: test job success' + # We signed this above, this should work + if ! kubectl create -n demo job demo --image=${{ env.demoimage }} ; then + echo Failed to create Job in namespace without label! + exit 1 + else + echo Succcessfully created Job with signed image + fi + echo '::endgroup:: test job success' + + echo '::group:: test job rejection' + # We did not sign this, should fail + if kubectl create -n demo job demo2 --image=${{ env.demoimage2 }} ; then + echo Failed to block unsigned Job creation! + exit 1 + else + echo Successfully blocked Job creation with unsigned image + fi + echo '::endgroup::' + + - name: Collect diagnostics + if: ${{ failure() }} + uses: chainguard-dev/actions/kind-diag@84c993eaf02da1c325854fb272a4df9184bd80fc # main diff --git a/config/webhook.yaml b/config/webhook.yaml index 2ed482ef32f..7971f2687b5 100644 --- a/config/webhook.yaml +++ b/config/webhook.yaml @@ -62,6 +62,10 @@ spec: value: sigstore.dev/cosigned - name: WEBHOOK_NAME value: webhook + # Since we need to validate against different Rekor clients based on + # differing policies, we fetch the Rekor public key during validation. + - name: SIGSTORE_TRUST_REKOR_API_PUBLIC_KEY + value: "true" securityContext: allowPrivilegeEscalation: false @@ -103,4 +107,4 @@ metadata: namespace: cosign-system # stringData: # cosign.pub: | -# \ No newline at end of file +# diff --git a/go.mod b/go.mod index 744e7d169a1..4b048b4d79b 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,7 @@ require ( github.com/hashicorp/go-secure-stdlib/parseutil v0.1.3 github.com/hashicorp/yamux v0.0.0-20211028200310-0bc27b27de87 github.com/in-toto/in-toto-golang v0.3.4-0.20211211042327-af1f9fb822bf + github.com/kelseyhightower/envconfig v1.4.0 github.com/manifoldco/promptui v0.9.0 github.com/miekg/pkcs11 v1.1.1 github.com/mitchellh/go-homedir v1.1.0 @@ -218,7 +219,6 @@ require ( github.com/jonboulle/clockwork v0.2.2 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/kelseyhightower/envconfig v1.4.0 // indirect github.com/klauspost/compress v1.14.2 // indirect github.com/leodido/go-urn v1.2.1 // indirect github.com/magiconair/properties v1.8.5 // indirect diff --git a/pkg/apis/config/image_policies.go b/pkg/apis/config/image_policies.go index b63d392ac27..100d8192363 100644 --- a/pkg/apis/config/image_policies.go +++ b/pkg/apis/config/image_policies.go @@ -81,30 +81,32 @@ func parseEntry(entry string, out interface{}) error { return json.Unmarshal(j, &out) } -// GetAuthorities returns all matching Authorities that need to be matched for -// the given Image. -func (p *ImagePolicyConfig) GetAuthorities(image string) ([]v1alpha1.Authority, error) { +// GetMatchingPolicies returns all matching Policies and their Authorities that +// need to be matched for the given Image. +// Returned map contains the name of the CIP as the key, and an array of +// authorities from that Policy that must be validated against. +func (p *ImagePolicyConfig) GetMatchingPolicies(image string) (map[string][]v1alpha1.Authority, error) { if p == nil { return nil, errors.New("config is nil") } var lastError error - ret := []v1alpha1.Authority{} + ret := map[string][]v1alpha1.Authority{} // TODO(vaikas): this is very inefficient, we should have a better // way to go from image to Authorities, but just seeing if this is even // workable so fine for now. - for _, v := range p.Policies { + for k, v := range p.Policies { for _, pattern := range v.Images { if pattern.Glob != "" { if GlobMatch(image, pattern.Glob) { - ret = append(ret, v.Authorities...) + ret[k] = append(ret[k], v.Authorities...) } } else if pattern.Regex != "" { if regex, err := regexp.Compile(pattern.Regex); err != nil { lastError = err } else if regex.MatchString(image) { - ret = append(ret, v.Authorities...) + ret[k] = append(ret[k], v.Authorities...) } } } diff --git a/pkg/apis/config/image_policies_test.go b/pkg/apis/config/image_policies_test.go index d3562442c53..490c975f5c4 100644 --- a/pkg/apis/config/image_policies_test.go +++ b/pkg/apis/config/image_policies_test.go @@ -43,62 +43,90 @@ func TestGetAuthorities(t *testing.T) { if err != nil { t.Error("NewImagePoliciesConfigFromConfigMap(example) =", err) } - c, err := defaults.GetAuthorities("rando") + c, err := defaults.GetMatchingPolicies("rando") checkGetMatches(t, c, err) + matchedPolicy := "cluster-image-policy-0" want := "inlinedata here" - if got := c[0].Key.Data; got != want { + if got := c[matchedPolicy][0].Key.Data; got != want { t.Errorf("Did not get what I wanted %q, got %+v", want, got) } // Make sure glob matches 'randomstuff*' - c, err = defaults.GetAuthorities("randomstuffhere") + c, err = defaults.GetMatchingPolicies("randomstuffhere") checkGetMatches(t, c, err) + matchedPolicy = "cluster-image-policy-1" want = "otherinline here" - if got := c[0].Key.Data; got != want { + if got := c[matchedPolicy][0].Key.Data; got != want { t.Errorf("Did not get what I wanted %q, got %+v", want, got) } - c, err = defaults.GetAuthorities("rando3") + c, err = defaults.GetMatchingPolicies("rando3") checkGetMatches(t, c, err) + matchedPolicy = "cluster-image-policy-2" want = "cacert chilling here" - if got := c[0].Keyless.CACert.Data; got != want { - t.Errorf("Did not get what I wanted %q, got %+v", want, c[0].Keyless.CACert.Data) + if got := c[matchedPolicy][0].Keyless.CACert.Data; got != want { + t.Errorf("Did not get what I wanted %q, got %+v", want, got) } want = "issuer" - if got := c[0].Keyless.Identities[0].Issuer; got != want { - t.Errorf("Did not get what I wanted %q, got %+v", want, c[0].Keyless.Identities[0].Issuer) + if got := c[matchedPolicy][0].Keyless.Identities[0].Issuer; got != want { + t.Errorf("Did not get what I wanted %q, got %+v", want, got) } want = "subject" - if got := c[0].Keyless.Identities[0].Subject; got != want { - t.Errorf("Did not get what I wanted %q, got %+v", want, c[0].Keyless.Identities[0].Subject) + if got := c[matchedPolicy][0].Keyless.Identities[0].Subject; got != want { + t.Errorf("Did not get what I wanted %q, got %+v", want, got) } // Make sure regex matches ".*regexstring.*" - c, err = defaults.GetAuthorities("randomregexstringstuff") + c, err = defaults.GetMatchingPolicies("randomregexstringstuff") checkGetMatches(t, c, err) + matchedPolicy = "cluster-image-policy-4" want = inlineKeyData - if got := c[0].Key.Data; got != want { + if got := c[matchedPolicy][0].Key.Data; got != want { t.Errorf("Did not get what I wanted %q, got %+v", want, got) } // Test multiline yaml cert - c, err = defaults.GetAuthorities("inlinecert") + c, err = defaults.GetMatchingPolicies("inlinecert") checkGetMatches(t, c, err) + matchedPolicy = "cluster-image-policy-3" want = inlineKeyData - if got := c[0].Key.Data; got != want { + if got := c[matchedPolicy][0].Key.Data; got != want { t.Errorf("Did not get what I wanted %q, got %+v", want, got) } // Test multiline cert but json encoded - c, err = defaults.GetAuthorities("ghcr.io/example/*") + c, err = defaults.GetMatchingPolicies("ghcr.io/example/*") checkGetMatches(t, c, err) + matchedPolicy = "cluster-image-policy-json" want = inlineKeyData - if got := c[0].Key.Data; got != want { + if got := c[matchedPolicy][0].Key.Data; got != want { + t.Errorf("Did not get what I wanted %q, got %+v", want, got) + } + // Test multiple matches + c, err = defaults.GetMatchingPolicies("regexstringtoo") + checkGetMatches(t, c, err) + if len(c) != 2 { + t.Errorf("Wanted two matches, got %d", len(c)) + } + matchedPolicy = "cluster-image-policy-4" + want = inlineKeyData + if got := c[matchedPolicy][0].Key.Data; got != want { + t.Errorf("Did not get what I wanted %q, got %+v", want, got) + } + matchedPolicy = "cluster-image-policy-5" + want = "inlinedata here" + if got := c[matchedPolicy][0].Key.Data; got != want { t.Errorf("Did not get what I wanted %q, got %+v", want, got) } } -func checkGetMatches(t *testing.T, c []v1alpha1.Authority, err error) { +func checkGetMatches(t *testing.T, c map[string][]v1alpha1.Authority, err error) { if err != nil { t.Error("GetMatches Failed =", err) } if len(c) == 0 { t.Error("Wanted a config, got none.") } + for _, v := range c { + if v != nil || len(v) > 0 { + return + } + } + t.Error("Wanted a config and non-zero authorities, got no authorities") } diff --git a/pkg/apis/config/testdata/config-image-policies.yaml b/pkg/apis/config/testdata/config-image-policies.yaml index fe6f26e84a6..ad7154c3f02 100644 --- a/pkg/apis/config/testdata/config-image-policies.yaml +++ b/pkg/apis/config/testdata/config-image-policies.yaml @@ -72,5 +72,11 @@ data: MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAExB6+H6054/W1SJgs5JR6AJr6J35J RCTfQ5s1kD+hGMSE1rH7s46hmXEeyhnlRnaGF8eMU/SBJE/2NKPnxE7WzQ== -----END PUBLIC KEY----- + cluster-image-policy-5: | + images: + - regex: .*regexstringtoo.* + authorities: + - key: + data: inlinedata here cluster-image-policy-json: "{\"images\":[{\"glob\":\"ghcr.io/example/*\",\"regex\":\"\"}],\"authorities\":[{\"key\":{\"data\":\"-----BEGIN PUBLIC KEY-----\\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAExB6+H6054/W1SJgs5JR6AJr6J35J\\nRCTfQ5s1kD+hGMSE1rH7s46hmXEeyhnlRnaGF8eMU/SBJE/2NKPnxE7WzQ==\\n-----END PUBLIC KEY-----\"}}]}" diff --git a/pkg/cosign/kubernetes/webhook/validation.go b/pkg/cosign/kubernetes/webhook/validation.go index ab1d9854a84..55b379b8324 100644 --- a/pkg/cosign/kubernetes/webhook/validation.go +++ b/pkg/cosign/kubernetes/webhook/validation.go @@ -32,13 +32,14 @@ import ( "github.com/sigstore/cosign/pkg/cosign" "github.com/sigstore/cosign/pkg/oci" ociremote "github.com/sigstore/cosign/pkg/oci/remote" + "github.com/sigstore/rekor/pkg/generated/client" "github.com/sigstore/sigstore/pkg/signature" ) func valid(ctx context.Context, ref name.Reference, keys []*ecdsa.PublicKey, opts ...ociremote.Option) error { if len(keys) == 0 { // If there are no keys, then verify against the fulcio root. - sps, err := validSignatures(ctx, ref, nil /* verifier */, opts...) + sps, err := validSignaturesWithFulcio(ctx, ref, fulcioroots.Get(), nil /* rekor */, opts...) if err != nil { return err } @@ -77,13 +78,24 @@ var cosignVerifySignatures = cosign.VerifyImageSignatures func validSignatures(ctx context.Context, ref name.Reference, verifier signature.Verifier, opts ...ociremote.Option) ([]oci.Signature, error) { sigs, _, err := cosignVerifySignatures(ctx, ref, &cosign.CheckOpts{ RegistryClientOpts: opts, - RootCerts: fulcioroots.Get(), SigVerifier: verifier, ClaimVerifier: cosign.SimpleClaimVerifier, }) return sigs, err } +// validSignaturesWithFulcio expects a Fulcio Cert to verify against. An +// optional rekorClient can also be given, if nil passed, default is assumed. +func validSignaturesWithFulcio(ctx context.Context, ref name.Reference, fulcioRoots *x509.CertPool, rekorClient *client.Rekor, opts ...ociremote.Option) ([]oci.Signature, error) { + sigs, _, err := cosignVerifySignatures(ctx, ref, &cosign.CheckOpts{ + RegistryClientOpts: opts, + RootCerts: fulcioRoots, + RekorClient: rekorClient, + ClaimVerifier: cosign.SimpleClaimVerifier, + }) + return sigs, err +} + func getKeys(ctx context.Context, cfg map[string][]byte) ([]*ecdsa.PublicKey, *apis.FieldError) { keys := []*ecdsa.PublicKey{} errs := []error{} diff --git a/pkg/cosign/kubernetes/webhook/validator.go b/pkg/cosign/kubernetes/webhook/validator.go index f2b5d7fc6ac..05e72c66eeb 100644 --- a/pkg/cosign/kubernetes/webhook/validator.go +++ b/pkg/cosign/kubernetes/webhook/validator.go @@ -17,14 +17,20 @@ package webhook import ( "context" - "crypto/ecdsa" + "crypto/x509" "fmt" + "github.com/google/go-containerregistry/pkg/authn" "github.com/google/go-containerregistry/pkg/authn/k8schain" "github.com/google/go-containerregistry/pkg/name" "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/pkg/errors" "github.com/sigstore/cosign/pkg/apis/config" + "github.com/sigstore/cosign/pkg/apis/cosigned/v1alpha1" ociremote "github.com/sigstore/cosign/pkg/oci/remote" + "github.com/sigstore/fulcio/pkg/api" + rekor "github.com/sigstore/rekor/pkg/client" + "github.com/sigstore/rekor/pkg/generated/client" corev1 "k8s.io/api/core/v1" "k8s.io/client-go/kubernetes" listersv1 "k8s.io/client-go/listers/core/v1" @@ -142,13 +148,58 @@ func (v *Validator) validatePodSpec(ctx context.Context, ps *corev1.PodSpec, opt containerKeys := keys config := config.FromContext(ctx) + + // During the migration from the secret only validation into policy + // based ones. If there were matching policies that successfully + // validated the image, keep tally of it and if all Policies that + // matched validated, skip the traditional one since they are not + // necessarily going to play nicely together. + passedPolicyChecks := false if config != nil { - authorityKeys, fieldErrors := getAuthorityKeys(ctx, ref, config) - if fieldErrors != nil { - // TODO:(dennyhoang) Enforce currently non-breaking errors https://github.com/sigstore/cosign/issues/1642 - logging.FromContext(ctx).Warnf("Failed to fetch authorities for %s : %v", ref.Name(), fieldErrors) + policies, err := config.ImagePolicyConfig.GetMatchingPolicies(ref.Name()) + if err != nil { + errorField := apis.ErrGeneric(err.Error(), "image").ViaFieldIndex(field, i) + errorField.Details = c.Image + errs = errs.Also(errorField) + continue + } + + // If there is at least one policy that matches, that means it + // has to be satisfied. + if len(policies) > 0 { + av, fieldErrors := validatePolicies(ctx, ref, kc, policies) + if av != len(policies) { + logging.FromContext(ctx).Warnf("Failed to validate at least one policy for %s", ref.Name()) + // Do we really want to add all the error details here? + // Seems like we can just say which policy failed, so + // doing that for now. + for failingPolicy := range fieldErrors { + errorField := apis.ErrGeneric(fmt.Sprintf("failed policy: %s", failingPolicy), "image").ViaFieldIndex(field, i) + errorField.Details = c.Image + errs = errs.Also(errorField) + } + // Because there was at least one policy that was + // supposed to be validated, but it failed, then fail + // this image. It should not fall through to the + // traditional secret checking so it does not slip + // through the policy cracks, and also to reduce noise + // in the errors returned to the user. + continue + } else { + logging.FromContext(ctx).Warnf("Validated authorities for %s", ref.Name()) + // Only say we passed (aka, we skip the traditidional check + // below) if more than one authority was validated, which + // means that there was a matching ClusterImagePolicy. + if av > 0 { + passedPolicyChecks = true + } + } } - containerKeys = append(containerKeys, authorityKeys...) + } + + if passedPolicyChecks { + logging.FromContext(ctx).Debugf("Found at least one matching policy and it was validated for %s", ref.Name()) + continue } if err := valid(ctx, ref, containerKeys, ociremote.WithRemoteOptions(remote.WithAuthFromKeychain(kc))); err != nil { @@ -166,24 +217,106 @@ func (v *Validator) validatePodSpec(ctx context.Context, ps *corev1.PodSpec, opt return errs } -func getAuthorityKeys(ctx context.Context, ref name.Reference, config *config.Config) (keys []*ecdsa.PublicKey, errs *apis.FieldError) { - authorities, err := config.ImagePolicyConfig.GetAuthorities(ref.Name()) - if err != nil { - return keys, apis.ErrGeneric(fmt.Sprintf("failed to fetch authorities for %s : %v", ref.Name(), err), apis.CurrentField) - } +// validatePolicies will go through all the matching Policies and their +// Authorities for a given image. Returns the number of Policies that +// had at least one successful validation against it. +// If there's a policy that did not match, it will be returned in the map +// along with all the errors that caused it to fail. +// Note that if an image does not match any policies, it's perfectly +// reasonable that the return value is 0, nil since there were no errors, but +// the image was not validated against any matching policy and hence authority. +func validatePolicies(ctx context.Context, ref name.Reference, defaultKC authn.Keychain, policies map[string][]v1alpha1.Authority, _ ...ociremote.Option) (int, map[string][]error) { + // For a policy that does not pass at least one authority, gather errors + // here so that we can give meaningful errors to the user. + ret := map[string][]error{} + // For each matching policy it must validate at least one Authority within + // it. + // From the Design document, the part about multiple Policies matching: + // "If multiple policies match a particular image, then ALL of those + // policies must be satisfied for the image to be admitted." + // So we keep a tally to make sure that all the policies matched. + policiesValidated := 0 + // If none of the Authorities for a given policy pass the checks, gather + // the errors here. If one passes, do not return the errors. + authorityErrors := []error{} + for p, authorities := range policies { + logging.FromContext(ctx).Debugf("Checking Policy: %s", p) + // Now the part about having multiple Authority sections within a + // policy, any single match will do: + // "When multiple authorities are specified, any of them may be used + // to source the valid signature we are looking for to admit an image."" + authoritiesValidated := 0 + for _, authority := range authorities { + logging.FromContext(ctx).Debugf("Checking Authority: %+v", authority) + // TODO(vaikas): We currently only use the defaultKC, we have to look + // at authority.Sources to determine additional information for the + // WithRemoteOptions below, at least the 'TargetRepository' + // https://github.com/sigstore/cosign/issues/1651 + opts := ociremote.WithRemoteOptions(remote.WithAuthFromKeychain(defaultKC)) - for _, authority := range authorities { - if authority.Key != nil { - // Get the key from authority data - if authorityKeys, fieldErr := parseAuthorityKeys(ctx, authority.Key.Data); fieldErr != nil { - errs = errs.Also(fieldErr) - } else { - keys = append(keys, authorityKeys...) + if authority.Key != nil { + // Get the key from authority data + if authorityKeys, fieldErr := parseAuthorityKeys(ctx, authority.Key.Data); fieldErr != nil { + authorityErrors = append(authorityErrors, errors.Wrap(fieldErr, "failed to parse Key values")) + } else { + // TODO(vaikas): What should happen if there are multiple keys + // Is it even allowed? 'valid' returns success if any key + // matches. + // https://github.com/sigstore/cosign/issues/1652 + if err := valid(ctx, ref, authorityKeys, opts); err != nil { + authorityErrors = append(authorityErrors, errors.Wrap(err, "failed to validate keys")) + continue + } + // This authority matched, so mark it as validated and + // continue through other policies, no need to look at more + // of the Authorities. + authoritiesValidated++ + break + } + } + if authority.Keyless != nil && authority.Keyless.URL != nil { + logging.FromContext(ctx).Debugf("Fetching FulcioRoot for %s : From: %s ", ref.Name(), authority.Keyless.URL) + fulcioroot, err := getFulcioCert(authority.Keyless.URL) + if err != nil { + authorityErrors = append(authorityErrors, errors.Wrap(err, "failed to fetch FulcioRoot")) + continue + } + var rekorClient *client.Rekor + if authority.CTLog != nil && authority.CTLog.URL != nil { + logging.FromContext(ctx).Debugf("Using CTLog %s for %s", authority.CTLog.URL, ref.Name()) + rekorClient, err = rekor.GetRekorClient(authority.CTLog.URL.String()) + if err != nil { + logging.FromContext(ctx).Errorf("failed creating rekor client: +v", err) + authorityErrors = append(authorityErrors, errors.Wrap(err, "creating Rekor client")) + continue + } + } + sps, err := validSignaturesWithFulcio(ctx, ref, fulcioroot, rekorClient, opts) + if err != nil { + logging.FromContext(ctx).Errorf("failed validSignatures for %s: %v", ref.Name(), err) + authorityErrors = append(authorityErrors, errors.Wrap(err, "validate signatures")) + } else { + if len(sps) > 0 { + logging.FromContext(ctx).Debugf("validated signature for %s, got %d signatures", len(sps)) + // This authority matched, so mark it as validated and + // continue through other policies, no need to look at + // more of the Authorities. + authoritiesValidated++ + break + } else { + logging.FromContext(ctx).Errorf("no validSignatures found for %s", ref.Name()) + authorityErrors = append(authorityErrors, fmt.Errorf("no valid signatures found for %s", ref.Name())) + } + } } } + if authoritiesValidated > 0 { + policiesValidated++ + } else { + ret[p] = append(ret[p], authorityErrors...) + } } - - return keys, errs + return policiesValidated, ret } // ResolvePodSpecable implements duckv1.PodSpecValidator @@ -275,3 +408,17 @@ func (v *Validator) resolvePodSpec(ctx context.Context, ps *corev1.PodSpec, opt resolveContainers(ps.InitContainers) resolveContainers(ps.Containers) } + +func getFulcioCert(u *apis.URL) (*x509.CertPool, error) { + fClient := api.NewClient(u.URL()) + rootCertResponse, err := fClient.RootCert() + if err != nil { + return nil, errors.Wrap(err, "getting root cert") + } + + cp := x509.NewCertPool() + if !cp.AppendCertsFromPEM(rootCertResponse.ChainPEM) { + return nil, errors.New("error appending to root cert pool") + } + return cp, nil +} diff --git a/pkg/cosign/kubernetes/webhook/validator_test.go b/pkg/cosign/kubernetes/webhook/validator_test.go index 4d9b1473584..a9ce697e310 100644 --- a/pkg/cosign/kubernetes/webhook/validator_test.go +++ b/pkg/cosign/kubernetes/webhook/validator_test.go @@ -22,6 +22,8 @@ import ( "crypto/elliptic" "crypto/x509" "errors" + "net/http" + "net/http/httptest" "testing" "time" @@ -46,6 +48,11 @@ import ( "knative.dev/pkg/system" ) +const ( + fulcioRootCert = "-----BEGIN CERTIFICATE-----\nMIICNzCCAd2gAwIBAgITPLBoBQhl1hqFND9S+SGWbfzaRTAKBggqhkjOPQQDAjBo\nMQswCQYDVQQGEwJVSzESMBAGA1UECBMJV2lsdHNoaXJlMRMwEQYDVQQHEwpDaGlw\ncGVuaGFtMQ8wDQYDVQQKEwZSZWRIYXQxDDAKBgNVBAsTA0NUTzERMA8GA1UEAxMI\ndGVzdGNlcnQwHhcNMjEwMzEyMjMyNDQ5WhcNMzEwMjI4MjMyNDQ5WjBoMQswCQYD\nVQQGEwJVSzESMBAGA1UECBMJV2lsdHNoaXJlMRMwEQYDVQQHEwpDaGlwcGVuaGFt\nMQ8wDQYDVQQKEwZSZWRIYXQxDDAKBgNVBAsTA0NUTzERMA8GA1UEAxMIdGVzdGNl\ncnQwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQRn+Alyof6xP3GQClSwgV0NFuY\nYEwmKP/WLWr/LwB6LUYzt5v49RlqG83KuaJSpeOj7G7MVABdpIZYWwqAiZV3o2Yw\nZDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBATAdBgNVHQ4EFgQU\nT8Jwm6JuVb0dsiuHUROiHOOVHVkwHwYDVR0jBBgwFoAUT8Jwm6JuVb0dsiuHUROi\nHOOVHVkwCgYIKoZIzj0EAwIDSAAwRQIhAJkNZmP6sKA+8EebRXFkBa9DPjacBpTc\nOljJotvKidRhAiAuNrIazKEw2G4dw8x1z6EYk9G+7fJP5m93bjm/JfMBtA==\n-----END CERTIFICATE-----" + rekorResponse = "bad response" +) + func TestValidatePodSpec(t *testing.T) { tag := name.MustParseReference("gcr.io/distroless/static:nonroot") // Resolved via crane digest on 2021/09/25 @@ -56,6 +63,28 @@ func TestValidatePodSpec(t *testing.T) { secretName := "blah" + // Non-existent URL for testing complete failure + badURL := apis.HTTP("http://example.com/") + + // Spin up a Fulcio that responds with a Root Cert + fulcioServer := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + rw.Write([]byte(fulcioRootCert)) + })) + t.Cleanup(fulcioServer.Close) + fulcioURL, err := apis.ParseURL(fulcioServer.URL) + if err != nil { + t.Fatalf("Failed to parse fake Fulcio URL") + } + + rekorServer := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + rw.Write([]byte(rekorResponse)) + })) + t.Cleanup(rekorServer.Close) + rekorURL, err := apis.ParseURL(rekorServer.URL) + if err != nil { + t.Fatalf("Failed to parse fake Rekor URL") + } + var authorityKeyCosignPub *ecdsa.PublicKey // Random public key (cosign generate-key-pair) 2022-03-18 authorityKeyCosignPubString := `-----BEGIN PUBLIC KEY----- @@ -219,6 +248,138 @@ UoJou2P8sbDxpLiE/v3yLw1/jyOrCPWYHWFXnyyeGlkgSVefG54tNoK7Uw== }, ), cvs: authorityPublicKeyCVS, + }, { + name: "simple, error, authority keyless, bad fulcio", + ps: &corev1.PodSpec{ + InitContainers: []corev1.Container{{ + Name: "setup-stuff", + Image: digest.String(), + }}, + Containers: []corev1.Container{{ + Name: "user-container", + Image: digest.String(), + }}, + }, + customContext: config.ToContext(context.Background(), + &config.Config{ + ImagePolicyConfig: &config.ImagePolicyConfig{ + Policies: map[string]v1alpha1.ClusterImagePolicySpec{ + "cluster-image-policy-keyless": { + Images: []v1alpha1.ImagePattern{{ + Regex: ".*", + }}, + Authorities: []v1alpha1.Authority{ + { + Keyless: &v1alpha1.KeylessRef{ + URL: badURL, + }, + }, + }, + }, + }, + }, + }, + ), + want: func() *apis.FieldError { + var errs *apis.FieldError + fe := apis.ErrGeneric("failed policy: cluster-image-policy-keyless", "image").ViaFieldIndex("initContainers", 0) + fe.Details = digest.String() + errs = errs.Also(fe) + fe2 := apis.ErrGeneric("failed policy: cluster-image-policy-keyless", "image").ViaFieldIndex("containers", 0) + fe2.Details = digest.String() + errs = errs.Also(fe2) + return errs + }(), + cvs: fail, + }, { + name: "simple, error, authority keyless, good fulcio, no rekor", + ps: &corev1.PodSpec{ + InitContainers: []corev1.Container{{ + Name: "setup-stuff", + Image: digest.String(), + }}, + Containers: []corev1.Container{{ + Name: "user-container", + Image: digest.String(), + }}, + }, + customContext: config.ToContext(context.Background(), + &config.Config{ + ImagePolicyConfig: &config.ImagePolicyConfig{ + Policies: map[string]v1alpha1.ClusterImagePolicySpec{ + "cluster-image-policy-keyless": { + Images: []v1alpha1.ImagePattern{{ + Regex: ".*", + }}, + Authorities: []v1alpha1.Authority{ + { + Keyless: &v1alpha1.KeylessRef{ + URL: fulcioURL, + }, + }, + }, + }, + }, + }, + }, + ), + want: func() *apis.FieldError { + var errs *apis.FieldError + fe := apis.ErrGeneric("failed policy: cluster-image-policy-keyless", "image").ViaFieldIndex("initContainers", 0) + fe.Details = digest.String() + errs = errs.Also(fe) + fe2 := apis.ErrGeneric("failed policy: cluster-image-policy-keyless", "image").ViaFieldIndex("containers", 0) + fe2.Details = digest.String() + errs = errs.Also(fe2) + return errs + }(), + cvs: fail, + }, { + name: "simple, error, authority keyless, good fulcio, bad rekor", + ps: &corev1.PodSpec{ + InitContainers: []corev1.Container{{ + Name: "setup-stuff", + Image: digest.String(), + }}, + Containers: []corev1.Container{{ + Name: "user-container", + Image: digest.String(), + }}, + }, + customContext: config.ToContext(context.Background(), + &config.Config{ + ImagePolicyConfig: &config.ImagePolicyConfig{ + Policies: map[string]v1alpha1.ClusterImagePolicySpec{ + "cluster-image-policy-keyless": { + Images: []v1alpha1.ImagePattern{{ + Regex: ".*", + }}, + Authorities: []v1alpha1.Authority{ + { + Keyless: &v1alpha1.KeylessRef{ + URL: fulcioURL, + }, + CTLog: &v1alpha1.TLog{ + URL: rekorURL, + }, + }, + }, + }, + }, + }, + }, + ), + want: func() *apis.FieldError { + var errs *apis.FieldError + fe := apis.ErrGeneric("failed policy: cluster-image-policy-keyless", "image").ViaFieldIndex("initContainers", 0) + fe.Details = digest.String() + errs = errs.Also(fe) + fe2 := apis.ErrGeneric("failed policy: cluster-image-policy-keyless", "image").ViaFieldIndex("containers", 0) + fe2.Details = digest.String() + errs = errs.Also(fe2) + return errs + }(), + cvs: fail, }} for _, test := range tests { @@ -940,99 +1101,3 @@ UoJou2P8sbDxpLiE/v3yLw1/jyOrCPWYHWFXnyyeGlkgSVefG54tNoK7Uw== }) } } - -func TestGetAuthorityKeys(t *testing.T) { - refName := name.MustParseReference("gcr.io/distroless/static:nonroot") - // Random public key (cosign generate-key-pair) 2021-09-25 - validPublicKey := `-----BEGIN PUBLIC KEY----- -MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEapTW568kniCbL0OXBFIhuhOboeox -UoJou2P8sbDxpLiE/v3yLw1/jyOrCPWYHWFXnyyeGlkgSVefG54tNoK7Uw== ------END PUBLIC KEY-----` - - validKeyData := v1alpha1.Authority{ - Key: &v1alpha1.KeyRef{ - Data: validPublicKey, - }, - } - - tests := []struct { - name string - imagePatterns []v1alpha1.ImagePattern - authorities []v1alpha1.Authority - wantKeyLength int - expectErr bool - }{ - { - name: "no authorities", - wantKeyLength: 0, - expectErr: false, - }, { - name: "invalid regex and one key data", - imagePatterns: []v1alpha1.ImagePattern{{ - Regex: "*", - }}, - authorities: []v1alpha1.Authority{ - validKeyData, - }, - wantKeyLength: 0, - expectErr: true, - }, { - name: "unmatching glob and one key data", - imagePatterns: []v1alpha1.ImagePattern{{ - Glob: "-", - }}, - authorities: []v1alpha1.Authority{ - validKeyData, - }, - wantKeyLength: 0, - expectErr: false, - }, { - name: "wildcard glob and one key data", - imagePatterns: []v1alpha1.ImagePattern{{ - Glob: "*", - }}, - authorities: []v1alpha1.Authority{ - validKeyData, - }, - wantKeyLength: 1, - expectErr: false, - }, { - name: "wildcard regex and one key data", - imagePatterns: []v1alpha1.ImagePattern{{ - Regex: ".*", - }}, - authorities: []v1alpha1.Authority{ - validKeyData, - }, - wantKeyLength: 1, - expectErr: false, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - config := config.Config{ - ImagePolicyConfig: &config.ImagePolicyConfig{ - Policies: map[string]v1alpha1.ClusterImagePolicySpec{ - "cluster-image-policy": { - Images: test.imagePatterns, - Authorities: test.authorities, - }, - }, - }, - } - - keys, err := getAuthorityKeys(context.Background(), refName, &config) - if test.expectErr && err == nil { - t.Error("Did not get wanted error") - } - if !test.expectErr && err != nil { - t.Error("Did get unwanted error") - } - - if got := len(keys); got != test.wantKeyLength { - t.Errorf("Did not get what I wanted %d, got %d", test.wantKeyLength, got) - } - }) - } -} diff --git a/test/cmd/getoidctoken/main.go b/test/cmd/getoidctoken/main.go new file mode 100644 index 00000000000..82333c59dc9 --- /dev/null +++ b/test/cmd/getoidctoken/main.go @@ -0,0 +1,58 @@ +// Copyright 2022 The Sigstore 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 main + +import ( + "fmt" + "io/ioutil" + "log" + "net/http" + + "github.com/kelseyhightower/envconfig" +) + +type envConfig struct { + FileName string `envconfig:"OIDC_FILE" default:"/var/run/sigstore/cosign/oidc-token" required:"true"` +} + +func tokenWriter(filename string) func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, req *http.Request) { + getToken(filename, w, req) + } +} +func getToken(tokenFile string, w http.ResponseWriter, _ *http.Request) { + content, err := ioutil.ReadFile(tokenFile) + if err != nil { + log.Print("failed to read token file", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + _, err = fmt.Fprint(w, string(content)) + if err != nil { + log.Print("failed to write token file to response", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +func main() { + var env envConfig + if err := envconfig.Process("", &env); err != nil { + log.Fatalf("failed to process env var: %s", err) + } + http.HandleFunc("/", tokenWriter(env.FileName)) + if err := http.ListenAndServe(":8080", nil); err != nil { + panic(err) + } +} diff --git a/test/config/gettoken/gettoken.yaml b/test/config/gettoken/gettoken.yaml new file mode 100644 index 00000000000..20ec657fe3b --- /dev/null +++ b/test/config/gettoken/gettoken.yaml @@ -0,0 +1,38 @@ +# Copyright 2022 The Sigstore 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 +# +# https://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. + +apiVersion: serving.knative.dev/v1 +kind: Service +metadata: + name: gettoken +spec: + template: + spec: + containers: + - name: gettoken + image: ko://github.com/sigstore/cosign/test/cmd/getoidctoken + env: + - name: OIDC_FILE + value: "/var/run/sigstore/cosign/oidc-token" + volumeMounts: + - name: oidc-info + mountPath: /var/run/sigstore/cosign + volumes: + - name: oidc-info + projected: + sources: + - serviceAccountToken: + path: oidc-token + expirationSeconds: 600 + audience: sigstore diff --git a/test/testdata/cosigned/e2e/cip.yaml b/test/testdata/cosigned/e2e/cip.yaml new file mode 100644 index 00000000000..432e92c688f --- /dev/null +++ b/test/testdata/cosigned/e2e/cip.yaml @@ -0,0 +1,26 @@ +# Copyright 2022 The Sigstore 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 +# +# https://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. + +apiVersion: cosigned.sigstore.dev/v1alpha1 +kind: ClusterImagePolicy +metadata: + name: image-policy +spec: + images: + - glob: registry.local:5000/knative/demo* + authorities: + - keyless: + url: http://fulcio.fulcio-system.svc + ctlog: + url: http://rekor.rekor-system.svc