source-controller/internal/oci/notation/notation_test.go

652 lines
20 KiB
Go

/*
Copyright 2023 The Flux 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 notation
import (
"context"
"fmt"
"net/http"
"net/url"
"path"
"reflect"
"testing"
"github.com/go-logr/logr"
"github.com/google/go-containerregistry/pkg/authn"
"github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/v1/remote"
"github.com/notaryproject/notation-go"
"github.com/notaryproject/notation-go/verifier/trustpolicy"
. "github.com/onsi/gomega"
"github.com/fluxcd/source-controller/internal/oci"
testproxy "github.com/fluxcd/source-controller/tests/proxy"
testregistry "github.com/fluxcd/source-controller/tests/registry"
)
func TestOptions(t *testing.T) {
testCases := []struct {
name string
opts []Options
want *options
}{
{
name: "no options",
want: &options{},
},
{
name: "signature option",
opts: []Options{WithRootCertificates([][]byte{[]byte("foo")})},
want: &options{
rootCertificates: [][]byte{[]byte("foo")},
rOpt: nil,
},
},
{
name: "keychain option",
opts: []Options{
WithRemoteOptions(remote.WithAuthFromKeychain(authn.DefaultKeychain)),
WithKeychain(authn.DefaultKeychain),
},
want: &options{
rootCertificates: nil,
rOpt: []remote.Option{remote.WithAuthFromKeychain(authn.DefaultKeychain)},
keychain: authn.DefaultKeychain,
},
},
{
name: "keychain and authenticator option",
opts: []Options{
WithRemoteOptions(
remote.WithAuth(&authn.Basic{Username: "foo", Password: "bar"}),
remote.WithAuthFromKeychain(authn.DefaultKeychain),
),
WithAuth(&authn.Basic{Username: "foo", Password: "bar"}),
WithKeychain(authn.DefaultKeychain),
},
want: &options{
rootCertificates: nil,
rOpt: []remote.Option{
remote.WithAuth(&authn.Basic{Username: "foo", Password: "bar"}),
remote.WithAuthFromKeychain(authn.DefaultKeychain),
},
auth: &authn.Basic{Username: "foo", Password: "bar"},
keychain: authn.DefaultKeychain,
},
},
{
name: "keychain, authenticator and transport option",
opts: []Options{
WithRemoteOptions(
remote.WithAuth(&authn.Basic{Username: "foo", Password: "bar"}),
remote.WithAuthFromKeychain(authn.DefaultKeychain),
remote.WithTransport(http.DefaultTransport),
),
WithAuth(&authn.Basic{Username: "foo", Password: "bar"}),
WithKeychain(authn.DefaultKeychain),
},
want: &options{
rootCertificates: nil,
rOpt: []remote.Option{
remote.WithAuth(&authn.Basic{Username: "foo", Password: "bar"}),
remote.WithAuthFromKeychain(authn.DefaultKeychain),
remote.WithTransport(http.DefaultTransport),
},
auth: &authn.Basic{Username: "foo", Password: "bar"},
keychain: authn.DefaultKeychain,
},
},
{
name: "truststore, empty document",
opts: []Options{WithTrustPolicy(&trustpolicy.Document{})},
want: &options{
rootCertificates: nil,
rOpt: nil,
trustPolicy: &trustpolicy.Document{},
},
},
{
name: "truststore, dummy document",
opts: []Options{WithTrustPolicy(dummyPolicyDocument())},
want: &options{
rootCertificates: nil,
rOpt: nil,
trustPolicy: dummyPolicyDocument(),
},
},
{
name: "insecure, false",
opts: []Options{WithInsecureRegistry(false)},
want: &options{
rootCertificates: nil,
rOpt: nil,
trustPolicy: nil,
insecure: false,
},
},
{
name: "insecure, true",
opts: []Options{WithInsecureRegistry(true)},
want: &options{
rootCertificates: nil,
rOpt: nil,
trustPolicy: nil,
insecure: true,
},
},
{
name: "insecure, default",
opts: []Options{},
want: &options{
rootCertificates: nil,
rOpt: nil,
trustPolicy: nil,
insecure: false,
},
},
}
// Run the test cases
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
o := options{}
for _, opt := range tc.opts {
opt(&o)
}
if !reflect.DeepEqual(o.rootCertificates, tc.want.rootCertificates) {
t.Errorf("got %#v, want %#v", &o.rootCertificates, tc.want.rootCertificates)
}
if !reflect.DeepEqual(o.trustPolicy, tc.want.trustPolicy) {
t.Errorf("got %#v, want %#v", &o.trustPolicy, tc.want.trustPolicy)
}
if tc.want.rOpt != nil {
if len(o.rOpt) != len(tc.want.rOpt) {
t.Errorf("got %d remote options, want %d", len(o.rOpt), len(tc.want.rOpt))
}
return
}
if tc.want.rOpt == nil {
if len(o.rOpt) != 0 {
t.Errorf("got %d remote options, want %d", len(o.rOpt), 0)
}
}
})
}
}
func TestCleanTrustPolicy(t *testing.T) {
testCases := []struct {
name string
policy []trustpolicy.TrustPolicy
want *trustpolicy.Document
wantLogMessage string
}{
{
name: "no trust policy",
want: nil,
},
{
name: "trust policy verification level set to strict and should not be cleaned",
policy: []trustpolicy.TrustPolicy{{
Name: "test-statement-name",
RegistryScopes: []string{"*"},
SignatureVerification: trustpolicy.SignatureVerification{VerificationLevel: "strict"},
TrustStores: []string{"test"},
TrustedIdentities: nil,
}},
want: &trustpolicy.Document{
Version: "1.0",
TrustPolicies: []trustpolicy.TrustPolicy{{
Name: "test-statement-name",
RegistryScopes: []string{"*"},
SignatureVerification: trustpolicy.SignatureVerification{VerificationLevel: "strict"},
TrustStores: []string{"test"},
TrustedIdentities: nil,
}},
},
},
{
name: "trust policy with multiple policies and should not be cleaned",
policy: []trustpolicy.TrustPolicy{
{
Name: "test-statement-name",
RegistryScopes: []string{"*"},
SignatureVerification: trustpolicy.SignatureVerification{VerificationLevel: "strict"},
TrustStores: []string{"test"},
TrustedIdentities: []string{"x509.subject:CN=Notation Test Root,O=Notary,L=Seattle,ST=WA,C=US"},
},
{
Name: "test-statement-name-2",
RegistryScopes: []string{"example.com/podInfo"},
SignatureVerification: trustpolicy.SignatureVerification{VerificationLevel: "strict"},
TrustStores: []string{"test"},
TrustedIdentities: nil,
},
},
want: &trustpolicy.Document{
Version: "1.0",
TrustPolicies: []trustpolicy.TrustPolicy{
{
Name: "test-statement-name",
RegistryScopes: []string{"*"},
SignatureVerification: trustpolicy.SignatureVerification{VerificationLevel: "strict"},
TrustStores: []string{"test"},
TrustedIdentities: []string{"x509.subject:CN=Notation Test Root,O=Notary,L=Seattle,ST=WA,C=US"},
},
{
Name: "test-statement-name-2",
RegistryScopes: []string{"example.com/podInfo"},
SignatureVerification: trustpolicy.SignatureVerification{VerificationLevel: "strict"},
TrustStores: []string{"test"},
TrustedIdentities: nil,
},
},
},
},
{
name: "trust policy verification level skip should be cleaned",
policy: []trustpolicy.TrustPolicy{
{
Name: "test-statement-name",
RegistryScopes: []string{"*"},
SignatureVerification: trustpolicy.SignatureVerification{VerificationLevel: "skip"},
TrustStores: []string{"test"},
TrustedIdentities: []string{"x509.subject:CN=Notation Test Root,O=Notary,L=Seattle,ST=WA,C=US"},
},
},
want: &trustpolicy.Document{
Version: "1.0",
TrustPolicies: []trustpolicy.TrustPolicy{
{
Name: "test-statement-name",
RegistryScopes: []string{"*"},
SignatureVerification: trustpolicy.SignatureVerification{VerificationLevel: "skip"},
TrustStores: []string{},
TrustedIdentities: []string{},
},
},
},
wantLogMessage: "warning: trust policy statement 'test-statement-name' is set to skip signature verification but configured with trust stores and/or trusted identities. Ignoring trust stores and trusted identities",
},
{
name: "trust policy with multiple policies and mixture of verification levels including skip",
policy: []trustpolicy.TrustPolicy{
{
Name: "test-statement-name",
RegistryScopes: []string{"*"},
SignatureVerification: trustpolicy.SignatureVerification{VerificationLevel: "strict"},
TrustStores: []string{"test"},
TrustedIdentities: []string{"x509.subject:CN=Notation Test Root,O=Notary,L=Seattle,ST=WA,C=US"},
},
{
Name: "test-statement-name-2",
RegistryScopes: []string{"example.com/podInfo"},
SignatureVerification: trustpolicy.SignatureVerification{VerificationLevel: "skip"},
TrustStores: []string{"test"},
TrustedIdentities: []string{"x509.subject:CN=Notation Test Root,O=Notary,L=Seattle,ST=WA,C=US"},
},
},
want: &trustpolicy.Document{
Version: "1.0",
TrustPolicies: []trustpolicy.TrustPolicy{
{
Name: "test-statement-name",
RegistryScopes: []string{"*"},
SignatureVerification: trustpolicy.SignatureVerification{VerificationLevel: "strict"},
TrustStores: []string{"test"},
TrustedIdentities: []string{"x509.subject:CN=Notation Test Root,O=Notary,L=Seattle,ST=WA,C=US"},
},
{
Name: "test-statement-name-2",
RegistryScopes: []string{"example.com/podInfo"},
SignatureVerification: trustpolicy.SignatureVerification{VerificationLevel: "skip"},
TrustStores: []string{},
TrustedIdentities: []string{},
},
},
},
wantLogMessage: "warning: trust policy statement 'test-statement-name-2' is set to skip signature verification but configured with trust stores and/or trusted identities. Ignoring trust stores and trusted identities",
},
}
// Run the test cases
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
g := NewWithT(t)
l := &testLogger{[]string{}, logr.RuntimeInfo{CallDepth: 1}}
logger := logr.New(l)
var policy *trustpolicy.Document
if tc.policy != nil {
policy = &trustpolicy.Document{
Version: "1.0",
TrustPolicies: tc.policy,
}
}
cleanedPolicy := CleanTrustPolicy(policy, logger)
if !reflect.DeepEqual(cleanedPolicy, tc.want) {
t.Errorf("got %#v, want %#v", cleanedPolicy, tc.want)
}
if tc.wantLogMessage != "" {
g.Expect(len(l.Output)).Should(Equal(1))
g.Expect(l.Output[0]).Should(Equal(tc.wantLogMessage))
}
})
}
}
func TestOutcomeChecker(t *testing.T) {
testCases := []struct {
name string
outcome []*notation.VerificationOutcome
wantErrMessage string
wantLogMessage []string
wantVerificationResult oci.VerificationResult
}{
{
name: "no outcome failed with error message",
wantVerificationResult: oci.VerificationResultFailed,
wantErrMessage: "signature verification failed for all the signatures associated with example.com/podInfo",
},
{
name: "verification result ignored with log message",
outcome: []*notation.VerificationOutcome{
{
VerificationLevel: trustpolicy.LevelAudit,
VerificationResults: []*notation.ValidationResult{
{
Type: trustpolicy.TypeAuthenticity,
Action: trustpolicy.ActionLog,
Error: fmt.Errorf("123"),
},
},
},
},
wantVerificationResult: oci.VerificationResultIgnored,
wantLogMessage: []string{"verification check for type 'authenticity' failed for 'example.com/podInfo' with message: '123'"},
},
{
name: "verification result ignored with no log message (skip)",
outcome: []*notation.VerificationOutcome{
{
VerificationLevel: trustpolicy.LevelSkip,
VerificationResults: []*notation.ValidationResult{},
},
},
wantVerificationResult: oci.VerificationResultIgnored,
},
{
name: "verification result success with log message",
outcome: []*notation.VerificationOutcome{
{
VerificationLevel: trustpolicy.LevelAudit,
VerificationResults: []*notation.ValidationResult{
{
Type: trustpolicy.TypeAuthenticTimestamp,
Action: trustpolicy.ActionLog,
Error: fmt.Errorf("456"),
},
{
Type: trustpolicy.TypeExpiry,
Action: trustpolicy.ActionLog,
Error: fmt.Errorf("789"),
},
},
},
},
wantVerificationResult: oci.VerificationResultSuccess,
wantLogMessage: []string{
"verification check for type 'authenticTimestamp' failed for 'example.com/podInfo' with message: '456'",
"verification check for type 'expiry' failed for 'example.com/podInfo' with message: '789'",
},
},
{
name: "verification result success with no log message",
outcome: []*notation.VerificationOutcome{
{
VerificationLevel: trustpolicy.LevelAudit,
VerificationResults: []*notation.ValidationResult{},
},
},
wantVerificationResult: oci.VerificationResultSuccess,
},
}
// Run the test cases
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
g := NewWithT(t)
l := &testLogger{[]string{}, logr.RuntimeInfo{CallDepth: 1}}
logger := logr.New(l)
v := NotationVerifier{
logger: logger,
}
result, err := v.checkOutcome(tc.outcome, "example.com/podInfo")
if tc.wantErrMessage != "" {
g.Expect(err).ToNot(BeNil())
g.Expect(err.Error()).Should(Equal(tc.wantErrMessage))
} else {
g.Expect(err).To(BeNil())
}
g.Expect(result).Should(Equal(tc.wantVerificationResult))
g.Expect(len(l.Output)).Should(Equal(len(tc.wantLogMessage)))
for i, j := range tc.wantLogMessage {
g.Expect(l.Output[i]).Should(Equal(j))
}
})
}
}
func TestRepoUrlWithDigest(t *testing.T) {
testCases := []struct {
name string
repoUrl string
digest string
tag string
wantResultUrl string
wantErrMessage string
passUrlWithoutTag bool
}{
{
name: "valid repo url with digest",
repoUrl: "ghcr.io/stefanprodan/charts/podinfo",
digest: "sha256:cdd538a0167e4b51152b71a477e51eb6737553510ce8797dbcc537e1342311bb",
wantResultUrl: "ghcr.io/stefanprodan/charts/podinfo@sha256:cdd538a0167e4b51152b71a477e51eb6737553510ce8797dbcc537e1342311bb",
wantErrMessage: "",
},
{
name: "valid repo url with tag",
repoUrl: "ghcr.io/stefanprodan/charts/podinfo",
tag: "6.6.0",
wantResultUrl: "ghcr.io/stefanprodan/charts/podinfo@sha256:cdd538a0167e4b51152b71a477e51eb6737553510ce8797dbcc537e1342311bb",
wantErrMessage: "",
},
{
name: "valid repo url without tag",
repoUrl: "ghcr.io/stefanprodan/charts/podinfo",
tag: "6.6.0",
wantResultUrl: "ghcr.io/stefanprodan/charts/podinfo@sha256:cdd538a0167e4b51152b71a477e51eb6737553510ce8797dbcc537e1342311bb",
wantErrMessage: "url ghcr.io/stefanprodan/charts/podinfo does not contain tag or digest",
passUrlWithoutTag: true,
},
}
// Run the test cases
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
g := NewWithT(t)
l := &testLogger{[]string{}, logr.RuntimeInfo{CallDepth: 1}}
logger := logr.New(l)
v := NotationVerifier{
logger: logger,
}
var url string
repo, _ := name.NewRepository(tc.repoUrl)
var ref name.Reference
if tc.digest != "" {
ref = repo.Digest(tc.digest)
url = fmt.Sprintf("%s@%s", tc.repoUrl, tc.digest)
} else if tc.tag != "" {
ref = repo.Tag(tc.tag)
if !tc.passUrlWithoutTag {
url = fmt.Sprintf("%s:%s", tc.repoUrl, tc.tag)
} else {
url = tc.repoUrl
}
} else {
ref = repo.Tag(name.DefaultTag)
url = fmt.Sprintf("%s:%s", tc.repoUrl, name.DefaultTag)
}
result, err := v.repoUrlWithDigest(url, ref)
if tc.wantErrMessage != "" {
g.Expect(err).ToNot(BeNil())
g.Expect(err.Error()).Should(Equal(tc.wantErrMessage))
} else {
g.Expect(err).To(BeNil())
g.Expect(result).Should(Equal(tc.wantResultUrl))
}
})
}
}
func TestVerificationWithProxy(t *testing.T) {
g := NewWithT(t)
registryAddr := testregistry.New(t)
tarFilePath := path.Join("..", "..", "controller", "testdata", "podinfo", "podinfo-6.1.5.tar")
_, err := testregistry.CreatePodinfoImageFromTar(tarFilePath, "6.1.5", registryAddr)
g.Expect(err).NotTo(HaveOccurred())
tagURL := fmt.Sprintf("%s/podinfo:6.1.5", registryAddr)
ref, err := name.ParseReference(tagURL)
g.Expect(err).NotTo(HaveOccurred())
proxyAddr, proxyPort := testproxy.New(t)
tests := []struct {
name string
proxyURL *url.URL
err string
}{
{
name: "with correct proxy",
proxyURL: &url.URL{Scheme: "http", Host: proxyAddr},
err: "no signature is associated with",
},
{
name: "with incorrect proxy",
proxyURL: &url.URL{Scheme: "http", Host: fmt.Sprintf("localhost:%d", proxyPort+1)},
err: "connection refused",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
ctx := context.Background()
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = http.ProxyURL(tt.proxyURL)
var opts []Options
opts = append(opts, WithTransport(transport))
opts = append(opts, WithTrustPolicy(dummyPolicyDocument()))
opts = append(opts, WithInsecureRegistry(true))
verifier, err := NewNotationVerifier(opts...)
g.Expect(err).NotTo(HaveOccurred())
_, err = verifier.Verify(ctx, ref)
g.Expect(err.Error()).To(ContainSubstring(tt.err))
})
}
}
func dummyPolicyDocument() (policyDoc *trustpolicy.Document) {
policyDoc = &trustpolicy.Document{
Version: "1.0",
TrustPolicies: []trustpolicy.TrustPolicy{dummyPolicyStatement()},
}
return
}
func dummyPolicyStatement() (policyStatement trustpolicy.TrustPolicy) {
policyStatement = trustpolicy.TrustPolicy{
Name: "test-statement-name",
RegistryScopes: []string{"*"},
SignatureVerification: trustpolicy.SignatureVerification{VerificationLevel: "strict"},
TrustStores: []string{"ca:valid-trust-store", "signingAuthority:valid-trust-store"},
TrustedIdentities: []string{"x509.subject:CN=Notation Test Root,O=Notary,L=Seattle,ST=WA,C=US"},
}
return
}
// mocking LogSink to capture log messages. Source: https://stackoverflow.com/a/71425740
type testLogger struct {
Output []string
r logr.RuntimeInfo
}
func (t *testLogger) doLog(msg string) {
t.Output = append(t.Output, msg)
}
func (t *testLogger) Init(info logr.RuntimeInfo) {
t.r = info
}
func (t *testLogger) Enabled(level int) bool {
return true
}
func (t *testLogger) Info(level int, msg string, keysAndValues ...interface{}) {
t.doLog(msg)
}
func (t *testLogger) Error(err error, msg string, keysAndValues ...interface{}) {
t.doLog(msg)
}
func (t *testLogger) WithValues(keysAndValues ...interface{}) logr.LogSink {
return t
}
func (t *testLogger) WithName(name string) logr.LogSink {
return t
}