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

cmd: dockerfile resolve #1120

Closed
wants to merge 10 commits into from
42 changes: 42 additions & 0 deletions cmd/cosign/cli/dockerfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ func Dockerfile() *cobra.Command {

cmd.AddCommand(
dockerfileVerify(),
dockerfileResolve(),
)

return cmd
Expand Down Expand Up @@ -116,3 +117,44 @@ Shell-like variables in the Dockerfile's FROM lines will be substituted with val

return cmd
}

func dockerfileResolve() *cobra.Command {
o := &options.ResolveDockerfileOptions{}

cmd := &cobra.Command{
Use: "resolve",
Short: "Resolve the digest of the images and rewrites them with fully qualified image reference",
Long: `Resolve the digest of the images and rewrites them with fully qualified image reference

This command creates a surface which resolves mutable image tags into immutable image digests:
- FROM <tag>, it rewrites the Dockerfile to FROM <digest>

Using FROM without <digest> is dangerous because even if what's currently tagged on the registry is signed properly,
there is a race before the FROM is evaluated (what if it changes!), or (with docker build) it's possible that
what is in the local cache(!) is what's actually used, and not what was verified! (See issue #648)

This command does NOT do image verification; instead it only rewrites all image tags to corresponding digest(s).

The following image reference definitions are currently supported:
- FROM --platform=linux/amd64 gcr.io/distroless/base AS base
- COPY --from=gcr.io/distroless/base`,
Example: ` cosign dockerfile resolve Dockerfile

# print to stdout
cosign dockerfile resolve Dockerfile

# specify a output file
cosign dockerfile resolve -o Dockerfile.resolved Dockerfile`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
v := &dockerfile.ResolveDockerfileCommand{
Output: o.Output,
}
return v.Exec(cmd.Context(), args)
},
}

o.AddFlags(cmd)

return cmd
}
108 changes: 108 additions & 0 deletions cmd/cosign/cli/dockerfile/resolve.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// 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 dockerfile

import (
"bufio"
"bytes"
"context"
"flag"
"fmt"
"io"
"os"
"strings"

"github.com/google/go-containerregistry/pkg/name"
"github.com/sigstore/cosign/v2/pkg/oci/remote"

"github.com/pkg/errors"
)

// ResolveDockerfileCommand rewrites the Dockerfile
// base images to FROM <digest>.
type ResolveDockerfileCommand struct {
Output string
}

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

dockerfile, err := os.Open(args[0])
if err != nil {
return fmt.Errorf("could not open Dockerfile: %w", err)
}
defer dockerfile.Close()

resolvedDockerfile, err := resolveDigest(dockerfile)
if err != nil {
return fmt.Errorf("failed extracting images from Dockerfile: %w", err)
}

if c.Output != "" {
Dentrax marked this conversation as resolved.
Show resolved Hide resolved
if err := os.WriteFile(c.Output, resolvedDockerfile, 0600); err != nil {
return fmt.Errorf("failed writing resolved Dockerfile: %w", err)
}
} else {
fmt.Fprintln(os.Stdout, string(resolvedDockerfile))
}

return nil
}

func resolveDigest(dockerfile io.Reader) ([]byte, error) {
fileScanner := bufio.NewScanner(dockerfile)
tmp := bytes.NewBuffer([]byte{})

for fileScanner.Scan() {
line := strings.TrimSpace(fileScanner.Text())

if strings.HasPrefix(strings.ToUpper(line), "FROM") ||
strings.HasPrefix(strings.ToUpper(line), "COPY") {
switch image := getImageFromLine(line); image {
case "scratch":
tmp.WriteString(line)
default:
ref, err := name.ParseReference(image)
if err != nil {
// we should not return err here since
Dentrax marked this conversation as resolved.
Show resolved Hide resolved
// we can define the image refs smth like
// i.e., FROM alpine:$(TAG), FROM $(IMAGE), etc.
// TODO: support parameter substitution by passing a `--build-arg` flag
fmt.Fprintf(os.Stderr, "WARNING: parameter substitution for images is not supported yet. consider setting all environment variables before resolving the Dockerfile. Image: %s.\n", image)
tmp.WriteString(line)
tmp.WriteString("\n")
continue
}

d, err := remote.ResolveDigest(ref)
if err != nil {
return nil, errors.Wrap(err, "resolving digest")
}

// rewrite the image as follows:
// alpine:3.13 => index.docker.io/library/alpine@sha256:026f721af4cf2843e07bba648e158fb35ecc876d822130633cc49f707f0fc88c
tmp.WriteString(strings.ReplaceAll(line, image, d.String()))
}
} else {
tmp.WriteString(line)
}
tmp.WriteString("\n")
}

return tmp.Bytes(), nil
}
134 changes: 134 additions & 0 deletions cmd/cosign/cli/dockerfile/resolve_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// 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 dockerfile

