1 Commits

Author SHA1 Message Date
James Carnegie
ed0ae8ecf6 fix: verify mapped image name against subjects (#156)
* fix: verify mapped image name against subjects
2024-09-05 08:08:55 -05:00
10 changed files with 195 additions and 8 deletions

View File

@@ -2,11 +2,17 @@ package test
import ( import (
"context" "context"
"crypto"
"crypto/x509"
_ "embed" _ "embed"
"encoding/pem"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"time"
"github.com/docker/attest/attestation"
"github.com/docker/attest/signerverifier" "github.com/docker/attest/signerverifier"
"github.com/docker/attest/tlog" "github.com/docker/attest/tlog"
"github.com/secure-systems-lab/go-securesystemslib/dsse" "github.com/secure-systems-lab/go-securesystemslib/dsse"
@@ -74,3 +80,38 @@ func Setup(t *testing.T) (context.Context, dsse.SignerVerifier) {
return ctx, signer return ctx, signer
} }
func publicKeyToPEM(pubKey crypto.PublicKey) (string, error) {
derBytes, err := x509.MarshalPKIXPublicKey(pubKey)
if err != nil {
return "", err
}
pemBlock := &pem.Block{
Type: "PUBLIC KEY",
Bytes: derBytes,
}
return string(pem.EncodeToMemory(pemBlock)), nil
}
// LoadKeyMetadata loads the key metadata for the given signer verifier.
func GenKeyMetadata(sv dsse.SignerVerifier) (*attestation.KeyMetadata, error) {
pub := sv.Public()
pem, err := publicKeyToPEM(pub)
if err != nil {
return nil, fmt.Errorf("failed to convert public key to PEM: %w", err)
}
id, err := sv.KeyID()
if err != nil {
return nil, err
}
return &attestation.KeyMetadata{
ID: id,
Status: "active",
SigningFormat: "dssev1",
From: time.Now(),
PEM: pem,
}, nil
}

View File

@@ -87,7 +87,7 @@ func RefToPURL(named reference.Named, platform *v1.Platform) (string, bool, erro
}) })
} }
p := packageurl.NewPackageURL("docker", ns, name, version, qualifiers, "") p := packageurl.NewPackageURL(packageurl.TypeDocker, ns, name, version, qualifiers, "")
return p.ToString(), isCanonical, nil return p.ToString(), isCanonical, nil
} }

View File

