diff --git a/internal/test/test.go b/internal/test/test.go index 7362067..8e07025 100644 --- a/internal/test/test.go +++ b/internal/test/test.go @@ -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 +} diff --git a/oci/oci.go b/oci/oci.go index 3d52ecd..436526f 100644 --- a/oci/oci.go +++ b/oci/oci.go @@ -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 } diff --git a/policy/policy.go b/policy/policy.go index 039a194..86c7310 100644 --- a/policy/policy.go +++ b/policy/policy.go @@ -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 } diff --git a/policy/policy_test.go b/policy/policy_test.go index 2e1e034..d209f09 100644 --- a/policy/policy_test.go +++ b/policy/policy_test.go @@ -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 != "" { diff --git a/test/testdata/local-policy-real/.gitignore b/test/testdata/local-policy-real/.gitignore new file mode 100644 index 0000000..5b6b072 --- /dev/null +++ b/test/testdata/local-policy-real/.gitignore @@ -0,0 +1 @@ +config.yaml diff --git a/test/testdata/local-policy-real/mapping.yaml b/test/testdata/local-policy-real/mapping.yaml new file mode 100644 index 0000000..2dacf8a --- /dev/null +++ b/test/testdata/local-policy-real/mapping.yaml @@ -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 diff --git a/test/testdata/local-policy-real/policy.rego b/test/testdata/local-policy-real/policy.rego new file mode 100644 index 0000000..ceb6eb8 --- /dev/null +++ b/test/testdata/local-policy-real/policy.rego @@ -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", + }, +} diff --git a/types.go b/types.go index 21fcd39..3824208 100644 --- a/types.go +++ b/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 +} diff --git a/verify.go b/verify.go index f10918c..5b231d8 100644 --- a/verify.go +++ b/verify.go @@ -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, + } +} diff --git a/verify_test.go b/verify_test.go index 48c1e7e..ce52f5c 100644 --- a/verify_test.go +++ b/verify_test.go @@ -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)