import (
"bytes"
"reflect"
"testing"
)

func Test_resolveDigest(t *testing.T) {
tests := []struct {
name string
dockerfile string
want string
wantErr bool
}{
{
"happy alpine",
`FROM alpine:3.13`,
Dentrax marked this conversation as resolved.
Show resolved Hide resolved
`FROM index.docker.io/library/alpine@sha256:469b6e04ee185740477efa44ed5bdd64a07bbdd6c7e5f5d169e540889597b911
`,
false,
},
{
"happy alpine trim",
` FROM alpine:3.13 `,
`FROM index.docker.io/library/alpine@sha256:469b6e04ee185740477efa44ed5bdd64a07bbdd6c7e5f5d169e540889597b911
`,
false,
},
{
"happy alpine copy",
`FROM alpine:3.13
COPY --from=alpine:3.13
`,
`FROM index.docker.io/library/alpine@sha256:469b6e04ee185740477efa44ed5bdd64a07bbdd6c7e5f5d169e540889597b911
COPY --from=index.docker.io/library/alpine@sha256:469b6e04ee185740477efa44ed5bdd64a07bbdd6c7e5f5d169e540889597b911
`,
false,
},
{
"alpine with digest",
`FROM alpine@sha256:469b6e04ee185740477efa44ed5bdd64a07bbdd6c7e5f5d169e540889597b911`,
`FROM alpine@sha256:469b6e04ee185740477efa44ed5bdd64a07bbdd6c7e5f5d169e540889597b911
`,
false,
},
{
"multi-line",
`FROM alpine:3.13
COPY . .

RUN ls`,
`FROM index.docker.io/library/alpine@sha256:469b6e04ee185740477efa44ed5bdd64a07bbdd6c7e5f5d169e540889597b911
COPY . .

RUN ls
`,
false,
},
{
"skip scratch",
`FROM alpine:3.13
FROM scratch
RUN ls`,
`FROM index.docker.io/library/alpine@sha256:469b6e04ee185740477efa44ed5bdd64a07bbdd6c7e5f5d169e540889597b911
FROM scratch
RUN ls
`,
false,
},
{
"should not break invalid image ref",
`FROM alpine:$(TAG)
FROM $(IMAGE)
`,
`FROM alpine:$(TAG)
FROM $(IMAGE)
`,
false,
},
{
"should not break for invalid --from image reference",
`FROM golang:latest AS builder
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .

FROM alpine:latest
WORKDIR /root/
COPY --from=builder /go/src/github.com/foo/bar/app .
CMD ["./app"]`,
`FROM index.docker.io/library/golang@sha256:660f138b4477001d65324a51fa158c1b868651b44e43f0953bf062e9f38b72f3 AS builder
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .

FROM index.docker.io/library/alpine@sha256:8914eb54f968791faf6a8638949e480fef81e697984fba772b3976835194c6d4
WORKDIR /root/
COPY --from=builder /go/src/github.com/foo/bar/app .
CMD ["./app"]
`,
false,
},
{
"should not break for invalid --from image reference with digest",
`COPY --from=nginx:latest /etc/nginx/nginx.conf /nginx.conf`,
`COPY --from=index.docker.io/library/nginx@sha256:0047b729188a15da49380d9506d65959cce6d40291ccfb4e039f5dc7efd33286 /etc/nginx/nginx.conf /nginx.conf
`,
false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := resolveDigest(bytes.NewBuffer([]byte(tt.dockerfile)))
if (err != nil) != tt.wantErr {
t.Errorf("resolveDigest() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(string(got), tt.want) {
t.Errorf("resolveDigest() got = %v, want %v", string(got), tt.want)
}
})
}
}
55 changes: 52 additions & 3 deletions cmd/cosign/cli/dockerfile/verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,14 @@ import (
"github.com/sigstore/cosign/v2/cmd/cosign/cli/verify"
)

