855 attest registry source only (#856)

Add source.NewFromRegistry function so that the syft attest command can always explicitly ask for an OCIRegistry provider rather than rely on local daemon detection for image sources.

Attestation can not be used where local images loaded in a daemon are the source. Digest values for the layer identification step in attestation can sometimes vary across workstations.

This fix makes it so that attest is generating an SBOM for, and attesting to, a source that exists in an OCI registry. It should never load a source from a local user docker/podman daemon.

Signed-off-by: Christopher Phillips <christopher.phillips@anchore.com>
Co-authored-by: Alex Goodman <alex.goodman@anchore.com>
This commit is contained in:
Christopher Angelo Phillips
2022-03-01 23:16:42 -05:00
committed by GitHub
co-authored by Alex Goodman
parent edac8c7bf7
commit afc0c1acd9
17 changed files with 459 additions and 79 deletions
+3 -5
View File
@@ -6,7 +6,7 @@ import (
)
func TestAttestCmd(t *testing.T) {
coverageImage := "docker-archive:" + getFixtureImage(t, "image-pkg-coverage")
img := "registry:busybox:latest"
tests := []struct {
name string
args []string
@@ -26,19 +26,17 @@ func TestAttestCmd(t *testing.T) {
},
{
name: "can encode syft.json as the predicate given a password",
args: []string{"attest", "-o", "json", coverageImage},
args: []string{"attest", "-o", "json", img},
assertions: []traitAssertion{
assertSuccessfulReturnCode,
// assertVerifyAttestation(coverageImage), Follow up on this assertion with verify blog or ephemperal registry
},
pw: "test",
},
{
name: "can encode syft.json as the predicate given a blank password",
args: []string{"attest", "-o", "json", coverageImage},
args: []string{"attest", "-o", "json", img},
assertions: []traitAssertion{
assertSuccessfulReturnCode,
// assertVerifyAttestation(coverageImage), Follow up on this assertion with verify blog or ephemperal registry
},
pw: "",
},
+175
View File
@@ -0,0 +1,175 @@
package cli
import (
"bufio"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func runAndShow(t *testing.T, cmd *exec.Cmd) {
t.Helper()
stderr, err := cmd.StderrPipe()
require.NoErrorf(t, err, "could not get stderr: +v", err)
stdout, err := cmd.StdoutPipe()
require.NoErrorf(t, err, "could not get stdout: +v", err)
err = cmd.Start()
require.NoErrorf(t, err, "failed to start cmd: %+v", err)
show := func(label string, reader io.ReadCloser) {
scanner := bufio.NewScanner(reader)
scanner.Split(bufio.ScanLines)
for scanner.Scan() {
t.Logf("%s: %s", label, scanner.Text())
}
}
show("out", stdout)
show("err", stderr)
}
func TestCosignWorkflow(t *testing.T) {
// found under test-fixtures/registry/Makefile
img := "localhost:5000/attest:latest"
attestationFile := "attestation.json"
tests := []struct {
name string
syftArgs []string
cosignAttachArgs []string
cosignVerifyArgs []string
env map[string]string
assertions []traitAssertion
setup func(*testing.T)
cleanup func()
}{
{
name: "cosign verify syft attest",
syftArgs: []string{
"attest",
"-o",
"json",
img,
},
// cosign attach attestation --attestation image_latest_sbom_attestation.json caphill4/attest:latest
cosignAttachArgs: []string{
"attach",
"attestation",
"--attestation",
attestationFile,
img,
},
// cosign verify-attestation -key cosign.pub caphill4/attest:latest
cosignVerifyArgs: []string{
"verify-attestation",
"-key",
"cosign.pub",
img,
},
assertions: []traitAssertion{
assertSuccessfulReturnCode,
},
setup: func(t *testing.T) {
cwd, err := os.Getwd()
require.NoErrorf(t, err, "unable to get cwd: %+v", err)
// get working directory for local registry
fixturesPath := filepath.Join(cwd, "test-fixtures", "registry")
makeTask := filepath.Join(fixturesPath, "Makefile")
t.Logf("Generating Fixture from 'make %s'", makeTask)
cmd := exec.Command("make")
cmd.Dir = fixturesPath
runAndShow(t, cmd)
var done = make(chan struct{})
defer close(done)
for interval := range testRetryIntervals(done) {
resp, err := http.Get("http://127.0.0.1:5000/v2/")
if err != nil {
t.Logf("waiting for registry err=%+v", err)
} else {
if resp.StatusCode == http.StatusOK {
break
}
t.Logf("waiting for registry code=%+v", resp.StatusCode)
}
time.Sleep(interval)
}
cmd = exec.Command("make", "push")
cmd.Dir = fixturesPath
runAndShow(t, cmd)
},
cleanup: func() {
cwd, err := os.Getwd()
assert.NoErrorf(t, err, "unable to get cwd: %+v", err)
fixturesPath := filepath.Join(cwd, "test-fixtures", "registry")
makeTask := filepath.Join(fixturesPath, "Makefile")
t.Logf("Generating Fixture from 'make %s'", makeTask)
// delete attestation file
os.Remove(attestationFile)
cmd := exec.Command("make", "stop")
cmd.Dir = fixturesPath
runAndShow(t, cmd)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Cleanup(tt.cleanup)
tt.setup(t)
pkiCleanup := setupPKI(t, "") // blank password
defer pkiCleanup()
// attest
cmd, stdout, stderr := runSyft(t, tt.env, tt.syftArgs...)
for _, traitFn := range tt.assertions {
traitFn(t, stdout, stderr, cmd.ProcessState.ExitCode())
}
checkCmdFailure(t, stdout, stderr, cmd)
require.NoError(t, os.WriteFile(attestationFile, []byte(stdout), 0666))
// attach
cmd, stdout, stderr = runCosign(t, tt.env, tt.cosignAttachArgs...)
for _, traitFn := range tt.assertions {
traitFn(t, stdout, stderr, cmd.ProcessState.ExitCode())
}
checkCmdFailure(t, stdout, stderr, cmd)
// attest
cmd, stdout, stderr = runCosign(t, tt.env, tt.cosignAttachArgs...)
for _, traitFn := range tt.assertions {
traitFn(t, stdout, stderr, cmd.ProcessState.ExitCode())
}
checkCmdFailure(t, stdout, stderr, cmd)
})
}
}
func checkCmdFailure(t testing.TB, stdout, stderr string, cmd *exec.Cmd) {
require.Falsef(t, t.Failed(), "%s %s trait assertion failed", cmd.Path, strings.Join(cmd.Args, " "))
if t.Failed() {
t.Log("STDOUT:\n", stdout)
t.Log("STDERR:\n", stderr)
t.Log("COMMAND:", strings.Join(cmd.Args, " "))
}
}
@@ -0,0 +1 @@
**/*
@@ -0,0 +1 @@
FROM alpine:latest
+23
View File
@@ -0,0 +1,23 @@
all: build start
.PHONY: stop
stop:
docker kill registry
.PHONY: build
build:
docker build -t localhost:5000/attest:latest .
.PHONY: start
start:
docker run --rm \
-d \
--name registry \
-it \
--privileged \
-p 5000:5000 \
registry:2.8
.PHONY: push
push:
docker push localhost:5000/attest:latest
+53
View File
@@ -3,6 +3,7 @@ package cli
import (
"bytes"
"fmt"
"math"
"os"
"os/exec"
"path"
@@ -10,6 +11,7 @@ import (
"runtime"
"strings"
"testing"
"time"
"github.com/anchore/stereoscope/pkg/imagetest"
)
@@ -95,6 +97,20 @@ func runSyft(t testing.TB, env map[string]string, args ...string) (*exec.Cmd, st
return cmd, stdout, stderr
}
func runCosign(t testing.TB, env map[string]string, args ...string) (*exec.Cmd, string, string) {
cmd := getCosignCommand(t, args...)
if env == nil {
env = make(map[string]string)
}
stdout, stderr := runCommand(cmd, env)
return cmd, stdout, stderr
}
func getCosignCommand(t testing.TB, args ...string) *exec.Cmd {
return exec.Command(filepath.Join(repoRoot(t), ".tmp/cosign"), args...)
}
func runCommand(cmd *exec.Cmd, env map[string]string) (string, string) {
if env != nil {
cmd.Env = append(os.Environ(), envMapToSlice(env)...)
@@ -154,3 +170,40 @@ func repoRoot(t testing.TB) string {
}
return absRepoRoot
}
func testRetryIntervals(done <-chan struct{}) <-chan time.Duration {
return exponentialBackoffDurations(250*time.Millisecond, 4*time.Second, 2, done)
}
func exponentialBackoffDurations(minDuration, maxDuration time.Duration, step float64, done <-chan struct{}) <-chan time.Duration {
sleepDurations := make(chan time.Duration)
go func() {
defer close(sleepDurations)
retryLoop:
for attempt := 0; ; attempt++ {
duration := exponentialBackoffDuration(minDuration, maxDuration, step, attempt)
select {
case sleepDurations <- duration:
break
case <-done:
break retryLoop
}
if duration == maxDuration {
break
}
}
}()
return sleepDurations
}
func exponentialBackoffDuration(minDuration, maxDuration time.Duration, step float64, attempt int) time.Duration {
duration := time.Duration(float64(minDuration) * math.Pow(step, float64(attempt)))
if duration < minDuration {
return minDuration
} else if duration > maxDuration {
return maxDuration
}
return duration
}