fix: verify mapped image name against subjects (#156)
* fix: verify mapped image name against subjects
This commit is contained in:
@@ -2,11 +2,17 @@ package test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/x509"
|
||||
_ "embed"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/docker/attest/attestation"
|
||||
"github.com/docker/attest/signerverifier"
|
||||
"github.com/docker/attest/tlog"
|
||||
"github.com/secure-systems-lab/go-securesystemslib/dsse"
|
||||
@@ -74,3 +80,38 @@ func Setup(t *testing.T) (context.Context, dsse.SignerVerifier) {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -69,14 +69,18 @@ func VerifySubject(ctx context.Context, subject []intoto.Subject, resolver attes
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if purl.Type != "docker" {
|
||||
if purl.Type != packageurl.TypeDocker {
|
||||
continue
|
||||
}
|
||||
if purl.Qualifiers.Map()["platform"] != platform.String() {
|
||||
continue
|
||||
}
|
||||
// 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 {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/docker/attest/policy"
|
||||
v1 "github.com/google/go-containerregistry/pkg/v1"
|
||||
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/require"
|
||||
)
|
||||
@@ -180,6 +181,8 @@ func TestCreateAttestationResolver(t *testing.T) {
|
||||
func TestVerifySubject(t *testing.T) {
|
||||
ctx, _ := test.Setup(t)
|
||||
defaultResolver := attestation.MockResolver{}
|
||||
hostWithPort := packageurl.QualifiersFromMap(map[string]string{"platform": "linux/amd64"})
|
||||
withHost := packageurl.NewPackageURL(packageurl.TypeDocker, "localhost:1234", "alpine", "", hostWithPort, "")
|
||||
testCases := []struct {
|
||||
name string
|
||||
subject []intoto.Subject
|
||||
@@ -205,6 +208,24 @@ func TestVerifySubject(t *testing.T) {
|
||||
},
|
||||
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",
|
||||
subject: []intoto.Subject{
|
||||
@@ -337,6 +358,10 @@ func TestVerifySubject(t *testing.T) {
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
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
|
||||
tc.subject[0].Digest = map[string]string{"sha256": "da8b190665956ea07890a0273e2a9c96bfe291662f08e2860e868eef69c34620"}
|
||||
if tc.digest != "" {
|
||||
|
||||
1
test/testdata/local-policy-real/.gitignore
vendored
Normal file
1
test/testdata/local-policy-real/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
config.yaml
|
||||
15
test/testdata/local-policy-real/mapping.yaml
vendored
Normal file
15
test/testdata/local-policy-real/mapping.yaml
vendored
Normal 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
|
||||
59
test/testdata/local-policy-real/policy.rego
vendored
Normal file
59
test/testdata/local-policy-real/policy.rego
vendored
Normal 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",
|
||||
},
|
||||
}
|
||||
11
types.go
11
types.go
@@ -1,8 +1,10 @@
|
||||
package attest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/docker/attest/attestation"
|
||||
"github.com/docker/attest/policy"
|
||||
v1 "github.com/google/go-containerregistry/pkg/v1"
|
||||
intoto "github.com/in-toto/in-toto-golang/in_toto"
|
||||
@@ -35,3 +37,12 @@ type VerificationResult struct {
|
||||
Violations []policy.Violation
|
||||
SubjectDescriptor *v1.Descriptor
|
||||
}
|
||||
|
||||
type wrappedResolver struct {
|
||||
imageName string
|
||||
attestation.Resolver
|
||||
}
|
||||
|
||||
func (w *wrappedResolver) ImageName(_ context.Context) (string, error) {
|
||||
return w.imageName, nil
|
||||
}
|
||||
|
||||
@@ -209,6 +209,7 @@ func VerifyAttestations(ctx context.Context, resolver attestation.Resolver, eval
|
||||
}
|
||||
oldName := ref.Name()
|
||||
name = strings.Replace(name, oldName, resolvedPolicy.ResolvedName, 1)
|
||||
resolver = WithImageName(resolver, resolvedPolicy.ResolvedName)
|
||||
}
|
||||
|
||||
ref, err := reference.ParseNormalizedNamed(name)
|
||||
@@ -251,3 +252,11 @@ func VerifyAttestations(ctx context.Context, resolver attestation.Resolver, eval
|
||||
verificationResult.SubjectDescriptor = desc
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,9 +18,13 @@ import (
|
||||
intoto "github.com/in-toto/in-toto-golang/in_toto"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"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 (
|
||||
LinuxAMD64 = "linux/amd64"
|
||||
@@ -53,7 +57,6 @@ func TestVerifyAttestations(t *testing.T) {
|
||||
return policy.AllowedResult(), tc.policyEvaluationError
|
||||
},
|
||||
}
|
||||
|
||||
_, err := VerifyAttestations(ctx, resolver, &mockPE, &policy.Policy{ResolvedName: ""})
|
||||
if tc.expectedError != nil {
|
||||
if assert.Error(t, err) {
|
||||
@@ -175,6 +178,20 @@ func TestSignVerify(t *testing.T) {
|
||||
// setup an image with signed attestations
|
||||
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 {
|
||||
name string
|
||||
signTL bool
|
||||
@@ -185,9 +202,10 @@ func TestSignVerify(t *testing.T) {
|
||||
{name: "happy path", signTL: true, policyDir: PassNoTLPolicyDir},
|
||||
{name: "sign tl, verify no tl", signTL: true, policyDir: PassPolicyDir},
|
||||
{name: "no tl", signTL: false, policyDir: PassPolicyDir},
|
||||
{name: "mirror", signTL: true, 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", signTL: false, policyDir: PassMirrorPolicyDir, imageName: "mirror.org/library/test-image:test"},
|
||||
{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: "mirror with verification", signTL: false, policyDir: LocalKeysPolicy, imageName: "mirror.org/library/test-image:test"},
|
||||
}
|
||||
|
||||
attIdx, err := oci.IndexFromPath(test.UnsignedTestImage())
|
||||
@@ -196,7 +214,7 @@ func TestSignVerify(t *testing.T) {
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
opts := &attestation.SigningOptions{
|
||||
SkipTL: tc.signTL,
|
||||
SkipTL: !tc.signTL,
|
||||
}
|
||||
|
||||
signedManifests, err := SignStatements(ctx, attIdx.Index, signer, opts)
|
||||
@@ -218,6 +236,7 @@ func TestSignVerify(t *testing.T) {
|
||||
policyOpts := &policy.Options{
|
||||
LocalPolicyDir: tc.policyDir,
|
||||
DisableTUF: true,
|
||||
Debug: true,
|
||||
}
|
||||
results, err := Verify(ctx, spec, policyOpts)
|
||||
require.NoError(t, err)
|
||||
@@ -225,6 +244,9 @@ func TestSignVerify(t *testing.T) {
|
||||
assert.Equal(t, tc.expectedNonSuccess, results.Outcome)
|
||||
return
|
||||
}
|
||||
if results.Outcome == OutcomeFailure {
|
||||
t.Logf("Violations: %v", results.Violations)
|
||||
}
|
||||
assert.Equal(t, OutcomeSuccess, results.Outcome)
|
||||
platform, err := oci.ParsePlatform(LinuxAMD64)
|
||||
require.NoError(t, err)
|
||||
|
||||
Reference in New Issue
Block a user