Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add cosign verify-manifest command #490

Merged
merged 4 commits into from
Jul 29, 2021
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions cmd/cosign/cli/verify_manifest.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// Copyright 2021 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 cli

import (
"context"
"flag"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"regexp"
"strings"

"github.com/peterbourgon/ff/v3/ffcli"
"github.com/pkg/errors"
)

// VerifyCommand verifies all image signatures on a supplied k8s resource
type VerifyManifestCommand struct {
VerifyCommand
}

// Verify builds and returns an ffcli command
func VerifyManifest() *ffcli.Command {
cmd := VerifyManifestCommand{VerifyCommand: VerifyCommand{}}
flagset := flag.NewFlagSet("cosign verify-manifest", flag.ExitOnError)
applyVerifyFlags(&cmd.VerifyCommand, flagset)

return &ffcli.Command{
Name: "verify-manifest",
ShortUsage: "cosign verify-manifest -key <key path>|<key url>|<kms uri> <path/to/manifest>",
ShortHelp: "Verify all signatures of images specified in the manifest",
LongHelp: `Verify all signature of images in a Kubernetes resource manifest by checking claims
against the transparency log.

Shell-like variables in the Dockerfile's FROM lines will be substituted with values from the OS ENV.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this is coming from the verify-dockerfile command description.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated the help docs to reflect.


EXAMPLES
# verify cosign claims and signing certificates on images in the manifest
cosign verify-manifest <path/to/my-deployment.yaml>

# additionally verify specified annotations
cosign verify-manifest -a key1=val1 -a key2=val2 <path/to/my-deployment.yaml>

# (experimental) additionally, verify with the transparency log
COSIGN_EXPERIMENTAL=1 cosign verify-dockerfile <path/to/my-deployment.yaml>

# verify images with public key
cosign verify-manifest -key cosign.pub <path/to/my-deployment.yaml>

# verify images with public key provided by URL
cosign verify-manifest -key https://host.for/<FILE> <path/to/my-deployment.yaml>

# verify images with public key stored in Azure Key Vault
cosign verify-manifest -key azurekms://[VAULT_NAME][VAULT_URI]/[KEY] <path/to/my-deployment.yaml>

# verify images with public key stored in AWS KMS
cosign verify-manifest -key awskms://[ENDPOINT]/[ID/ALIAS/ARN] <path/to/my-deployment.yaml>

# verify images with public key stored in Google Cloud KMS
cosign verify-manifest -key gcpkms://projects/[PROJECT]/locations/global/keyRings/[KEYRING]/cryptoKeys/[KEY] <path/to/my-deployment.yaml>

# verify images with public key stored in Hashicorp Vault
cosign verify-manifest -key hashivault://[KEY] <path/to/my-deployment.yaml>`,

FlagSet: flagset,
Exec: cmd.Exec,
}
}

// Exec runs the verification command
func (c *VerifyManifestCommand) Exec(ctx context.Context, args []string) error {
if len(args) != 1 {
return flag.ErrHelp
}

manifestPath := args[0]

if filepath.Ext(strings.TrimSpace(manifestPath)) != ".yaml" {
return fmt.Errorf("only yaml manifests are supported at this time")
}

manifest, err := ioutil.ReadFile(manifestPath)
if err != nil {
return fmt.Errorf("could not read manifest: %v", err)
}

images, err := getImagesFromYamlManifest(string(manifest))
if err != nil {
return fmt.Errorf("failed extracting images from manifest: %v", err)
}
if len(images) == 0 {
return errors.New("no images found in manifest")
}
fmt.Fprintf(os.Stderr, "Extracted image(s): %s\n", strings.Join(images, ", "))

return c.VerifyCommand.Exec(ctx, images)
}