@@ -69,14 +69,18 @@ func VerifySubject(ctx context.Context, subject []intoto.Subject, resolver attes
if err != nil { if err != nil {
continue continue
} }
if purl.Type != "docker" { if purl.Type != packageurl.TypeDocker {
continue continue
} }
if purl.Qualifiers.Map()["platform"] != platform.String() { if purl.Qualifiers.Map()["platform"] != platform.String() {
continue continue
} }
// ensure reference is normalized before comparing // ensure reference is normalized before comparing
subjectName, err := reference.ParseNormalizedNamed(purl.Name) withNamespace := purl.Name
if purl.Namespace != "" {
withNamespace = purl.Namespace + "/" + purl.Name
}
subjectName, err := reference.ParseNormalizedNamed(withNamespace)
if err != nil { if err != nil {
continue continue
} }

View File

@@ -14,6 +14,7 @@ import (
"github.com/docker/attest/policy" "github.com/docker/attest/policy"
v1 "github.com/google/go-containerregistry/pkg/v1" v1 "github.com/google/go-containerregistry/pkg/v1"
intoto "github.com/in-toto/in-toto-golang/in_toto" intoto "github.com/in-toto/in-toto-golang/in_toto"
"github.com/package-url/packageurl-go"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -180,6 +181,8 @@ func TestCreateAttestationResolver(t *testing.T) {
func TestVerifySubject(t *testing.T) { func TestVerifySubject(t *testing.T) {
ctx, _ := test.Setup(t) ctx, _ := test.Setup(t)
defaultResolver := attestation.MockResolver{} defaultResolver := attestation.MockResolver{}
hostWithPort := packageurl.QualifiersFromMap(map[string]string{"platform": "linux/amd64"})
withHost := packageurl.NewPackageURL(packageurl.TypeDocker, "localhost:1234", "alpine", "", hostWithPort, "")
testCases := []struct { testCases := []struct {
name string name string
subject []intoto.Subject subject []intoto.Subject
@@ -205,6 +208,24 @@ func TestVerifySubject(t *testing.T) {
}, },
img: "alpine", img: "alpine",
}, },
{
name: "with host and port",
subject: []intoto.Subject{
{
Name: withHost.ToString(),
},
},
img: "localhost:1234/alpine",
},
{
name: "with host and port (from image-signer-verifier tests)",
subject: []intoto.Subject{
{
Name: "pkg:docker/registry.local%3A5000/image-signer-verifier-test@10710107227?platform=linux%2Famd64",
},
},
img: "registry.local:5000/image-signer-verifier-test",
},
{ {
name: "with library", name: "with library",
subject: []intoto.Subject{ subject: []intoto.Subject{
@@ -337,6 +358,10 @@ func TestVerifySubject(t *testing.T) {
for _, tc := range testCases { for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
defaultResolver.Image = tc.img defaultResolver.Image = tc.img
// make sure we're using a fixed platform vs a detected one
defaultResolver.PlatformFn = func() (*v1.Platform, error) {
return &v1.Platform{Architecture: "amd64", OS: "linux"}, nil
}
// digest from mock resolver // digest from mock resolver
tc.subject[0].Digest = map[string]string{"sha256": "da8b190665956ea07890a0273e2a9c96bfe291662f08e2860e868eef69c34620"} tc.subject[0].Digest = map[string]string{"sha256": "da8b190665956ea07890a0273e2a9c96bfe291662f08e2860e868eef69c34620"}
if tc.digest != "" { if tc.digest != "" {

View File

@@ -0,0 +1 @@
config.yaml

View File

@@ -0,0 +1,15 @@
version: v1
kind: policy-mapping
policies:
- id: test-images
description: Local test images
files:
- path: policy.rego
- path: config.yaml #auto generated
attestations:
style: attached
rules:
- pattern: "^docker[.]io/library/test-image$"
policy-id: test-images
- pattern: "^mirror[.]org/library/(.*)$"
rewrite: docker.io/library/$1

View File

@@ -0,0 +1,59 @@
package attest
import rego.v1
import data.keys
provs(pred) := p if {
res := attest.fetch(pred)
not res.error
p := res.value
}
atts := union({
provs("https://slsa.dev/provenance/v0.2"),
provs("https://spdx.dev/Document"),
})
opts := {"keys": keys, "skip_tl": true}
statements contains s if {
some att in atts
res := attest.verify(att, opts)
not res.error
s := res.value
}
subjects contains subject if {
some statement in statements
some subject in statement.subject
}
unsafe_statement_from_attestation(att) := statement if {
payload := att.payload
statement := json.unmarshal(base64.decode(payload))
}
violations contains violation if {
some att in atts
statement := unsafe_statement_from_attestation(att)
res := attest.verify(att, opts)
err := res.error
violation := {
"type": "unsigned_statement",
"description": sprintf("Statement is not correctly signed: %v", [err]),
"attestation": statement,
"details": {"error": err},
}
}
result := {
"success": count(statements) > 0,
"violations": violations,
"summary": {
"subjects": subjects,
"slsa_level": "SLSA_BUILD_LEVEL_3",
"verifier": "docker-official-images",
"policy_uri": "https://docker.com/official/policy/v0.1",
},
}

View File

@@ -1,8 +1,10 @@
package attest package attest
import ( import (
"context"
"fmt" "fmt"
"github.com/docker/attest/attestation"
"github.com/docker/attest/policy" "github.com/docker/attest/policy"
v1 "github.com/google/go-containerregistry/pkg/v1" v1 "github.com/google/go-containerregistry/pkg/v1"
intoto "github.com/in-toto/in-toto-golang/in_toto" intoto "github.com/in-toto/in-toto-golang/in_toto"
@@ -35,3 +37,12 @@ type VerificationResult struct {
Violations []policy.Violation Violations []policy.Violation
SubjectDescriptor *v1.Descriptor SubjectDescriptor *v1.Descriptor
} }
type wrappedResolver struct {
imageName string
attestation.Resolver
}
func (w *wrappedResolver) ImageName(_ context.Context) (string, error) {
return w.imageName, nil
}

View File

@@ -209,6 +209,7 @@ func VerifyAttestations(ctx context.Context, resolver attestation.Resolver, eval
} }
oldName := ref.Name() oldName := ref.Name()
name = strings.Replace(name, oldName, resolvedPolicy.ResolvedName, 1) name = strings.Replace(name, oldName, resolvedPolicy.ResolvedName, 1)
resolver = WithImageName(resolver, resolvedPolicy.ResolvedName)
} }
ref, err := reference.ParseNormalizedNamed(name) ref, err := reference.ParseNormalizedNamed(name)
@@ -251,3 +252,11 @@ func VerifyAttestations(ctx context.Context, resolver attestation.Resolver, eval
verificationResult.SubjectDescriptor = desc verificationResult.SubjectDescriptor = desc
return verificationResult, nil return verificationResult, nil
} }
// WithImageName returns a new resolver that will return the given image name as this can be updated by the mapping file.
func WithImageName(resolver attestation.Resolver, s string) attestation.Resolver {
return &wrappedResolver{
Resolver: resolver,
imageName: s,
}
}

View File

@@ -18,9 +18,13 @@ import (
intoto "github.com/in-toto/in-toto-golang/in_toto" intoto "github.com/in-toto/in-toto-golang/in_toto"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sigs.k8s.io/yaml"
) )
var ExampleAttestation = filepath.Join("test", "testdata", "example_attestation.json") var (
ExampleAttestation = filepath.Join("test", "testdata", "example_attestation.json")
LocalKeysPolicy = filepath.Join("test", "testdata", "local-policy-real")
)
const ( const (
LinuxAMD64 = "linux/amd64" LinuxAMD64 = "linux/amd64"
@@ -53,7 +57,6 @@ func TestVerifyAttestations(t *testing.T) {
return policy.AllowedResult(), tc.policyEvaluationError return policy.AllowedResult(), tc.policyEvaluationError
}, },
} }
_, err := VerifyAttestations(ctx, resolver, &mockPE, &policy.Policy{ResolvedName: ""}) _, err := VerifyAttestations(ctx, resolver, &mockPE, &policy.Policy{ResolvedName: ""})
if tc.expectedError != nil { if tc.expectedError != nil {
if assert.Error(t, err) { if assert.Error(t, err) {
@@ -175,6 +178,20 @@ func TestSignVerify(t *testing.T) {
// setup an image with signed attestations // setup an image with signed attestations
outputLayout := test.CreateTempDir(t, "", TestTempDir) outputLayout := test.CreateTempDir(t, "", TestTempDir)
keys, err := test.GenKeyMetadata(signer)
require.NoError(t, err)
config := struct {
Keys []*attestation.KeyMetadata `json:"keys"`
}{
Keys: []*attestation.KeyMetadata{keys},
}
keysYaml, err := yaml.Marshal(config)
require.NoError(t, err)
// write keysYaml to config.yaml in LocalKeysPolicy.
err = os.WriteFile(filepath.Join(LocalKeysPolicy, "config.yaml"), keysYaml, 0o600)
require.NoError(t, err)
testCases := []struct { testCases := []struct {
name string name string
signTL bool signTL bool
@@ -185,9 +202,10 @@ func TestSignVerify(t *testing.T) {
{name: "happy path", signTL: true, policyDir: PassNoTLPolicyDir}, {name: "happy path", signTL: true, policyDir: PassNoTLPolicyDir},
{name: "sign tl, verify no tl", signTL: true, policyDir: PassPolicyDir}, {name: "sign tl, verify no tl", signTL: true, policyDir: PassPolicyDir},
{name: "no tl", signTL: false, policyDir: PassPolicyDir}, {name: "no tl", signTL: false, policyDir: PassPolicyDir},
{name: "mirror", signTL: true, policyDir: PassMirrorPolicyDir, imageName: "mirror.org/library/test-image:test"}, {name: "mirror", signTL: false, policyDir: PassMirrorPolicyDir, imageName: "mirror.org/library/test-image:test"},
{name: "mirror no match", signTL: true, policyDir: PassMirrorPolicyDir, imageName: "incorrect.org/library/test-image:test", expectedNonSuccess: OutcomeNoPolicy}, {name: "mirror no match", signTL: false, policyDir: PassMirrorPolicyDir, imageName: "incorrect.org/library/test-image:test", expectedNonSuccess: OutcomeNoPolicy},
{name: "verify inputs", signTL: false, policyDir: InputsPolicyDir}, {name: "verify inputs", signTL: false, policyDir: InputsPolicyDir},
{name: "mirror with verification", signTL: false, policyDir: LocalKeysPolicy, imageName: "mirror.org/library/test-image:test"},
} }
attIdx, err := oci.IndexFromPath(test.UnsignedTestImage()) attIdx, err := oci.IndexFromPath(test.UnsignedTestImage())
@@ -196,7 +214,7 @@ func TestSignVerify(t *testing.T) {
for _, tc := range testCases { for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
opts := &attestation.SigningOptions{ opts := &attestation.SigningOptions{
SkipTL: tc.signTL, SkipTL: !tc.signTL,
} }
signedManifests, err := SignStatements(ctx, attIdx.Index, signer, opts) signedManifests, err := SignStatements(ctx, attIdx.Index, signer, opts)
@@ -218,6 +236,7 @@ func TestSignVerify(t *testing.T) {
policyOpts := &policy.Options{ policyOpts := &policy.Options{
LocalPolicyDir: tc.policyDir, LocalPolicyDir: tc.policyDir,
DisableTUF: true, DisableTUF: true,
Debug: true,
} }
results, err := Verify(ctx, spec, policyOpts) results, err := Verify(ctx, spec, policyOpts)
require.NoError(t, err) require.NoError(t, err)
@@ -225,6 +244,9 @@ func TestSignVerify(t *testing.T) {
assert.Equal(t, tc.expectedNonSuccess, results.Outcome) assert.Equal(t, tc.expectedNonSuccess, results.Outcome)
return return
} }
if results.Outcome == OutcomeFailure {
t.Logf("Violations: %v", results.Violations)
}
assert.Equal(t, OutcomeSuccess, results.Outcome) assert.Equal(t, OutcomeSuccess, results.Outcome)
platform, err := oci.ParsePlatform(LinuxAMD64) platform, err := oci.ParsePlatform(LinuxAMD64)
require.NoError(t, err) require.NoError(t, err)