From ac1d556b92b84f00e66b9f59bbb35e4e6eea02eb Mon Sep 17 00:00:00 2001 From: Ethan Lowman Date: Fri, 5 May 2023 15:29:33 -0400 Subject: [PATCH] Add image verifier transfer service plugin system based on a binary directory Signed-off-by: Ethan Lowman --- cmd/containerd/builtins/builtins.go | 1 + docs/image-verification.md | 51 +++ pkg/imageverifier/bindir/bindir.go | 259 +++++++++++ pkg/imageverifier/bindir/bindir_test.go | 419 ++++++++++++++++++ pkg/imageverifier/bindir/processes_unix.go | 55 +++ pkg/imageverifier/bindir/processes_windows.go | 105 +++++ ...ifier_test_input_output_management.go.tmpl | 45 ++ .../testdata/verifiers/accept_reason_a.go | 23 + .../testdata/verifiers/accept_reason_b.go | 23 + .../testdata/verifiers/accept_reason_c.go | 23 + .../bindir/testdata/verifiers/large_stderr.go | 35 ++ .../verifiers/large_stderr_chunked.go | 45 ++ .../bindir/testdata/verifiers/large_stdout.go | 35 ++ .../verifiers/large_stdout_chunked.go | 45 ++ .../testdata/verifiers/reject_reason_d.go | 27 ++ .../testdata/verifiers/slow_child_process.go | 40 ++ pkg/imageverifier/image_verifier.go | 32 ++ pkg/transfer/local/pull.go | 27 ++ pkg/transfer/local/transfer.go | 20 +- plugin/plugin.go | 2 + plugins/imageverifier/path_unix.go | 21 + plugins/imageverifier/path_windows.go | 25 ++ plugins/imageverifier/plugin.go | 45 ++ plugins/transfer/plugin.go | 18 +- 24 files changed, 1412 insertions(+), 9 deletions(-) create mode 100644 docs/image-verification.md create mode 100644 pkg/imageverifier/bindir/bindir.go create mode 100644 pkg/imageverifier/bindir/bindir_test.go create mode 100644 pkg/imageverifier/bindir/processes_unix.go create mode 100644 pkg/imageverifier/bindir/processes_windows.go create mode 100644 pkg/imageverifier/bindir/testdata/verifier_templates/verifier_test_input_output_management.go.tmpl create mode 100644 pkg/imageverifier/bindir/testdata/verifiers/accept_reason_a.go create mode 100644 pkg/imageverifier/bindir/testdata/verifiers/accept_reason_b.go create mode 100644 pkg/imageverifier/bindir/testdata/verifiers/accept_reason_c.go create mode 100644 pkg/imageverifier/bindir/testdata/verifiers/large_stderr.go create mode 100644 pkg/imageverifier/bindir/testdata/verifiers/large_stderr_chunked.go create mode 100644 pkg/imageverifier/bindir/testdata/verifiers/large_stdout.go create mode 100644 pkg/imageverifier/bindir/testdata/verifiers/large_stdout_chunked.go create mode 100644 pkg/imageverifier/bindir/testdata/verifiers/reject_reason_d.go create mode 100644 pkg/imageverifier/bindir/testdata/verifiers/slow_child_process.go create mode 100644 pkg/imageverifier/image_verifier.go create mode 100644 plugins/imageverifier/path_unix.go create mode 100644 plugins/imageverifier/path_windows.go create mode 100644 plugins/imageverifier/plugin.go diff --git a/cmd/containerd/builtins/builtins.go b/cmd/containerd/builtins/builtins.go index 6ed9f3b0a..a5232abcc 100644 --- a/cmd/containerd/builtins/builtins.go +++ b/cmd/containerd/builtins/builtins.go @@ -24,6 +24,7 @@ import ( _ "github.com/containerd/containerd/leases/plugin" _ "github.com/containerd/containerd/metadata/plugin" _ "github.com/containerd/containerd/pkg/nri/plugin" + _ "github.com/containerd/containerd/plugins/imageverifier" _ "github.com/containerd/containerd/plugins/sandbox" _ "github.com/containerd/containerd/plugins/streaming" _ "github.com/containerd/containerd/plugins/transfer" diff --git a/docs/image-verification.md b/docs/image-verification.md new file mode 100644 index 000000000..fa6b5e63e --- /dev/null +++ b/docs/image-verification.md @@ -0,0 +1,51 @@ +# Image Verification + +The following covers the default "bindir" `ImageVerifier` plugin implementation. + +To enable image verification, add a stanza like the following to the containerd config: + +```yaml +[plugins] + [plugins."io.containerd.image-verifier.v1.bindir"] + bin_dir = "/opt/containerd/image-verifier/bin" + max_verifiers = 10 + per_verifier_timeout = "10s" +``` + +All files in `bin_dir`, if it exists, must be verifier executables which conform to the following API. + +## Image Verifier Binary API + +### CLI Arguments + +- `-name`: The given reference to the image that may be pulled. +- `-digest`: The resolved digest of the image that may be pulled. +- `-stdin-media-type`: The media type of the JSON data passed to stdin. + +### Standard Input + +A JSON encoded payload is passed to the verifier binary's standard input. The +media type of this payload is specified by the `-stdin-media-type` CLI +argument, and may change in future versions of containerd. Currently, the +payload has a media type of `application/vnd.oci.descriptor.v1+json` and +represents the OCI Content Descriptor of the image that may be pulled. See +[the OCI specification](https://github.com/opencontainers/image-spec/blob/main/descriptor.md) +for more details. + +### Image Pull Judgement + +Print to standard output a reason for the image pull judgement. + +Return an exit code of 0 to allow the image to be pulled and any other exit code to block the image from being pulled. + +## Image Verifier Caller Contract + +- If `bin_dir` does not exist or contains no files, the image verifier does not block image pulls. +- An image is pulled only if all verifiers that are called return an "ok" judgement (exit with status code 0). In other words, image pull judgements are combined with an `AND` operator. +- If any verifiers exceeds the `per_verifier_timeout` or fails to exec, the verification fails with an error and a `nil` judgement is returned. +- If `max_verifiers < 0`, there is no imposed limit on the number of image verifiers called. +- If `max_verifiers >= 0`, there is a limit imposed on the number of image verifiers called. The entries in `bin_dir` are lexicographically sorted by name, and the first `n = max_verifiers` of the verifiers will be called, and the rest will be skipped. +- There is no guarantee for the order of execution of verifier binaries. +- Standard error output of verifier binaries is logged at debug level by containerd, subject to truncation. +- Standard output of verifier binaries (the "reason" for the judgement) is subject to truncation. +- System resources used by verifier binaries are currently accounted for in and constrained by containerd's own cgroup, but this is subject to change. diff --git a/pkg/imageverifier/bindir/bindir.go b/pkg/imageverifier/bindir/bindir.go new file mode 100644 index 000000000..3bf464591 --- /dev/null +++ b/pkg/imageverifier/bindir/bindir.go @@ -0,0 +1,259 @@ +/* + Copyright The containerd 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 bindir + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/containerd/containerd/log" + "github.com/containerd/containerd/pkg/imageverifier" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/sirupsen/logrus" +) + +const outputLimitBytes = 1 << 15 // 32 KiB + +type Config struct { + BinDir string `toml:"bin_dir"` + MaxVerifiers int `toml:"max_verifiers"` + PerVerifierTimeout time.Duration `toml:"per_verifier_timeout"` +} + +type ImageVerifier struct { + config *Config +} + +var _ imageverifier.ImageVerifier = (*ImageVerifier)(nil) + +func NewImageVerifier(c *Config) *ImageVerifier { + return &ImageVerifier{ + config: c, + } +} + +func (v *ImageVerifier) VerifyImage(ctx context.Context, name string, desc ocispec.Descriptor) (*imageverifier.Judgement, error) { + // os.ReadDir sorts entries by name. + entries, err := os.ReadDir(v.config.BinDir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return &imageverifier.Judgement{ + OK: true, + Reason: fmt.Sprintf("image verifier directory %v does not exist", v.config.BinDir), + }, nil + } + + return nil, fmt.Errorf("failed to list directory contents: %w", err) + } + + if len(entries) == 0 { + return &imageverifier.Judgement{ + OK: true, + Reason: fmt.Sprintf("no image verifier binaries found in %v", v.config.BinDir), + }, nil + } + + reason := &strings.Builder{} + for i, entry := range entries { + if (i+1) > v.config.MaxVerifiers && v.config.MaxVerifiers >= 0 { + log.G(ctx).Warnf("image verifiers are being skipped since directory %v has %v entries, more than configured max of %v verifiers", v.config.BinDir, len(entries), v.config.MaxVerifiers) + break + } + + bin := entry.Name() + start := time.Now() + exitCode, vr, err := v.runVerifier(ctx, bin, name, desc) + runtime := time.Since(start) + if err != nil { + return nil, fmt.Errorf("failed to call verifier %v (runtime %v): %w", bin, runtime, err) + } + + if exitCode != 0 { + return &imageverifier.Judgement{ + OK: false, + Reason: fmt.Sprintf("verifier %v rejected image (exit code %v): %v", bin, exitCode, vr), + }, nil + } + + if i > 0 { + reason.WriteString(", ") + } + reason.WriteString(fmt.Sprintf("%v => %v", bin, vr)) + } + + return &imageverifier.Judgement{ + OK: true, + Reason: reason.String(), + }, nil +} + +func (v *ImageVerifier) runVerifier(ctx context.Context, bin string, imageName string, desc ocispec.Descriptor) (exitCode int, reason string, err error) { + ctx, cancel := context.WithTimeout(ctx, v.config.PerVerifierTimeout) + defer cancel() + + binPath := filepath.Join(v.config.BinDir, bin) + args := []string{ + "-name", imageName, + "-digest", desc.Digest.String(), + "-stdin-media-type", ocispec.MediaTypeDescriptor, + } + + cmd := exec.CommandContext(ctx, binPath, args...) + + // We construct our own pipes instead of using the default StdinPipe, + // StoutPipe, and StderrPipe in order to set timeouts on reads and writes. + stdinRead, stdinWrite, err := os.Pipe() + if err != nil { + return -1, "", err + } + cmd.Stdin = stdinRead + defer stdinRead.Close() + defer stdinWrite.Close() + + stdoutRead, stdoutWrite, err := os.Pipe() + if err != nil { + return -1, "", err + } + cmd.Stdout = stdoutWrite + defer stdoutRead.Close() + defer stdoutWrite.Close() + + stderrRead, stderrWrite, err := os.Pipe() + if err != nil { + return -1, "", err + } + cmd.Stderr = stderrWrite + defer stderrRead.Close() + defer stderrWrite.Close() + + // Close parent ends of pipes on timeout. Without this, I/O may hang in the + // parent process. + if d, ok := ctx.Deadline(); ok { + stdinWrite.SetDeadline(d) + stdoutRead.SetDeadline(d) + stderrRead.SetDeadline(d) + } + + // Finish configuring, and then fork & exec the child process. + p, err := startProcess(ctx, cmd) + if err != nil { + return -1, "", err + } + defer p.cleanup(ctx) + + // Close the child ends of the pipes in the parent process. + stdinRead.Close() + stdoutWrite.Close() + stderrWrite.Close() + + // Write the descriptor to stdin. + go func() { + // Descriptors are usually small enough to fit in a pipe buffer (which is + // often 64 KiB on Linux) so this write usually won't block on the child + // process reading stdin. However, synchronously writing to stdin may cause + // the parent to block if the descriptor is larger than the pipe buffer and + // the child process doesn't read stdin. Therefore, we write to stdin + // asynchronously, limited by the stdinWrite deadline set above. + err := json.NewEncoder(stdinWrite).Encode(desc) + if err != nil { + // This may error out with a "broken pipe" error if the descriptor is + // larger than the pipe buffer and the child process does not read all + // of stdin. + log.G(ctx).WithError(err).Warn("failed to completely write descriptor to stdin") + } + stdinWrite.Close() + }() + + // Pipe verifier stderr lines to debug logs. + stderrLog := log.G(ctx).Logger.WithFields(logrus.Fields{ + "image_verifier": bin, + "stream": "stderr", + }) + stderrLogDone := make(chan struct{}) + go func() { + defer close(stderrLogDone) + defer stderrRead.Close() + lr := &io.LimitedReader{ + R: stderrRead, + N: outputLimitBytes, + } + + s := bufio.NewScanner(lr) + for s.Scan() { + stderrLog.Debug(s.Text()) + } + if err := s.Err(); err != nil { + stderrLog.WithError(err).Debug("error logging image verifier stderr") + } + + if lr.N == 0 { + // Peek ahead to see if stderr reader was truncated. + b := make([]byte, 1) + if n, _ := stderrRead.Read(b); n > 0 { + stderrLog.Debug("(previous logs may be truncated)") + } + } + + // Discard the truncated part of stderr. Doing this rather than closing the + // reader avoids broken pipe errors. This is bounded by the stderrRead + // deadline. + if _, err := io.Copy(io.Discard, stderrRead); err != nil { + log.G(ctx).WithError(err).Error("error flushing stderr") + } + }() + + stdout, err := io.ReadAll(io.LimitReader(stdoutRead, outputLimitBytes)) + if err != nil { + log.G(ctx).WithError(err).Error("error reading stdout") + } else { + m := strings.Builder{} + m.WriteString(strings.TrimSpace(string(stdout))) + // Peek ahead to see if stdout is truncated. + b := make([]byte, 1) + if n, _ := stdoutRead.Read(b); n > 0 { + m.WriteString("(stdout truncated)") + } + reason = m.String() + } + + // Discard the truncated part of stdout. Doing this rather than closing the + // reader avoids broken pipe errors. This is bounded by the stdoutRead + // deadline. + if _, err := io.Copy(io.Discard, stdoutRead); err != nil { + log.G(ctx).WithError(err).Error("error flushing stdout") + } + stdoutRead.Close() + + <-stderrLogDone + if err := cmd.Wait(); err != nil { + if ee := (&exec.ExitError{}); errors.As(err, &ee) && ee.ProcessState.Exited() { + return ee.ProcessState.ExitCode(), reason, nil + } + return -1, "", fmt.Errorf("waiting on command to exit: %v", err) + } + + return cmd.ProcessState.ExitCode(), reason, nil +} diff --git a/pkg/imageverifier/bindir/bindir_test.go b/pkg/imageverifier/bindir/bindir_test.go new file mode 100644 index 000000000..7ea12ecbb --- /dev/null +++ b/pkg/imageverifier/bindir/bindir_test.go @@ -0,0 +1,419 @@ +/* + Copyright The containerd 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 bindir + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "text/template" + "time" + + "github.com/containerd/containerd/log" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// buildGoVerifiers uses the local Go toolchain to build each of the standalone +// main package source files in srcDir into binaries placed in binDir. +func buildGoVerifiers(t *testing.T, srcsDir string, binDir string) { + srcs, err := os.ReadDir(srcsDir) + require.NoError(t, err) + + for _, srcFile := range srcs { + // Build the source into a Go binary. + src := filepath.Join(srcsDir, srcFile.Name()) + bin := filepath.Join(binDir, strings.Split(srcFile.Name(), ".")[0]+exeIfWindows()) + cmd := exec.Command("go", "build", "-o", bin, src) + + code, err := os.ReadFile(src) + require.NoError(t, err) + + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("failed to build test verifier %s: %v\n%s\nGo code:\n%s", src, err, out, code) + } + } +} + +func exeIfWindows() string { + // The command `go build -o abc abc.go` creates abc.exe on Windows. + if runtime.GOOS == "windows" { + return ".exe" + } + return "" +} + +// newBinDir creates a temporary directory and copies each of the selected bins +// fromSrcDir into that directory. The copied verifier binaries are given names +// such that they sort (and therefore execute) in the order that bins is given. +func newBinDir(t *testing.T, srcDir string, bins ...string) string { + binDir := t.TempDir() + + for i, bin := range bins { + src, err := os.Open(filepath.Join(srcDir, bin+exeIfWindows())) + require.NoError(t, err) + defer src.Close() + + dst, err := os.OpenFile(filepath.Join(binDir, fmt.Sprintf("verifier-%v%v", i, exeIfWindows())), os.O_WRONLY|os.O_CREATE, 0755) + require.NoError(t, err) + defer dst.Close() + + _, err = io.Copy(dst, src) + require.NoError(t, err) + } + + return binDir +} + +func TestBinDirVerifyImage(t *testing.T) { + // Enable debug logs to easily see stderr for verifiers upon test failure. + logger := log.L.Dup() + logger.Logger.SetLevel(logrus.DebugLevel) + ctx := log.WithLogger(context.Background(), logger) + + // Build verifiers from plain Go file. + allBinsDir := t.TempDir() + buildGoVerifiers(t, "testdata/verifiers", allBinsDir) + + // Build verifiers from templates. + data := struct { + ArgsFile string + StdinFile string + }{ + ArgsFile: filepath.Join(t.TempDir(), "args.txt"), + StdinFile: filepath.Join(t.TempDir(), "stdin.txt"), + } + + tmplDir := "testdata/verifier_templates" + templates, err := os.ReadDir(tmplDir) + require.NoError(t, err) + + renderedVerifierTmplDir := t.TempDir() + for _, tmplFile := range templates { + tmplPath := filepath.Join(tmplDir, tmplFile.Name()) + + tmpl, err := template.New(tmplFile.Name()).ParseFiles(tmplPath) + require.NoError(t, err) + + goFileName := strings.ReplaceAll(tmplFile.Name(), ".go.tmpl", ".go") + f, err := os.Create(filepath.Join(renderedVerifierTmplDir, goFileName)) + require.NoError(t, err) + defer f.Close() + + require.NoError(t, tmpl.Execute(f, data)) + f.Close() + } + buildGoVerifiers(t, renderedVerifierTmplDir, allBinsDir) + + // Actual tests begin here. + t.Run("proper input/output management", func(t *testing.T) { + binDir := newBinDir(t, allBinsDir, + "verifier_test_input_output_management", + ) + + v := NewImageVerifier(&Config{ + BinDir: binDir, + MaxVerifiers: -1, + PerVerifierTimeout: 5 * time.Second, + }) + + j, err := v.VerifyImage(ctx, "registry.example.com/image:abc", ocispec.Descriptor{ + Digest: "sha256:98ea6e4f216f2fb4b69fff9b3a44842c38686ca685f3f55dc48c5d3fb1107be4", + MediaType: "application/vnd.docker.distribution.manifest.list.v2+json", + Size: 2048, + Annotations: map[string]string{"a": "b"}, + }) + assert.NoError(t, err) + assert.True(t, j.OK) + assert.Equal(t, fmt.Sprintf("verifier-0%[1]v => Reason A line 1\nReason A line 2", exeIfWindows()), j.Reason) + + b, err := os.ReadFile(data.ArgsFile) + require.NoError(t, err) + assert.Equal(t, "-name registry.example.com/image:abc -digest sha256:98ea6e4f216f2fb4b69fff9b3a44842c38686ca685f3f55dc48c5d3fb1107be4 -stdin-media-type application/vnd.oci.descriptor.v1+json", string(b)) + + b, err = os.ReadFile(data.StdinFile) + require.NoError(t, err) + assert.Equal(t, `{"mediaType":"application/vnd.docker.distribution.manifest.list.v2+json","digest":"sha256:98ea6e4f216f2fb4b69fff9b3a44842c38686ca685f3f55dc48c5d3fb1107be4","size":2048,"annotations":{"a":"b"}}`, strings.TrimSpace(string(b))) + }) + + t.Run("large output is truncated", func(t *testing.T) { + bins := []string{ + "large_stdout", + "large_stdout_chunked", + "large_stderr", + "large_stderr_chunked", + } + binDir := newBinDir(t, allBinsDir, bins...) + + v := NewImageVerifier(&Config{ + BinDir: binDir, + MaxVerifiers: -1, + PerVerifierTimeout: 30 * time.Second, + }) + + j, err := v.VerifyImage(ctx, "registry.example.com/image:abc", ocispec.Descriptor{}) + assert.NoError(t, err) + assert.True(t, j.OK, "expected OK, got not OK with reason: %v", j.Reason) + assert.Less(t, len(j.Reason), len(bins)*(outputLimitBytes+1024), "reason is: %v", j.Reason) // 1024 leaves margin for the formatting around the reason. + }) + + t.Run("missing directory", func(t *testing.T) { + v := NewImageVerifier(&Config{ + BinDir: filepath.Join(t.TempDir(), "missing_directory"), + MaxVerifiers: 10, + PerVerifierTimeout: 5 * time.Second, + }) + + j, err := v.VerifyImage(ctx, "registry.example.com/image:abc", ocispec.Descriptor{}) + assert.NoError(t, err) + assert.True(t, j.OK) + assert.NotEmpty(t, j.Reason) + }) + + t.Run("empty directory", func(t *testing.T) { + v := NewImageVerifier(&Config{ + BinDir: t.TempDir(), + MaxVerifiers: 10, + PerVerifierTimeout: 5 * time.Second, + }) + + j, err := v.VerifyImage(ctx, "registry.example.com/image:abc", ocispec.Descriptor{}) + assert.NoError(t, err) + assert.True(t, j.OK) + assert.NotEmpty(t, j.Reason) + }) + + t.Run("max verifiers = 0", func(t *testing.T) { + binDir := newBinDir(t, allBinsDir, + "reject_reason_d", // This never runs. + ) + + v := NewImageVerifier(&Config{ + BinDir: binDir, + MaxVerifiers: 0, + PerVerifierTimeout: 5 * time.Second, + }) + + j, err := v.VerifyImage(ctx, "registry.example.com/image:abc", ocispec.Descriptor{}) + assert.NoError(t, err) + assert.True(t, j.OK) + assert.Empty(t, j.Reason) + }) + + t.Run("max verifiers = 1", func(t *testing.T) { + binDir := newBinDir(t, allBinsDir, + "accept_reason_a", + "reject_reason_d", // This never runs. + ) + + v := NewImageVerifier(&Config{ + BinDir: binDir, + MaxVerifiers: 1, + PerVerifierTimeout: 5 * time.Second, + }) + + j, err := v.VerifyImage(ctx, "registry.example.com/image:abc", ocispec.Descriptor{}) + assert.NoError(t, err) + assert.True(t, j.OK) + assert.NotEmpty(t, j.Reason) + }) + + t.Run("max verifiers = 2", func(t *testing.T) { + binDir := newBinDir(t, allBinsDir, + "accept_reason_a", + "accept_reason_a", + "reject_reason_d", // This never runs. + ) + + v := NewImageVerifier(&Config{ + BinDir: binDir, + MaxVerifiers: 2, + PerVerifierTimeout: 5 * time.Second, + }) + + j, err := v.VerifyImage(ctx, "registry.example.com/image:abc", ocispec.Descriptor{}) + assert.NoError(t, err) + assert.True(t, j.OK) + assert.NotEmpty(t, j.Reason) + }) + + t.Run("max verifiers = 3, all accept", func(t *testing.T) { + binDir := newBinDir(t, allBinsDir, + "accept_reason_a", + "accept_reason_b", + "accept_reason_c", + ) + v := NewImageVerifier(&Config{ + BinDir: binDir, + MaxVerifiers: 3, + PerVerifierTimeout: 5 * time.Second, + }) + + j, err := v.VerifyImage(ctx, "registry.example.com/image:abc", ocispec.Descriptor{}) + assert.NoError(t, err) + assert.True(t, j.OK) + assert.Equal(t, fmt.Sprintf("verifier-0%[1]v => Reason A, verifier-1%[1]v => Reason B, verifier-2%[1]v => Reason C", exeIfWindows()), j.Reason) + }) + + t.Run("max verifiers = 3, with reject", func(t *testing.T) { + binDir := newBinDir(t, allBinsDir, + "accept_reason_a", + "accept_reason_b", + "reject_reason_d", + ) + + v := NewImageVerifier(&Config{ + BinDir: binDir, + MaxVerifiers: 3, + PerVerifierTimeout: 5 * time.Second, + }) + + j, err := v.VerifyImage(ctx, "registry.example.com/image:abc", ocispec.Descriptor{}) + assert.NoError(t, err) + assert.False(t, j.OK) + assert.Equal(t, fmt.Sprintf("verifier verifier-2%[1]v rejected image (exit code 1): Reason D", exeIfWindows()), j.Reason) + }) + + t.Run("max verifiers = -1, all accept", func(t *testing.T) { + binDir := newBinDir(t, allBinsDir, + "accept_reason_a", + "accept_reason_b", + "accept_reason_c", + ) + + v := NewImageVerifier(&Config{ + BinDir: binDir, + MaxVerifiers: -1, + PerVerifierTimeout: 5 * time.Second, + }) + + j, err := v.VerifyImage(ctx, "registry.example.com/image:abc", ocispec.Descriptor{}) + assert.NoError(t, err) + assert.True(t, j.OK) + assert.Equal(t, fmt.Sprintf("verifier-0%[1]v => Reason A, verifier-1%[1]v => Reason B, verifier-2%[1]v => Reason C", exeIfWindows()), j.Reason) + }) + + t.Run("max verifiers = -1, with reject", func(t *testing.T) { + binDir := newBinDir(t, allBinsDir, + "accept_reason_a", + "accept_reason_b", + "reject_reason_d", + ) + + v := NewImageVerifier(&Config{ + BinDir: binDir, + MaxVerifiers: -1, + PerVerifierTimeout: 5 * time.Second, + }) + + j, err := v.VerifyImage(ctx, "registry.example.com/image:abc", ocispec.Descriptor{}) + assert.NoError(t, err) + assert.False(t, j.OK) + assert.Equal(t, fmt.Sprintf("verifier verifier-2%[1]v rejected image (exit code 1): Reason D", exeIfWindows()), j.Reason) + }) + + t.Run("max verifiers = -1, with timeout", func(t *testing.T) { + binDir := newBinDir(t, allBinsDir, + "accept_reason_a", + "accept_reason_b", + "slow_child_process", + ) + + v := NewImageVerifier(&Config{ + BinDir: binDir, + MaxVerifiers: -1, + PerVerifierTimeout: 5 * time.Second, + }) + + j, err := v.VerifyImage(ctx, "registry.example.com/image:abc", ocispec.Descriptor{}) + if runtime.GOOS == "windows" { + assert.NoError(t, err) + assert.False(t, j.OK) + assert.Equal(t, "verifier verifier-2.exe rejected image (exit code 1): ", j.Reason) + } else { + assert.ErrorContains(t, err, "signal: killed") + assert.Nil(t, j) + } + + command := []string{"ps", "ax"} + if runtime.GOOS == "windows" { + command = []string{"tasklist"} + } + b, err := exec.Command(command[0], command[1:]...).CombinedOutput() + if err != nil { + t.Fatal(err) + } + + if strings.Contains(string(b), "verifier-") { + t.Fatalf("killing the verifier binary didn't kill all its children:\n%v", string(b)) + } + }) + + t.Run("max verifiers = -1, with exec failure", func(t *testing.T) { + binDir := t.TempDir() + err := os.WriteFile(filepath.Join(binDir, "bad.sh"), []byte("BAD"), 0744) + require.NoError(t, err) + + v := NewImageVerifier(&Config{ + BinDir: binDir, + MaxVerifiers: -1, + PerVerifierTimeout: 5 * time.Second, + }) + + j, err := v.VerifyImage(ctx, "registry.example.com/image:abc", ocispec.Descriptor{}) + assert.Error(t, err) + assert.Nil(t, j) + }) + + t.Run("descriptor larger than linux pipe buffer, verifier doesn't read stdin", func(t *testing.T) { + binDir := newBinDir(t, allBinsDir, + "accept_reason_a", + ) + + v := NewImageVerifier(&Config{ + BinDir: binDir, + MaxVerifiers: 1, + PerVerifierTimeout: 5 * time.Second, + }) + + j, err := v.VerifyImage(ctx, "registry.example.com/image:abc", ocispec.Descriptor{ + Digest: "sha256:98ea6e4f216f2fb4b69fff9b3a44842c38686ca685f3f55dc48c5d3fb1107be4", + MediaType: "application/vnd.docker.distribution.manifest.list.v2+json", + Size: 2048, + Annotations: map[string]string{ + // Pipe buffer is usually 64KiB. + "large_payload": strings.Repeat("0", 2*64*(1<<10)), + }, + }) + + // Should see a log like the following, but verification still succeeds: + // time="2023-09-05T11:15:50-04:00" level=warning msg="failed to completely write descriptor to stdin" error="write |1: broken pipe" + + assert.NoError(t, err) + assert.True(t, j.OK) + assert.NotEmpty(t, j.Reason) + }) +} diff --git a/pkg/imageverifier/bindir/processes_unix.go b/pkg/imageverifier/bindir/processes_unix.go new file mode 100644 index 000000000..94419f0c1 --- /dev/null +++ b/pkg/imageverifier/bindir/processes_unix.go @@ -0,0 +1,55 @@ +//go:build !windows + +/* + Copyright The containerd 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 bindir + +import ( + "context" + "fmt" + "os/exec" + + "golang.org/x/sys/unix" +) + +type process struct { + cmd *exec.Cmd +} + +// Configure the verifier command so that killing it kills all child +// processes of the verifier process. +func startProcess(ctx context.Context, cmd *exec.Cmd) (*process, error) { + // Assign the verifier a new process group so that killing its process group + // in Cancel() doesn't kill the parent process (containerd). + cmd.SysProcAttr = &unix.SysProcAttr{Setpgid: true} + + cmd.Cancel = func() error { + // Passing a negative PID causes kill(2) to kill all processes in the + // process group whose ID is cmd.Process.Pid. + return unix.Kill(-cmd.Process.Pid, unix.SIGKILL) + } + + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("starting process: %w", err) + } + + return &process{ + cmd: cmd, + }, nil +} + +func (p *process) cleanup(ctx context.Context) {} diff --git a/pkg/imageverifier/bindir/processes_windows.go b/pkg/imageverifier/bindir/processes_windows.go new file mode 100644 index 000000000..64bc4b51c --- /dev/null +++ b/pkg/imageverifier/bindir/processes_windows.go @@ -0,0 +1,105 @@ +//go:build windows + +/* + Copyright The containerd 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 bindir + +import ( + "context" + "fmt" + "os/exec" + "unsafe" + + "github.com/containerd/containerd/log" + "golang.org/x/sys/windows" +) + +type process struct { + cmd *exec.Cmd + + jobHandle *windows.Handle + processHandle *windows.Handle +} + +// Configure the verifier command so that killing it kills all child +// processes of the verifier process. +// +// Job/process management based on: +// https://devblogs.microsoft.com/oldnewthing/20131209-00/?p=2433 +func startProcess(ctx context.Context, cmd *exec.Cmd) (*process, error) { + p := &process{ + cmd: cmd, + } + + jobHandle, err := windows.CreateJobObject(nil, nil) + if err != nil { + return nil, fmt.Errorf("creating job object: %w", err) + } + p.jobHandle = &jobHandle + + info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{ + BasicLimitInformation: windows.JOBOBJECT_BASIC_LIMIT_INFORMATION{ + LimitFlags: windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + }, + } + _, err = windows.SetInformationJobObject( + jobHandle, + windows.JobObjectExtendedLimitInformation, + uintptr(unsafe.Pointer(&info)), + uint32(unsafe.Sizeof(info)), + ) + if err != nil { + p.cleanup(ctx) + return nil, fmt.Errorf("setting limits for job object: %w", err) + } + + if err := cmd.Start(); err != nil { + p.cleanup(ctx) + return nil, fmt.Errorf("starting process: %w", err) + } + + processHandle, err := windows.OpenProcess( + windows.PROCESS_QUERY_INFORMATION|windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, + false, + uint32(cmd.Process.Pid), + ) + if err != nil { + return nil, fmt.Errorf("getting handle for verifier process: %w", err) + } + p.processHandle = &processHandle + + err = windows.AssignProcessToJobObject(jobHandle, processHandle) + if err != nil { + p.cleanup(ctx) + return nil, fmt.Errorf("associating new process to job object: %w", err) + } + + return p, nil +} + +func (p *process) cleanup(ctx context.Context) { + if p.jobHandle != nil { + if err := windows.CloseHandle(*p.jobHandle); err != nil { + log.G(ctx).WithError(err).Error("failed to close job handle") + } + } + if p.processHandle != nil { + if err := windows.CloseHandle(*p.processHandle); err != nil { + log.G(ctx).WithError(err).Error("failed to close process handle") + } + } +} diff --git a/pkg/imageverifier/bindir/testdata/verifier_templates/verifier_test_input_output_management.go.tmpl b/pkg/imageverifier/bindir/testdata/verifier_templates/verifier_test_input_output_management.go.tmpl new file mode 100644 index 000000000..5d3df40ce --- /dev/null +++ b/pkg/imageverifier/bindir/testdata/verifier_templates/verifier_test_input_output_management.go.tmpl @@ -0,0 +1,45 @@ +/* + Copyright The containerd 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" + "os" + "strings" +) + +func main() { + err := os.WriteFile(`{{.ArgsFile}}`, []byte(strings.Join(os.Args[1:], " ")), 0644) + if err != nil { + panic(err) + } + + stdin, err := io.ReadAll(os.Stdin) + if err != nil { + panic(err) + } + err = os.WriteFile(`{{.StdinFile}}`, stdin, 0644) + if err != nil { + panic(err) + } + + fmt.Println("Reason A line 1") + fmt.Fprintln(os.Stderr, "Debug A line 1") + fmt.Println("Reason A line 2") + fmt.Fprintln(os.Stderr, "Debug A line 2") +} diff --git a/pkg/imageverifier/bindir/testdata/verifiers/accept_reason_a.go b/pkg/imageverifier/bindir/testdata/verifiers/accept_reason_a.go new file mode 100644 index 000000000..ceeda341e --- /dev/null +++ b/pkg/imageverifier/bindir/testdata/verifiers/accept_reason_a.go @@ -0,0 +1,23 @@ +/* + Copyright The containerd 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" + +func main() { + fmt.Println("Reason A") +} diff --git a/pkg/imageverifier/bindir/testdata/verifiers/accept_reason_b.go b/pkg/imageverifier/bindir/testdata/verifiers/accept_reason_b.go new file mode 100644 index 000000000..f41c8c889 --- /dev/null +++ b/pkg/imageverifier/bindir/testdata/verifiers/accept_reason_b.go @@ -0,0 +1,23 @@ +/* + Copyright The containerd 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" + +func main() { + fmt.Println("Reason B") +} diff --git a/pkg/imageverifier/bindir/testdata/verifiers/accept_reason_c.go b/pkg/imageverifier/bindir/testdata/verifiers/accept_reason_c.go new file mode 100644 index 000000000..ddc90ce16 --- /dev/null +++ b/pkg/imageverifier/bindir/testdata/verifiers/accept_reason_c.go @@ -0,0 +1,23 @@ +/* + Copyright The containerd 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" + +func main() { + fmt.Println("Reason C") +} diff --git a/pkg/imageverifier/bindir/testdata/verifiers/large_stderr.go b/pkg/imageverifier/bindir/testdata/verifiers/large_stderr.go new file mode 100644 index 000000000..a8d37d6f2 --- /dev/null +++ b/pkg/imageverifier/bindir/testdata/verifiers/large_stderr.go @@ -0,0 +1,35 @@ +/* + Copyright The containerd 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" + "os" + "strings" +) + +func main() { + n := 50000 + fmt.Fprintf(os.Stderr, "attempting to write %v bytes to stderr\n", n) + + wrote, err := fmt.Fprintf(os.Stderr, strings.Repeat("A", n)) + if err != nil { + fmt.Fprintf(os.Stderr, "got error writing to stderr: %v\n", err) + } + + fmt.Fprintf(os.Stderr, "wrote %v bytes to stderr\n", wrote) +} diff --git a/pkg/imageverifier/bindir/testdata/verifiers/large_stderr_chunked.go b/pkg/imageverifier/bindir/testdata/verifiers/large_stderr_chunked.go new file mode 100644 index 000000000..6e0966013 --- /dev/null +++ b/pkg/imageverifier/bindir/testdata/verifiers/large_stderr_chunked.go @@ -0,0 +1,45 @@ +/* + Copyright The containerd 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" + "os" +) + +func main() { + n := 500000 + fmt.Fprintf(os.Stderr, "attempting to write %v bytes to stderr\n", n) + + // Writing this all in one fmt.Fprintf has a different interaction with + // stderr pipe buffering than writing one byte at a time. + wrote := 0 + for i := 0; i < n; i++ { + w, err := fmt.Fprintf(os.Stderr, "A") + if err != nil { + fmt.Fprintf(os.Stderr, "got error writing to stderr: %v\n", err) + } + + wrote += w + + if i%10000 == 0 { + fmt.Fprintf(os.Stderr, "progress: wrote %v bytes to stderr\n", wrote) + } + } + + fmt.Fprintf(os.Stderr, "wrote %v bytes to stderr\n", wrote) +} diff --git a/pkg/imageverifier/bindir/testdata/verifiers/large_stdout.go b/pkg/imageverifier/bindir/testdata/verifiers/large_stdout.go new file mode 100644 index 000000000..fd3ad37bb --- /dev/null +++ b/pkg/imageverifier/bindir/testdata/verifiers/large_stdout.go @@ -0,0 +1,35 @@ +/* + Copyright The containerd 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" + "os" + "strings" +) + +func main() { + n := 50000 + fmt.Fprintf(os.Stderr, "attempting to write %v bytes to stdout\n", n) + + wrote, err := fmt.Print(strings.Repeat("A", n)) + if err != nil { + fmt.Fprintf(os.Stderr, "got error writing to stdout: %v\n", err) + } + + fmt.Fprintf(os.Stderr, "wrote %v bytes to stdout\n", wrote) +} diff --git a/pkg/imageverifier/bindir/testdata/verifiers/large_stdout_chunked.go b/pkg/imageverifier/bindir/testdata/verifiers/large_stdout_chunked.go new file mode 100644 index 000000000..8d47a4417 --- /dev/null +++ b/pkg/imageverifier/bindir/testdata/verifiers/large_stdout_chunked.go @@ -0,0 +1,45 @@ +/* + Copyright The containerd 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" + "os" +) + +func main() { + n := 500000 + fmt.Fprintf(os.Stderr, "attempting to write %v bytes to stdout\n", n) + + // Writing this all in one fmt.Print has a different interaction with stdout + // pipe buffering than writing one byte at a time. + wrote := 0 + for i := 0; i < n; i++ { + w, err := fmt.Print("A") + if err != nil { + fmt.Fprintf(os.Stderr, "got error writing to stdout: %v\n", err) + } + + wrote += w + + if i%10000 == 0 { + fmt.Fprintf(os.Stderr, "progress: wrote %v bytes to stdout\n", wrote) + } + } + + fmt.Fprintf(os.Stderr, "wrote %v bytes to stdout\n", wrote) +} diff --git a/pkg/imageverifier/bindir/testdata/verifiers/reject_reason_d.go b/pkg/imageverifier/bindir/testdata/verifiers/reject_reason_d.go new file mode 100644 index 000000000..68878b5b3 --- /dev/null +++ b/pkg/imageverifier/bindir/testdata/verifiers/reject_reason_d.go @@ -0,0 +1,27 @@ +/* + Copyright The containerd 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" + "os" +) + +func main() { + fmt.Println("Reason D") + os.Exit(1) +} diff --git a/pkg/imageverifier/bindir/testdata/verifiers/slow_child_process.go b/pkg/imageverifier/bindir/testdata/verifiers/slow_child_process.go new file mode 100644 index 000000000..d53138879 --- /dev/null +++ b/pkg/imageverifier/bindir/testdata/verifiers/slow_child_process.go @@ -0,0 +1,40 @@ +/* + Copyright The containerd 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" + "os" + "os/exec" +) + +func main() { + // Launch a slow child process by re-executing this binary with the -sleep-forever flag. + if len(os.Args) == 2 && os.Args[1] == "-sleep-forever" { + fmt.Println("sleeping forever...") + for { + } + } + + thisBin := os.Args[0] + cmd := exec.Command(thisBin, "-sleep-forever") + b, err := cmd.CombinedOutput() + fmt.Println(string(b)) + if err != nil { + panic(err) + } +} diff --git a/pkg/imageverifier/image_verifier.go b/pkg/imageverifier/image_verifier.go new file mode 100644 index 000000000..ac437b5ee --- /dev/null +++ b/pkg/imageverifier/image_verifier.go @@ -0,0 +1,32 @@ +/* + Copyright The containerd 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 imageverifier + +import ( + "context" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" +) + +type ImageVerifier interface { + VerifyImage(ctx context.Context, name string, desc ocispec.Descriptor) (*Judgement, error) +} + +type Judgement struct { + OK bool + Reason string +} diff --git a/pkg/transfer/local/pull.go b/pkg/transfer/local/pull.go index 204d33434..9f30a68eb 100644 --- a/pkg/transfer/local/pull.go +++ b/pkg/transfer/local/pull.go @@ -30,6 +30,7 @@ import ( "github.com/containerd/containerd/remotes" "github.com/containerd/containerd/remotes/docker" ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/sirupsen/logrus" ) func (ts *localTransferService) pull(ctx context.Context, ir transfer.ImageFetcher, is transfer.ImageStorer, tops *transfer.Config) error { @@ -54,6 +55,32 @@ func (ts *localTransferService) pull(ctx context.Context, ir transfer.ImageFetch return fmt.Errorf("schema 1 image manifests are no longer supported: %w", errdefs.ErrInvalidArgument) } + // Verify image before pulling. + for vfName, vf := range ts.verifiers { + log := log.G(ctx).WithFields(logrus.Fields{ + "name": name, + "digest": desc.Digest.String(), + "verifier": vfName, + }) + log.Debug("Verifying image pull") + + jdg, err := vf.VerifyImage(ctx, name, desc) + if err != nil { + log.WithError(err).Error("No judgement received from verifier") + return fmt.Errorf("blocking pull of %v with digest %v: image verifier %v returned error: %w", name, desc.Digest.String(), vfName, err) + } + log = log.WithFields(logrus.Fields{ + "ok": jdg.OK, + "reason": jdg.Reason, + }) + + if !jdg.OK { + log.Warn("Image verifier blocked pull") + return fmt.Errorf("image verifier %s blocked pull of %v with digest %v for reason: %v", vfName, name, desc.Digest.String(), jdg.Reason) + } + log.Debug("Image verifier allowed pull") + } + // TODO: Handle already exists if tops.Progress != nil { tops.Progress(transfer.Progress{ diff --git a/pkg/transfer/local/transfer.go b/pkg/transfer/local/transfer.go index 7ca800a62..3d65865f2 100644 --- a/pkg/transfer/local/transfer.go +++ b/pkg/transfer/local/transfer.go @@ -29,15 +29,17 @@ import ( "github.com/containerd/containerd/errdefs" "github.com/containerd/containerd/images" "github.com/containerd/containerd/leases" + "github.com/containerd/containerd/pkg/imageverifier" "github.com/containerd/containerd/pkg/kmutex" "github.com/containerd/containerd/pkg/transfer" "github.com/containerd/containerd/pkg/unpack" ) type localTransferService struct { - leases leases.Manager - content content.Store - images images.Store + leases leases.Manager + content content.Store + images images.Store + verifiers map[string]imageverifier.ImageVerifier // limiter for upload limiterU *semaphore.Weighted // limiter for download operation @@ -45,12 +47,13 @@ type localTransferService struct { config TransferConfig } -func NewTransferService(lm leases.Manager, cs content.Store, is images.Store, tc *TransferConfig) transfer.Transferrer { +func NewTransferService(lm leases.Manager, cs content.Store, is images.Store, vfs map[string]imageverifier.ImageVerifier, tc *TransferConfig) transfer.Transferrer { ts := &localTransferService{ - leases: lm, - content: cs, - images: is, - config: *tc, + leases: lm, + content: cs, + images: is, + verifiers: vfs, + config: *tc, } if tc.MaxConcurrentUploadedLayers > 0 { ts.limiterU = semaphore.NewWeighted(int64(tc.MaxConcurrentUploadedLayers)) @@ -88,6 +91,7 @@ func (ts *localTransferService) Transfer(ctx context.Context, src interface{}, d case transfer.ImageExportStreamer: return ts.echo(ctx, s, d, topts) case transfer.ImageStorer: + // TODO: verify imports with ImageVerifiers? return ts.importStream(ctx, s, d, topts) } } diff --git a/plugin/plugin.go b/plugin/plugin.go index 1c8ec536e..951c96020 100644 --- a/plugin/plugin.go +++ b/plugin/plugin.go @@ -90,6 +90,8 @@ const ( SandboxStorePlugin Type = "io.containerd.sandbox.store.v1" // SandboxControllerPlugin implements a sandbox controller SandboxControllerPlugin Type = "io.containerd.sandbox.controller.v1" + // ImageVerifierPlugin implements an image verifier service + ImageVerifierPlugin Type = "io.containerd.image-verifier.v1" ) const ( diff --git a/plugins/imageverifier/path_unix.go b/plugins/imageverifier/path_unix.go new file mode 100644 index 000000000..af658839e --- /dev/null +++ b/plugins/imageverifier/path_unix.go @@ -0,0 +1,21 @@ +//go:build !windows + +/* + Copyright The containerd 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 imageverifier + +var defaultPath = "/opt/containerd/image-verifier/bin" diff --git a/plugins/imageverifier/path_windows.go b/plugins/imageverifier/path_windows.go new file mode 100644 index 000000000..0a22bfb74 --- /dev/null +++ b/plugins/imageverifier/path_windows.go @@ -0,0 +1,25 @@ +/* + Copyright The containerd 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 imageverifier + +import ( + "path/filepath" + + "github.com/containerd/containerd/defaults" +) + +var defaultPath = filepath.Join(defaults.DefaultRootDir, "opt", "image-verifier", "bin") diff --git a/plugins/imageverifier/plugin.go b/plugins/imageverifier/plugin.go new file mode 100644 index 000000000..dac859760 --- /dev/null +++ b/plugins/imageverifier/plugin.go @@ -0,0 +1,45 @@ +/* + Copyright The containerd 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 imageverifier + +import ( + "time" + + "github.com/containerd/containerd/pkg/imageverifier/bindir" + "github.com/containerd/containerd/plugin" +) + +// Register default image verifier service plugin +func init() { + plugin.Register(&plugin.Registration{ + Type: plugin.ImageVerifierPlugin, + ID: "bindir", + Config: defaultConfig(), + InitFn: func(ic *plugin.InitContext) (interface{}, error) { + cfg := ic.Config.(*bindir.Config) + return bindir.NewImageVerifier(cfg), nil + }, + }) +} + +func defaultConfig() *bindir.Config { + return &bindir.Config{ + BinDir: defaultPath, + MaxVerifiers: 10, + PerVerifierTimeout: 10 * time.Second, + } +} diff --git a/plugins/transfer/plugin.go b/plugins/transfer/plugin.go index 9ba0abd24..f30df506a 100644 --- a/plugins/transfer/plugin.go +++ b/plugins/transfer/plugin.go @@ -25,6 +25,7 @@ import ( "github.com/containerd/containerd/leases" "github.com/containerd/containerd/log" "github.com/containerd/containerd/metadata" + "github.com/containerd/containerd/pkg/imageverifier" "github.com/containerd/containerd/pkg/transfer/local" "github.com/containerd/containerd/pkg/unpack" "github.com/containerd/containerd/platforms" @@ -45,6 +46,7 @@ func init() { plugin.LeasePlugin, plugin.MetadataPlugin, plugin.DiffPlugin, + plugin.ImageVerifierPlugin, }, Config: defaultConfig(), InitFn: func(ic *plugin.InitContext) (interface{}, error) { @@ -59,6 +61,20 @@ func init() { return nil, err } + vfs := make(map[string]imageverifier.ImageVerifier) + vps, err := ic.GetByType(plugin.ImageVerifierPlugin) + if err != nil { + return nil, err + } + + for name, vp := range vps { + inst, err := vp.Instance() + if err != nil { + return nil, err + } + vfs[name] = inst.(imageverifier.ImageVerifier) + } + // Set configuration based on default or user input var lc local.TransferConfig lc.MaxConcurrentDownloads = config.MaxConcurrentDownloads @@ -126,7 +142,7 @@ func init() { } lc.RegistryConfigPath = config.RegistryConfigPath - return local.NewTransferService(l.(leases.Manager), ms.ContentStore(), metadata.NewImageStore(ms), &lc), nil + return local.NewTransferService(l.(leases.Manager), ms.ContentStore(), metadata.NewImageStore(ms), vfs, &lc), nil }, }) }