func getImagesFromYamlManifest(manifest string) ([]string, error) {
var images []string
re := regexp.MustCompile(`image:\s?(?P<Image>.*)\s?`)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Perhaps it could be more robust to decode the manifest and validate that the extensions are among those identified in the issue (Pod, DaemonSet...), then use the decoded object to get the image value for all the containers (e.g. obj.Spec.Containers[*].Image).
Otherwise you could have a some resources that might not be among the identified list, e.g. CRs.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me know if you want me to help you with this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@hectorj2f yes, this was a consideration as mentioned in #437 (comment) - I think it would be better, but as different Kinds come out, it binds us more to the K8s API for parsing unless we still just walk down the tree looking for "image" keys (but guaranteed valid YAML) which would address your other comment.

This regex also doesn't account for quoted string variants, so either way this naive approach needs to change in some fashion.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. There's prior art for ducktyping PodSpec-like Objects, but I don't think it's worth blocking merge for a "perfect solution

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's prior art for ducktyping PodSpec-like Objects.

Yes, you could use runtime.Raw and decode the object to the list of supported types here.

Sure, the current implementation should not be blocked by this. Once this PR is merged, I could open a PR with my proposed changes, so you can have a look at them.

for _, s := range re.FindAllStringSubmatch(manifest, -1) {
images = append(images, s[1])
}
return images, nil
}
92 changes: 92 additions & 0 deletions cmd/cosign/cli/verify_manifest_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Copyright 2021 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 cli

import (
"reflect"
"testing"
)

const SingleContainerManifest = `
apiVersion: v1
kind: Pod
metadata:
name: single-pod
spec:
restartPolicy: Never
containers:
- name: nginx-container
image: nginx:1.21.1
`

const MultiContainerManifest = `
apiVersion: v1
kind: Pod
metadata:
name: multi-pod
spec:
restartPolicy: Never
volumes:
- name: shared-data
emptyDir: {}
containers:
- name: nginx-container
image: nginx:1.21.1
volumeMounts:
- name: shared-data
mountPath: /usr/share/nginx/html
- name: ubuntu-container
image: ubuntu:21.10
volumeMounts:
- name: shared-data
mountPath: /pod-data
command: ["/bin/sh"]
args: ["-c", "echo Hello, World > /pod-data/index.html"]
`

func TestGetImagesFromYamlManifest(t *testing.T) {
testCases := []struct {
name string
fileContents string
expected []string
}{
{
name: "single image",
fileContents: SingleContainerManifest,
expected: []string{"nginx:1.21.1"},
},
{
name: "multi image",
fileContents: MultiContainerManifest,
expected: []string{"nginx:1.21.1", "ubuntu:21.10"},
},
{
name: "no images found",
fileContents: ``,
expected: nil,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
got, err := getImagesFromYamlManifest(tc.fileContents)
if err != nil {
t.Fatalf("getImagesFromYamlManifest returned error: %v", err)
}
if !reflect.DeepEqual(tc.expected, got) {
t.Errorf("getImagesFromYamlManifest returned %v, wanted %v", got, tc.expected)
}
})
}
}
1 change: 1 addition & 0 deletions cmd/cosign/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ func main() {
cli.VerifyAttestation(),
cli.VerifyBlob(),
cli.VerifyDockerfile(),
cli.VerifyManifest(),
// Upload sub-tree
upload.Upload(),
// Download sub-tree
Expand Down
4 changes: 4 additions & 0 deletions test/e2e_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ test_image="gcr.io/distroless/base" ./cosign verify-dockerfile -key ${DISTROLESS
# Image exists, but is unsigned
if (test_image="ubuntu" ./cosign verify-dockerfile -key ${DISTROLESS_PUB_KEY} ./test/testdata/with_arg.Dockerfile); then false; fi

# Test `cosign verify-manifest`
./cosign verify-manifest -key ${DISTROLESS_PUB_KEY} ./test/testdata/signed_manifest.yaml
if (./cosign verify-manifest -key ${DISTROLESS_PUB_KEY} ./test/testdata/unsigned_manifest.yaml); then false; fi

# Run the built container to make sure it doesn't crash
make ko-local
img="ko.local:$(git rev-parse HEAD)"
Expand Down
9 changes: 9 additions & 0 deletions test/testdata/signed_manifest.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
apiVersion: v1
kind: Pod
metadata:
name: single-pod
spec:
restartPolicy: Never
containers:
- name: distroless
image: gcr.io/distroless/base
9 changes: 9 additions & 0 deletions test/testdata/unsigned_manifest.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
apiVersion: v1
kind: Pod
metadata:
name: single-pod
spec:
restartPolicy: Never
containers:
- name: nginx-container
image: nginx