constellation/cli/internal/cmd/upgradeplan_test.go
Fabian Kammel 57b8efd1ec
Improve measurements verification with Rekor (#206)
Fetched measurements are now verified using Rekor in addition to a signature check.
Signed-off-by: Fabian Kammel <fk@edgeless.systems>
2022-10-11 13:57:52 +02:00

528 lines
16 KiB
Go

/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package cmd
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"testing"
"github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider"
"github.com/edgelesssys/constellation/v2/internal/config"
"github.com/edgelesssys/constellation/v2/internal/constants"
"github.com/edgelesssys/constellation/v2/internal/file"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/mod/semver"
"gopkg.in/yaml.v2"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
func TestGetCurrentImageVersion(t *testing.T) {
testCases := map[string]struct {
stubUpgradePlanner stubUpgradePlanner
csp cloudprovider.Provider
wantErr bool
}{
"valid Azure": {
stubUpgradePlanner: stubUpgradePlanner{
image: "/CommunityGalleries/ConstellationCVM-b3782fa0-0df7-4f2f-963e-fc7fc42663df/Images/constellation/Versions/0.0.0",
},
csp: cloudprovider.Azure,
},
"invalid Azure": {
stubUpgradePlanner: stubUpgradePlanner{
image: "/CommunityGalleries/someone-else/Images/constellation/Versions/0.0.1",
},
csp: cloudprovider.Azure,
wantErr: true,
},
"valid GCP": {
stubUpgradePlanner: stubUpgradePlanner{
image: "projects/constellation-images/global/images/constellation-v0-0-0",
},
csp: cloudprovider.GCP,
},
"invalid GCP": {
stubUpgradePlanner: stubUpgradePlanner{
image: "projects/constellation-images/global/images/constellation-debug-image",
},
csp: cloudprovider.GCP,
wantErr: true,
},
"invalid CSP": {
stubUpgradePlanner: stubUpgradePlanner{
image: "some-image",
},
csp: cloudprovider.Unknown,
wantErr: true,
},
"GetCurrentImage error": {
stubUpgradePlanner: stubUpgradePlanner{
err: errors.New("error"),
},
csp: cloudprovider.Azure,
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
version, err := getCurrentImageVersion(context.Background(), tc.stubUpgradePlanner, tc.csp)
if tc.wantErr {
assert.Error(err)
return
}
assert.NoError(err)
assert.True(semver.IsValid(version))
})
}
}
type stubUpgradePlanner struct {
image string
err error
}
func (u stubUpgradePlanner) GetCurrentImage(context.Context) (*unstructured.Unstructured, string, error) {
return nil, u.image, u.err
}
func TestFetchImages(t *testing.T) {
testImages := map[string]imageManifest{
"v0.0.0": {
AzureImage: "azure-v0.0.0",
GCPImage: "gcp-v0.0.0",
},
"v999.999.999": {
AzureImage: "azure-v999.999.999",
GCPImage: "gcp-v999.999.999",
},
}
testCases := map[string]struct {
client *http.Client
wantErr bool
}{
"success": {
client: newTestClient(func(req *http.Request) *http.Response {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBuffer(mustMarshal(t, testImages))),
Header: make(http.Header),
}
}),
},
"error": {
client: newTestClient(func(req *http.Request) *http.Response {
return &http.Response{
StatusCode: http.StatusInternalServerError,
Body: io.NopCloser(bytes.NewBuffer([]byte{})),
Header: make(http.Header),
}
}),
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
images, err := fetchImages(context.Background(), tc.client)
if tc.wantErr {
assert.Error(err)
return
}
assert.NoError(err)
assert.NotNil(images)
})
}
}
func TestGetCompatibleImages(t *testing.T) {
imageList := map[string]imageManifest{
"v0.0.0": {
AzureImage: "azure-v0.0.0",
GCPImage: "gcp-v0.0.0",
},
"v1.0.0": {
AzureImage: "azure-v1.0.0",
GCPImage: "gcp-v1.0.0",
},
"v1.0.1": {
AzureImage: "azure-v1.0.1",
GCPImage: "gcp-v1.0.1",
},
"v1.0.2": {
AzureImage: "azure-v1.0.2",
GCPImage: "gcp-v1.0.2",
},
"v1.1.0": {
AzureImage: "azure-v1.1.0",
GCPImage: "gcp-v1.1.0",
},
}
testCases := map[string]struct {
images map[string]imageManifest
csp cloudprovider.Provider
version string
wantImages map[string]config.UpgradeConfig
}{
"azure": {
images: imageList,
csp: cloudprovider.Azure,
version: "v1.0.0",
wantImages: map[string]config.UpgradeConfig{
"v1.0.1": {
Image: "azure-v1.0.1",
},
"v1.0.2": {
Image: "azure-v1.0.2",
},
"v1.1.0": {
Image: "azure-v1.1.0",
},
},
},
"gcp": {
images: imageList,
csp: cloudprovider.GCP,
version: "v1.0.0",
wantImages: map[string]config.UpgradeConfig{
"v1.0.1": {
Image: "gcp-v1.0.1",
},
"v1.0.2": {
Image: "gcp-v1.0.2",
},
"v1.1.0": {
Image: "gcp-v1.1.0",
},
},
},
"no compatible images": {
images: imageList,
csp: cloudprovider.Azure,
version: "v999.999.999",
wantImages: map[string]config.UpgradeConfig{},
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
compatibleImages := getCompatibleImages(tc.csp, tc.version, tc.images)
assert.Equal(tc.wantImages, compatibleImages)
})
}
}
func TestGetCompatibleImageMeasurements(t *testing.T) {
assert := assert.New(t)
testImages := map[string]config.UpgradeConfig{
"v0.0.0": {
Image: "azure-v0.0.0",
},
"v1.0.0": {
Image: "azure-v1.0.0",
},
}
client := newTestClient(func(req *http.Request) *http.Response {
if strings.HasSuffix(req.URL.String(), "/measurements.yaml") {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("0: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n")),
Header: make(http.Header),
}
}
if strings.HasSuffix(req.URL.String(), "/measurements.yaml.sig") {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("MEUCIBs1g2/n0FsgPfJ+0uLD5TaunGhxwDcQcUGBroejKvg3AiEAzZtcLU9O6IiVhxB8tBS+ty6MXoPNwL8WRWMzyr35eKI=")),
Header: make(http.Header),
}
}
return &http.Response{
StatusCode: http.StatusNotFound,
Body: io.NopCloser(strings.NewReader("Not found.")),
Header: make(http.Header),
}
})
pubK := []byte("-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUs5fDUIz9aiwrfr8BK4VjN7jE6sl\ngz7UuXsOin8+dB0SGrbNHy7TJToa2fAiIKPVLTOfvY75DqRAtffhO1fpBA==\n-----END PUBLIC KEY-----")
err := getCompatibleImageMeasurements(context.Background(), client, singleUUIDVerifier(), pubK, testImages)
assert.NoError(err)
for _, image := range testImages {
assert.NotEmpty(image.Measurements)
}
}
func TestUpgradePlan(t *testing.T) {
testImages := map[string]imageManifest{
"v1.0.0": {
AzureImage: "azure-v1.0.0",
GCPImage: "gcp-v1.0.0",
},
"v2.0.0": {
AzureImage: "azure-v2.0.0",
GCPImage: "gcp-v2.0.0",
},
}
testCases := map[string]struct {
planner stubUpgradePlanner
flags upgradePlanFlags
csp cloudprovider.Provider
verifier rekorVerifier
imageFetchStatus int
measurementsFetchStatus int
wantUpgrade bool
wantErr bool
}{
"no compatible images": {
planner: stubUpgradePlanner{
image: "projects/constellation-images/global/images/constellation-v999-999-999",
},
imageFetchStatus: http.StatusOK,
measurementsFetchStatus: http.StatusOK,
flags: upgradePlanFlags{
configPath: constants.ConfigFilename,
filePath: "upgrade-plan.yaml",
cosignPubKey: "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUs5fDUIz9aiwrfr8BK4VjN7jE6sl\ngz7UuXsOin8+dB0SGrbNHy7TJToa2fAiIKPVLTOfvY75DqRAtffhO1fpBA==\n-----END PUBLIC KEY-----",
},
csp: cloudprovider.GCP,
verifier: singleUUIDVerifier(),
wantUpgrade: false,
},
"upgrades gcp": {
planner: stubUpgradePlanner{
image: "projects/constellation-images/global/images/constellation-v1-0-0",
},
imageFetchStatus: http.StatusOK,
measurementsFetchStatus: http.StatusOK,
flags: upgradePlanFlags{
configPath: constants.ConfigFilename,
filePath: "upgrade-plan.yaml",
cosignPubKey: "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUs5fDUIz9aiwrfr8BK4VjN7jE6sl\ngz7UuXsOin8+dB0SGrbNHy7TJToa2fAiIKPVLTOfvY75DqRAtffhO1fpBA==\n-----END PUBLIC KEY-----",
},
csp: cloudprovider.GCP,
verifier: singleUUIDVerifier(),
wantUpgrade: true,
},
"upgrades azure": {
planner: stubUpgradePlanner{
image: "/CommunityGalleries/ConstellationCVM-b3782fa0-0df7-4f2f-963e-fc7fc42663df/Images/constellation/Versions/0.0.0",
},
imageFetchStatus: http.StatusOK,
measurementsFetchStatus: http.StatusOK,
flags: upgradePlanFlags{
configPath: constants.ConfigFilename,
filePath: "upgrade-plan.yaml",
cosignPubKey: "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUs5fDUIz9aiwrfr8BK4VjN7jE6sl\ngz7UuXsOin8+dB0SGrbNHy7TJToa2fAiIKPVLTOfvY75DqRAtffhO1fpBA==\n-----END PUBLIC KEY-----",
},
csp: cloudprovider.Azure,
verifier: singleUUIDVerifier(),
wantUpgrade: true,
},
"upgrade to stdout": {
planner: stubUpgradePlanner{
image: "projects/constellation-images/global/images/constellation-v1-0-0",
},
imageFetchStatus: http.StatusOK,
measurementsFetchStatus: http.StatusOK,
flags: upgradePlanFlags{
configPath: constants.ConfigFilename,
filePath: "-",
cosignPubKey: "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUs5fDUIz9aiwrfr8BK4VjN7jE6sl\ngz7UuXsOin8+dB0SGrbNHy7TJToa2fAiIKPVLTOfvY75DqRAtffhO1fpBA==\n-----END PUBLIC KEY-----",
},
csp: cloudprovider.GCP,
verifier: singleUUIDVerifier(),
wantUpgrade: true,
},
"current image not valid": {
planner: stubUpgradePlanner{
image: "not-valid",
},
imageFetchStatus: http.StatusOK,
measurementsFetchStatus: http.StatusOK,
flags: upgradePlanFlags{
configPath: constants.ConfigFilename,
filePath: "upgrade-plan.yaml",
cosignPubKey: "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUs5fDUIz9aiwrfr8BK4VjN7jE6sl\ngz7UuXsOin8+dB0SGrbNHy7TJToa2fAiIKPVLTOfvY75DqRAtffhO1fpBA==\n-----END PUBLIC KEY-----",
},
csp: cloudprovider.GCP,
verifier: singleUUIDVerifier(),
wantErr: true,
},
"image fetch error": {
planner: stubUpgradePlanner{
image: "projects/constellation-images/global/images/constellation-v1-0-0",
},
imageFetchStatus: http.StatusInternalServerError,
measurementsFetchStatus: http.StatusOK,
flags: upgradePlanFlags{
configPath: constants.ConfigFilename,
filePath: "upgrade-plan.yaml",
cosignPubKey: "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUs5fDUIz9aiwrfr8BK4VjN7jE6sl\ngz7UuXsOin8+dB0SGrbNHy7TJToa2fAiIKPVLTOfvY75DqRAtffhO1fpBA==\n-----END PUBLIC KEY-----",
},
csp: cloudprovider.GCP,
verifier: singleUUIDVerifier(),
wantErr: true,
},
"measurements fetch error": {
planner: stubUpgradePlanner{
image: "projects/constellation-images/global/images/constellation-v1-0-0",
},
imageFetchStatus: http.StatusOK,
measurementsFetchStatus: http.StatusInternalServerError,
flags: upgradePlanFlags{
configPath: constants.ConfigFilename,
filePath: "upgrade-plan.yaml",
cosignPubKey: "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUs5fDUIz9aiwrfr8BK4VjN7jE6sl\ngz7UuXsOin8+dB0SGrbNHy7TJToa2fAiIKPVLTOfvY75DqRAtffhO1fpBA==\n-----END PUBLIC KEY-----",
},
csp: cloudprovider.GCP,
verifier: singleUUIDVerifier(),
wantErr: true,
},
"failing search should not result in error": {
planner: stubUpgradePlanner{
image: "projects/constellation-images/global/images/constellation-v1-0-0",
},
imageFetchStatus: http.StatusOK,
measurementsFetchStatus: http.StatusOK,
flags: upgradePlanFlags{
configPath: constants.ConfigFilename,
filePath: "upgrade-plan.yaml",
cosignPubKey: "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUs5fDUIz9aiwrfr8BK4VjN7jE6sl\ngz7UuXsOin8+dB0SGrbNHy7TJToa2fAiIKPVLTOfvY75DqRAtffhO1fpBA==\n-----END PUBLIC KEY-----",
},
csp: cloudprovider.GCP,
verifier: &stubRekorVerifier{
SearchByHashUUIDs: []string{},
SearchByHashError: errors.New("some error"),
},
wantUpgrade: true,
},
"failing verify should not result in error": {
planner: stubUpgradePlanner{
image: "projects/constellation-images/global/images/constellation-v1-0-0",
},
imageFetchStatus: http.StatusOK,
measurementsFetchStatus: http.StatusOK,
flags: upgradePlanFlags{
configPath: constants.ConfigFilename,
filePath: "upgrade-plan.yaml",
cosignPubKey: "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUs5fDUIz9aiwrfr8BK4VjN7jE6sl\ngz7UuXsOin8+dB0SGrbNHy7TJToa2fAiIKPVLTOfvY75DqRAtffhO1fpBA==\n-----END PUBLIC KEY-----",
},
csp: cloudprovider.GCP,
verifier: &stubRekorVerifier{
SearchByHashUUIDs: []string{"11111111111111111111111111111111111111111111111111111111111111111111111111111111"},
VerifyEntryError: errors.New("some error"),
},
wantUpgrade: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
fileHandler := file.NewHandler(afero.NewMemMapFs())
cfg := config.Default()
cfg.RemoveProviderExcept(tc.csp)
require.NoError(fileHandler.WriteYAML(tc.flags.configPath, cfg))
cmd := newUpgradePlanCmd()
cmd.SetContext(context.Background())
var out bytes.Buffer
cmd.SetOut(&out)
client := newTestClient(func(req *http.Request) *http.Response {
if req.URL.String() == imageReleaseURL {
return &http.Response{
StatusCode: tc.imageFetchStatus,
Body: io.NopCloser(bytes.NewBuffer(mustMarshal(t, testImages))),
Header: make(http.Header),
}
}
if strings.HasSuffix(req.URL.String(), "/measurements.yaml") {
return &http.Response{
StatusCode: tc.measurementsFetchStatus,
Body: io.NopCloser(strings.NewReader("0: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n")),
Header: make(http.Header),
}
}
if strings.HasSuffix(req.URL.String(), "/measurements.yaml.sig") {
return &http.Response{
StatusCode: tc.measurementsFetchStatus,
Body: io.NopCloser(strings.NewReader("MEUCIBs1g2/n0FsgPfJ+0uLD5TaunGhxwDcQcUGBroejKvg3AiEAzZtcLU9O6IiVhxB8tBS+ty6MXoPNwL8WRWMzyr35eKI=")),
Header: make(http.Header),
}
}
return &http.Response{
StatusCode: http.StatusNotFound,
Body: io.NopCloser(strings.NewReader("Not found.")),
Header: make(http.Header),
}
})
err := upgradePlan(cmd, tc.planner, fileHandler, client, tc.verifier, tc.flags)
if tc.wantErr {
assert.Error(err)
return
}
assert.NoError(err)
if !tc.wantUpgrade {
assert.Contains(out.String(), "No compatible images")
return
}
var availableUpgrades map[string]config.UpgradeConfig
if tc.flags.filePath == "-" {
require.NoError(yaml.Unmarshal(out.Bytes(), &availableUpgrades))
} else {
require.NoError(fileHandler.ReadYAMLStrict(tc.flags.filePath, &availableUpgrades))
}
assert.GreaterOrEqual(len(availableUpgrades), 1)
for _, upgrade := range availableUpgrades {
assert.NotEmpty(upgrade.Image)
assert.NotEmpty(upgrade.Measurements)
}
})
}
}
func mustMarshal(t *testing.T, v interface{}) []byte {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("failed to marshal: %s", err)
}
return b
}