// VerifyCommand verifies a signature on a supplied container image
// VerifyDockerfileCommand verifies a signature on a supplied container image.
// nolint
type VerifyDockerfileCommand struct {
verify.VerifyCommand
BaseOnly bool
}

// Exec runs the verification command
// Exec runs the verification command.
func (c *VerifyDockerfileCommand) Exec(ctx context.Context, args []string) error {
if len(args) != 1 {
return flag.ErrHelp
Expand Down Expand Up @@ -66,8 +66,11 @@ func getImagesFromDockerfile(dockerfile io.Reader) ([]string, error) {
fileScanner := bufio.NewScanner(dockerfile)
for fileScanner.Scan() {
line := strings.TrimSpace(fileScanner.Text())
if strings.HasPrefix(strings.ToUpper(line), "FROM") {
if strings.HasPrefix(strings.ToUpper(line), "FROM") ||
strings.HasPrefix(strings.ToUpper(line), "COPY") {
switch image := getImageFromLine(line); image {
case "":
continue
case "scratch":
fmt.Fprintln(os.Stderr, "- scratch image ignored")
default:
Expand All @@ -82,6 +85,27 @@ func getImagesFromDockerfile(dockerfile io.Reader) ([]string, error) {
}

func getImageFromLine(line string) string {
if strings.HasPrefix(strings.ToUpper(line), "COPY") {
Dentrax marked this conversation as resolved.
Show resolved Hide resolved
// To support `COPY --from=image:latest /foo /bar` cases.
if strings.Contains(line, "--from") {
if img := getFromValue(line); img != "" {
return img
}
}
// If no value returned, it can be an environment variable or a stage name.
line = strings.TrimPrefix(line, "COPY") // Remove "COPY" prefix
line = strings.TrimSpace(line)
line = strings.TrimPrefix(line, "--from") // Remove "--from" prefix
foo := strings.Split(line, "=") // Get image ref after "="
if len(foo) != 2 {
return ""
}
// To support `COPY --from=stage /foo/bar` cases.
if strings.Contains(foo[1], " ") {
return ""
}
return os.ExpandEnv(foo[1]) // Substitute templated vars
}
line = strings.TrimPrefix(line, "FROM") // Remove "FROM" prefix
line = os.ExpandEnv(line) // Substitute templated vars
fields := strings.Fields(line)
Expand All @@ -94,3 +118,28 @@ func getImageFromLine(line string) string {
}
return fields[len(fields)-1] // The image should be the last portion of the line that remains
}

// getFromValue returns the value of the `--from=` directive in a Dockerfile.
// If the directive is not present or the value is not a valid image reference,
// an empty string is returned.
func getFromValue(input string) string {
fromKey := "--from="
fromIndex := strings.Index(input, fromKey)
if fromIndex == -1 {
return ""
}

valueStartIndex := fromIndex + len(fromKey)
valueEndIndex := strings.Index(input[valueStartIndex:], " ")
if valueEndIndex == -1 {
return input[valueStartIndex:]
}

value := input[valueStartIndex : valueStartIndex+valueEndIndex]
// In order to distinguish between `--from=my-custom-stage` and `--from=image:latest` cases,
// we check if the value contains a `:` character to determine if it's a stage or an image.
if strings.Contains(value, ":") {
return value
}
return ""
}
Loading