Return VSA and rich errors from verification (#38)

* Start of richer results from verification

* Pull out VSA code from signing

* Expose attestation signing fns

* Add VSA test

* Notes for policy result

* Require separate policy for VSA creation

* Load test signing key from tests

* Return rich object from policy

* Add result object schema and fix tests

* Ensure example test runs

* Remove data.yaml files from mock policies

* Don't run example - TUF policy isn't compatible

* Add attestation to manifests for all subjects

* Ensure adding attestation doesn't touch statements

* Don't export sign function

* Remove attestations from VerificationResult

* Change bool to Outcome enum in result

* Use outputLayout directly

* Make clearer that Outcome strings are for VSA

* Return multiple SLSA levels from policy

* Fix unmarshalling of policy-id (#39)

* Rename function

* Rename policy.VerificationResult -> policy.Result

* Re-add test for canonical input

---------

Co-authored-by: James Carnegie <james.carnegie@docker.com>
Co-authored-by: James Carnegie <kipz@users.noreply.github.com>
This commit is contained in:
Jonny Stoten
2024-05-22 14:49:23 +01:00
committed by GitHub
parent 745eea09e8
commit 1a7897a052
33 changed files with 776 additions and 461 deletions

View File

@@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
@@ -82,7 +83,11 @@ func Setup(t *testing.T) (context.Context, dsse.SignerVerifier) {
}
func GetMockSigner(ctx context.Context) (dsse.SignerVerifier, error) {
return signerverifier.GenKeyPair()
priv, err := os.ReadFile(filepath.Join("..", "..", "test", "testdata", "test-signing-key.pem"))
if err != nil {
return nil, err
}
return signerverifier.LoadKeyPair(priv)
}
type AnnotatedStatement struct {
@@ -115,7 +120,7 @@ func ExtractAnnotatedStatements(path string, mediaType string) ([]*AnnotatedStat
var statements []*AnnotatedStatement
for _, mf := range mfs2.Manifests {
if mf.Annotations["vnd.docker.reference.type"] != "attestation-manifest" {
if mf.Annotations[attestation.DockerReferenceType] != "attestation-manifest" {
continue
}

View File

@@ -4,7 +4,6 @@ import (
"context"
"github.com/docker/attest/pkg/attest"
"github.com/docker/attest/pkg/attestation"
"github.com/docker/attest/pkg/mirror"
"github.com/docker/attest/pkg/oci"
"github.com/docker/attest/pkg/signerverifier"
@@ -30,16 +29,6 @@ func ExampleSign_remote() {
Replace: true, // replace unsigned intoto statements with signed intoto attestations, otherwise leave in place
}
// configure VSA options (optional)
slsaBuildLevel := "3"
slsaPolicyUri := "https://docker.com/attest/policy"
slsaVerifierId := "https://docker.com"
opts.VSAOptions = &attestation.VSAOptions{
BuildLevel: "SLSA_BUILD_LEVEL_" + slsaBuildLevel,
PolicyURI: slsaPolicyUri,
VerifierID: slsaVerifierId,
}
// load image index with unsigned attestation-manifests
ref := "docker/image-signer-verifier:latest"
att, err := oci.AttestationIndexFromRemote(ref)

View File

@@ -59,14 +59,16 @@ func ExampleVerify_remote() {
}
// verify attestations
policy, err := attest.Verify(context.Background(), opts, resolver)
result, err := attest.Verify(context.Background(), opts, resolver)
if err != nil {
panic(err) // failed policy or attestation signature verification
panic(err)
}
if policy {
fmt.Printf("policy passed: %v\n", policy)
return // passed policy
switch result.Outcome {
case attest.OutcomeSuccess:
fmt.Println("policy passed")
case attest.OutcomeNoPolicy:
fmt.Println("no policy for image")
case attest.OutcomeFailure:
fmt.Println("policy failed")
}
// no policy found for image
fmt.Printf("no policy for image")
}

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"github.com/docker/attest/pkg/attestation"
"github.com/docker/attest/pkg/oci"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/empty"
"github.com/google/go-containerregistry/pkg/v1/match"
@@ -26,45 +27,98 @@ func Sign(ctx context.Context, idx v1.ImageIndex, signer dsse.SignerVerifier, op
// sign every attestation layer in each manifest
for _, manifest := range attestationManifests {
attestationLayers, err := attestation.GetAttestationsFromImage(manifest.Attestation.Image)
if err != nil {
return nil, fmt.Errorf("failed to get attestations from image: %w", err)
}
signedLayers, err := signLayers(ctx, attestationLayers, signer)
if err != nil {
return nil, fmt.Errorf("failed to sign attestations: %w", err)
}
if opts.VSAOptions != nil {
newLayer, err := generateVSA(ctx, manifest, signer, opts)
if err != nil {
return nil, fmt.Errorf("failed to generate VSA: %w", err)
}
signedLayers = append(signedLayers, *newLayer)
}
newImg, err := addSignedLayers(signedLayers, manifest, opts)
idx, err = signLayersAndAddToIndex(ctx, idx, manifest.Attestation.Layers, manifest, signer, opts)
if err != nil {
return nil, fmt.Errorf("failed to add signed layers: %w", err)
}
newDesc, err := partial.Descriptor(newImg)
if err != nil {
return nil, fmt.Errorf("failed to get descriptor: %w", err)
}
cf, err := manifest.Attestation.Image.ConfigFile()
if err != nil {
return nil, fmt.Errorf("failed to get config file: %w", err)
}
newDesc.Platform = cf.Platform()
newDesc.MediaType = manifest.MediaType
newDesc.Annotations = manifest.Annotations
idx = mutate.RemoveManifests(idx, match.Digests(manifest.Digest))
idx = mutate.AppendManifests(idx, mutate.IndexAddendum{
Add: newImg,
Descriptor: *newDesc,
})
}
return idx, nil
}
func AddAttestation(ctx context.Context, idx v1.ImageIndex, statement *intoto.Statement, signer dsse.SignerVerifier) (v1.ImageIndex, error) {
if len(statement.Subject) == 0 {
return nil, fmt.Errorf("statement has no subjects")
}
subjectDigests := make(map[string]bool)
for _, subject := range statement.Subject {
subjectDigest := fmt.Sprintf("sha256:%s", subject.Digest["sha256"])
subjectDigests[subjectDigest] = true
}
attestationManifests, err := attestation.GetAttestationManifestsFromIndex(idx)
if err != nil {
return nil, fmt.Errorf("failed to get attestation manifests: %w", err)
}
updatedIndex := false
for _, manifest := range attestationManifests {
if subjectDigests[manifest.Annotations[oci.DockerReferenceDigest]] {
attestationLayers := []attestation.AttestationLayer{
{
Statement: statement,
MediaType: types.MediaType(intoto.PayloadType),
Annotations: map[string]string{
oci.InTotoPredicateType: statement.PredicateType,
},
},
}
// hard-coding replace to false here, because if it's true we will remove any unsigned statements, even unrelated ones
idx, err = signLayersAndAddToIndex(ctx, idx, attestationLayers, manifest, signer, &SigningOptions{Replace: false})
if err != nil {
return nil, fmt.Errorf("failed to add signed layers: %w", err)
}
updatedIndex = true
}
}
if !updatedIndex {
return nil, fmt.Errorf("no attestation manifest found for statement")
}
return idx, nil
}
func signLayersAndAddToIndex(
ctx context.Context,
idx v1.ImageIndex,
attestationLayers []attestation.AttestationLayer,
manifest attestation.AttestationManifest,
signer dsse.SignerVerifier,
opts *SigningOptions) (v1.ImageIndex, error) {
signedLayers, err := signLayers(ctx, attestationLayers, signer)
if err != nil {
return nil, fmt.Errorf("failed to sign attestations: %w", err)
}
newImg, err := addSignedLayers(signedLayers, manifest, opts)
if err != nil {
return nil, fmt.Errorf("failed to add signed layers: %w", err)
}
newDesc, err := partial.Descriptor(newImg)
if err != nil {
return nil, fmt.Errorf("failed to get descriptor: %w", err)
}
cf, err := manifest.Attestation.Image.ConfigFile()
if err != nil {
return nil, fmt.Errorf("failed to get config file: %w", err)
}
newDesc.Platform = cf.Platform()
if newDesc.Platform == nil {
newDesc.Platform = &v1.Platform{
Architecture: "unknown",
OS: "unknown",
}
}
newDesc.MediaType = manifest.MediaType
newDesc.Annotations = manifest.Annotations
idx = mutate.RemoveManifests(idx, match.Digests(manifest.Digest))
idx = mutate.AppendManifests(idx, mutate.IndexAddendum{
Add: newImg,
Descriptor: *newDesc,
})
return idx, nil
}
// signLayers signs each intoto attestation layer with the given signer
func signLayers(ctx context.Context, layers []attestation.AttestationLayer, signer dsse.SignerVerifier) ([]mutate.Addendum, error) {
var signedLayers []mutate.Addendum
@@ -77,11 +131,7 @@ func signLayers(ctx context.Context, layers []attestation.AttestationLayer, sign
layer.Annotations[InTotoReferenceLifecycleStage] = LifecycleStageExperimental
// sign the statement
payload, err := json.Marshal(layer.Statement)
if err != nil {
return nil, fmt.Errorf("failed to marshal statement: %w", err)
}
env, err := attestation.SignDSSE(ctx, payload, intoto.PayloadType, signer)
env, err := signInTotoStatement(ctx, layer.Statement, signer)
if err != nil {
return nil, fmt.Errorf("failed to sign statement: %w", err)
}
@@ -103,6 +153,18 @@ func signLayers(ctx context.Context, layers []attestation.AttestationLayer, sign
return signedLayers, nil
}
func signInTotoStatement(ctx context.Context, statement *intoto.Statement, signer dsse.SignerVerifier) (*attestation.Envelope, error) {
payload, err := json.Marshal(statement)
if err != nil {
return nil, fmt.Errorf("failed to marshal statement: %w", err)
}
env, err := attestation.SignDSSE(ctx, payload, intoto.PayloadType, signer)
if err != nil {
return nil, fmt.Errorf("failed to sign statement: %w", err)
}
return env, nil
}
// addSignedLayers adds signed layers to a new or existing attestation image
func addSignedLayers(signedLayers []mutate.Addendum, manifest attestation.AttestationManifest, opts *SigningOptions) (v1.Image, error) {
var err error

View File

@@ -2,6 +2,7 @@ package attest
import (
"encoding/json"
"fmt"
"path/filepath"
"testing"
@@ -18,12 +19,14 @@ import (
intoto "github.com/in-toto/in-toto-golang/in_toto"
v02 "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v0.2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var (
UnsignedTestImage = filepath.Join("..", "..", "test", "testdata", "unsigned-test-image")
NoProvenanceImage = filepath.Join("..", "..", "test", "testdata", "no-provenance-image")
LocalPolicyDir = filepath.Join("..", "..", "test", "testdata", "local-policy")
PassPolicyDir = filepath.Join("..", "..", "test", "testdata", "local-policy-pass")
FailPolicyDir = filepath.Join("..", "..", "test", "testdata", "local-policy-fail")
TestTempDir = "attest-sign-test"
)
@@ -38,31 +41,25 @@ func TestSignVerifyOCILayout(t *testing.T) {
replace bool
}{
{"signed replaced (does nothing)", UnsignedTestImage, 0, 6, true},
{"without replace", UnsignedTestImage, 4, 6, false},
{"signed replaced (does nothing)", UnsignedTestImage, 0, 4, true},
{"without replace", UnsignedTestImage, 4, 4, false},
// image without provenance doesn't fail
{"no provenance (replace)", NoProvenanceImage, 0, 4, true},
{"no provenance (no replace)", NoProvenanceImage, 2, 4, false},
{"no provenance (replace)", NoProvenanceImage, 0, 2, true},
{"no provenance (no replace)", NoProvenanceImage, 2, 2, false},
}
policyResolver := &policy.PolicyOptions{
LocalPolicyDir: LocalPolicyDir,
LocalPolicyDir: PassPolicyDir,
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
tempDir := test.CreateTempDir(t, "", TestTempDir)
outputLayout := tempDir
outputLayout := test.CreateTempDir(t, "", TestTempDir)
opts := &SigningOptions{
Replace: tc.replace,
VSAOptions: &attestation.VSAOptions{
BuildLevel: "SLSA_BUILD_LEVEL_3",
PolicyURI: "https://docker.com/attest/policy",
VerifierID: "https://docker.com",
},
}
attIdx, err := oci.AttestationIndexFromPath(tc.TestImage)
assert.NoError(t, err)
require.NoError(t, err)
signedIndex, err := Sign(ctx, attIdx.Index, signer, opts)
assert.NoError(t, err)
require.NoError(t, err)
// output signed attestations
idx := v1.ImageIndex(empty.Index)
@@ -75,25 +72,21 @@ func TestSignVerifyOCILayout(t *testing.T) {
},
})
_, err = layout.Write(outputLayout, idx)
assert.NoError(t, err)
require.NoError(t, err)
resolver := &oci.OCILayoutResolver{
Path: outputLayout,
Platform: "",
}
policy, err := Verify(ctx, policyResolver, resolver)
assert.NoError(t, err)
assert.Truef(t, policy, "Policy should have been found")
require.NoError(t, err)
assert.Equalf(t, OutcomeSuccess, policy.Outcome, "Policy should have been found")
mt, _ := attestation.DSSEMediaType(attestation.VSAPredicateType)
vsas, err := test.ExtractAnnotatedStatements(tempDir, mt)
assert.NoError(t, err)
assert.Equalf(t, len(vsas), 2, "expected %d vsa statement, got %d", 2, len(vsas))
var allEnvelopes []*test.AnnotatedStatement
for _, predicate := range []string{intoto.PredicateSPDX, v02.PredicateSLSAProvenance, attestation.VSAPredicateType} {
mt, _ := attestation.DSSEMediaType(predicate)
statements, err := test.ExtractAnnotatedStatements(tempDir, mt)
assert.NoError(t, err)
statements, err := test.ExtractAnnotatedStatements(outputLayout, mt)
require.NoError(t, err)
allEnvelopes = append(allEnvelopes, statements...)
for _, stmt := range statements {
@@ -102,13 +95,77 @@ func TestSignVerifyOCILayout(t *testing.T) {
}
}
assert.Equalf(t, tc.expectedAttestations, len(allEnvelopes), "expected %d attestations, got %d", tc.expectedAttestations, len(allEnvelopes))
statements, err := test.ExtractAnnotatedStatements(tempDir, intoto.PayloadType)
assert.NoError(t, err)
statements, err := test.ExtractAnnotatedStatements(outputLayout, intoto.PayloadType)
require.NoError(t, err)
assert.Equalf(t, tc.expectedStatements, len(statements), "expected %d statement, got %d", tc.expectedStatements, len(statements))
})
}
}
func TestAddAttestation(t *testing.T) {
ctx, signer := test.Setup(t)
expectedAttestations := 2
expectedStatements := 4
outputLayout := test.CreateTempDir(t, "", TestTempDir)
attIdx, err := oci.AttestationIndexFromPath(UnsignedTestImage)
require.NoError(t, err)
statementToAdd := &intoto.Statement{
StatementHeader: intoto.StatementHeader{
PredicateType: attestation.VSAPredicateType,
Type: intoto.StatementInTotoV01,
Subject: []intoto.Subject{
{
Name: attIdx.Name,
Digest: map[string]string{
"sha256": "da8b190665956ea07890a0273e2a9c96bfe291662f08e2860e868eef69c34620",
},
},
{
Name: attIdx.Name,
Digest: map[string]string{
"sha256": "7a76cec943853f9f7105b1976afa1bf7cd5bb6afc4e9d5852dd8da7cf81ae86e",
},
},
},
},
}
signedIndex, err := AddAttestation(ctx, attIdx.Index, statementToAdd, signer)
require.NoError(t, err)
// output signed attestations
idx := v1.ImageIndex(empty.Index)
idx = mutate.AppendManifests(idx, mutate.IndexAddendum{
Add: signedIndex,
Descriptor: v1.Descriptor{
Annotations: map[string]string{
oci.OciReferenceTarget: attIdx.Name,
},
},
})
_, err = layout.Write(outputLayout, idx)
require.NoError(t, err)
var allEnvelopes []*test.AnnotatedStatement
mt, _ := attestation.DSSEMediaType(attestation.VSAPredicateType)
statements, err := test.ExtractAnnotatedStatements(outputLayout, mt)
require.NoError(t, err)
allEnvelopes = append(allEnvelopes, statements...)
for _, stmt := range statements {
assert.Equalf(t, attestation.VSAPredicateType, stmt.Annotations[oci.InTotoPredicateType], "expected predicate-type annotation to be set to %s, got %s", attestation.VSAPredicateType, stmt.Annotations[oci.InTotoPredicateType])
assert.Equalf(t, LifecycleStageExperimental, stmt.Annotations[InTotoReferenceLifecycleStage], "expected reference lifecycle stage annotation to be set to %s, got %s", LifecycleStageExperimental, stmt.Annotations[InTotoReferenceLifecycleStage])
}
assert.Equalf(t, expectedAttestations, len(allEnvelopes), "expected %d attestations, got %d", expectedAttestations, len(allEnvelopes))
statements, err = test.ExtractAnnotatedStatements(outputLayout, intoto.PayloadType)
fmt.Printf("statements: %+v\n", statements)
require.NoError(t, err)
assert.Equalf(t, expectedStatements, len(statements), "expected %d statement, got %d", expectedStatements, len(statements))
}
func TestAddSignedLayerAnnotations(t *testing.T) {
testCases := []struct {
name string
@@ -147,7 +204,7 @@ func TestAddSignedLayerAnnotations(t *testing.T) {
},
}
newImg, err := addSignedLayers(signedLayers, manifest, opts)
assert.NoError(t, err)
require.NoError(t, err)
mf, _ := newImg.RawManifest()
type Annotations struct {
Annotations map[string]string `json:"annotations"`
@@ -157,7 +214,7 @@ func TestAddSignedLayerAnnotations(t *testing.T) {
}
l := &Layers{}
err = json.Unmarshal(mf, l)
assert.NoError(t, err)
require.NoError(t, err)
_, ok := l.Layers[0].Annotations["test"]
assert.Truef(t, ok, "missing annotations")
})

View File

@@ -1,7 +1,10 @@
package attest
import (
"github.com/docker/attest/pkg/attestation"
"fmt"
"github.com/docker/attest/pkg/policy"
intoto "github.com/in-toto/in-toto-golang/in_toto"
)
const (
@@ -10,6 +13,32 @@ const (
)
type SigningOptions struct {
Replace bool
VSAOptions *attestation.VSAOptions
Replace bool
}
type Outcome string
const (
OutcomeSuccess Outcome = "success"
OutcomeFailure Outcome = "failure"
OutcomeNoPolicy Outcome = "no_policy"
)
func (o Outcome) StringForVSA() (string, error) {
switch o {
case OutcomeSuccess:
return "PASSED", nil
case OutcomeFailure:
return "FAILED", nil
default:
return "", fmt.Errorf("unknown outcome: %s", o)
}
}
type VerificationResult struct {
Outcome Outcome
Policy *policy.Policy
Input *policy.PolicyInput
VSA *intoto.Statement
Violations []policy.Violation
}

View File

@@ -3,23 +3,95 @@ package attest
import (
"context"
"fmt"
"time"
"github.com/docker/attest/pkg/attestation"
"github.com/docker/attest/pkg/oci"
"github.com/docker/attest/pkg/policy"
intoto "github.com/in-toto/in-toto-golang/in_toto"
)
func VerifyAttestations(ctx context.Context, resolver oci.AttestationResolver, files []*policy.PolicyFile) error {
func Verify(ctx context.Context, opts *policy.PolicyOptions, resolver oci.AttestationResolver) (result *VerificationResult, err error) {
pctx, err := policy.ResolvePolicy(ctx, resolver, opts)
if err != nil {
return nil, fmt.Errorf("failed to resolve policy: %w", err)
}
if pctx == nil {
return &VerificationResult{
Outcome: OutcomeNoPolicy,
}, nil
}
result, err = VerifyAttestations(ctx, resolver, pctx)
if err != nil {
return nil, fmt.Errorf("failed to evaluate policy: %w", err)
}
return result, nil
}
func ToPolicyResult(p *policy.Policy, input *policy.PolicyInput, result *policy.Result) (*VerificationResult, error) {
dgst, err := oci.SplitDigest(input.Digest)
if err != nil {
return nil, fmt.Errorf("failed to split digest: %w", err)
}
subject := intoto.Subject{
Name: input.Purl,
Digest: *dgst,
}
resourceUri, err := attestation.ToVSAResourceURI(subject)
if err != nil {
return nil, fmt.Errorf("failed to create resource uri: %w", err)
}
var outcome Outcome
if result.Success {
outcome = OutcomeSuccess
} else {
outcome = OutcomeFailure
}
outcomeStr, err := outcome.StringForVSA()
if err != nil {
return nil, err
}
return &VerificationResult{
Policy: p,
Outcome: outcome,
Violations: result.Violations,
VSA: &intoto.Statement{
StatementHeader: intoto.StatementHeader{
PredicateType: attestation.VSAPredicateType,
Type: intoto.StatementInTotoV01,
Subject: result.Summary.Subjects,
},
Predicate: attestation.VSAPredicate{
Verifier: attestation.VSAVerifier{
ID: result.Summary.Verifier,
},
TimeVerified: time.Now().UTC().Format(time.RFC3339),
ResourceUri: resourceUri,
Policy: attestation.VSAPolicy{URI: result.Summary.PolicyURI},
VerificationResult: outcomeStr,
VerifiedLevels: result.Summary.SLSALevels,
},
},
}, nil
}
func VerifyAttestations(ctx context.Context, resolver oci.AttestationResolver, pctx *policy.Policy) (*VerificationResult, error) {
digest, err := resolver.ImageDigest(ctx)
if err != nil {
return fmt.Errorf("failed to get image digest: %w", err)
return nil, fmt.Errorf("failed to get image digest: %w", err)
}
name, err := resolver.ImageName(ctx)
if err != nil {
return fmt.Errorf("failed to get image name: %w", err)
return nil, fmt.Errorf("failed to get image name: %w", err)
}
purl, canonical, err := oci.RefToPURL(name, resolver.ImagePlatformStr())
if err != nil {
return fmt.Errorf("failed to convert ref to purl: %w", err)
return nil, fmt.Errorf("failed to convert ref to purl: %w", err)
}
input := &policy.PolicyInput{
Digest: digest,
@@ -29,30 +101,11 @@ func VerifyAttestations(ctx context.Context, resolver oci.AttestationResolver, f
evaluator, err := policy.GetPolicyEvaluator(ctx)
if err != nil {
return err
return nil, err
}
rs, err := evaluator.Evaluate(ctx, resolver, files, input)
result, err := evaluator.Evaluate(ctx, resolver, pctx, input)
if err != nil {
return fmt.Errorf("policy evaluation failed: %w", err)
return nil, fmt.Errorf("policy evaluation failed: %w", err)
}
if !rs.Allowed() {
return fmt.Errorf("policy evaluation failed: %s", fmt.Sprint(rs))
}
return nil
}
func Verify(ctx context.Context, opts *policy.PolicyOptions, resolver oci.AttestationResolver) (policyFound bool, err error) {
policyFiles, err := policy.ResolvePolicy(ctx, resolver, opts)
if err != nil {
return false, fmt.Errorf("failed to resolve policy: %w", err)
}
// no policy for image -> success
if policyFiles == nil {
return false, nil
}
// policy found -> verify
return true, VerifyAttestations(ctx, resolver, policyFiles)
return ToPolicyResult(pctx, input, result)
}

View File

@@ -8,11 +8,17 @@ import (
"path/filepath"
"testing"
"github.com/docker/attest/internal/test"
"github.com/docker/attest/pkg/attestation"
"github.com/docker/attest/pkg/oci"
"github.com/docker/attest/pkg/policy"
"github.com/open-policy-agent/opa/rego"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/empty"
"github.com/google/go-containerregistry/pkg/v1/layout"
"github.com/google/go-containerregistry/pkg/v1/mutate"
intoto "github.com/in-toto/in-toto-golang/in_toto"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var (
@@ -43,19 +49,135 @@ func TestVerifyAttestations(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
mockPE := policy.MockPolicyEvaluator{
EvaluateFunc: func(ctx context.Context, resolver oci.AttestationResolver, pfs []*policy.PolicyFile, input *policy.PolicyInput) (*rego.ResultSet, error) {
EvaluateFunc: func(ctx context.Context, resolver oci.AttestationResolver, pctx *policy.Policy, input *policy.PolicyInput) (*policy.Result, error) {
return policy.AllowedResult(), tc.policyEvaluationError
},
}
ctx := policy.WithPolicyEvaluator(context.Background(), &mockPE)
err = VerifyAttestations(ctx, resolver, nil)
_, err := VerifyAttestations(ctx, resolver, nil)
if tc.expectedError != nil {
assert.Error(t, err)
assert.Equal(t, tc.expectedError.Error(), err.Error())
if assert.Error(t, err) {
assert.Equal(t, tc.expectedError.Error(), err.Error())
}
} else {
assert.NoError(t, err)
}
})
}
}
func TestVSA(t *testing.T) {
ctx, signer := test.Setup(t)
ctx = policy.WithPolicyEvaluator(ctx, policy.NewRegoEvaluator(true))
// setup an image with signed attestations
outputLayout := test.CreateTempDir(t, "", TestTempDir)
opts := &SigningOptions{
Replace: true,
}
attIdx, err := oci.AttestationIndexFromPath(UnsignedTestImage)
assert.NoError(t, err)
signedIndex, err := Sign(ctx, attIdx.Index, signer, opts)
assert.NoError(t, err)
// output signed attestations
idx := v1.ImageIndex(empty.Index)
idx = mutate.AppendManifests(idx, mutate.IndexAddendum{
Add: signedIndex,
Descriptor: v1.Descriptor{
Annotations: map[string]string{
oci.OciReferenceTarget: attIdx.Name,
},
},
})
_, err = layout.Write(outputLayout, idx)
assert.NoError(t, err)
//verify (without vsa should fail)
resolver := &oci.OCILayoutResolver{
Path: outputLayout,
Platform: "linux/amd64",
}
// mocked vsa query should pass
policyOpts := &policy.PolicyOptions{
LocalPolicyDir: PassPolicyDir,
}
results, err := Verify(ctx, policyOpts, resolver)
require.NoError(t, err)
assert.Equal(t, OutcomeSuccess, results.Outcome)
assert.Empty(t, results.Violations)
assert.Equal(t, intoto.StatementInTotoV01, results.VSA.Type)
assert.Equal(t, attestation.VSAPredicateType, results.VSA.PredicateType)
assert.Len(t, results.VSA.Subject, 1)
require.IsType(t, attestation.VSAPredicate{}, results.VSA.Predicate)
attestationPredicate := results.VSA.Predicate.(attestation.VSAPredicate)
assert.Equal(t, "PASSED", attestationPredicate.VerificationResult)
assert.Equal(t, "docker-official-images", attestationPredicate.Verifier.ID)
assert.Equal(t, []string{"SLSA_BUILD_LEVEL_3"}, attestationPredicate.VerifiedLevels)
assert.Equal(t, "https://docker.com/official/policy/v0.1", attestationPredicate.Policy.URI)
}
func TestVerificationFailure(t *testing.T) {
ctx, signer := test.Setup(t)
ctx = policy.WithPolicyEvaluator(ctx, policy.NewRegoEvaluator(true))
// setup an image with signed attestations
outputLayout := test.CreateTempDir(t, "", TestTempDir)
opts := &SigningOptions{
Replace: true,
}
attIdx, err := oci.AttestationIndexFromPath(UnsignedTestImage)
assert.NoError(t, err)
signedIndex, err := Sign(ctx, attIdx.Index, signer, opts)
assert.NoError(t, err)
// output signed attestations
idx := v1.ImageIndex(empty.Index)
idx = mutate.AppendManifests(idx, mutate.IndexAddendum{
Add: signedIndex,
Descriptor: v1.Descriptor{
Annotations: map[string]string{
oci.OciReferenceTarget: attIdx.Name,
},
},
})
_, err = layout.Write(outputLayout, idx)
assert.NoError(t, err)
//verify (without vsa should fail)
resolver := &oci.OCILayoutResolver{
Path: outputLayout,
Platform: "linux/amd64",
}
// mocked vsa query should pass
policyOpts := &policy.PolicyOptions{
LocalPolicyDir: FailPolicyDir,
}
results, err := Verify(ctx, policyOpts, resolver)
require.NoError(t, err)
assert.Equal(t, OutcomeFailure, results.Outcome)
assert.Len(t, results.Violations, 1)
violation := results.Violations[0]
assert.Equal(t, "missing_attestation", violation.Type)
assert.Equal(t, "Attestation missing for subject", violation.Description)
assert.Nil(t, violation.Attestation)
assert.Equal(t, intoto.StatementInTotoV01, results.VSA.Type)
assert.Equal(t, attestation.VSAPredicateType, results.VSA.PredicateType)
assert.Len(t, results.VSA.Subject, 1)
require.IsType(t, attestation.VSAPredicate{}, results.VSA.Predicate)
attestationPredicate := results.VSA.Predicate.(attestation.VSAPredicate)
assert.Equal(t, "FAILED", attestationPredicate.VerificationResult)
assert.Equal(t, "docker-official-images", attestationPredicate.Verifier.ID)
assert.Equal(t, []string{"SLSA_BUILD_LEVEL_3"}, attestationPredicate.VerifiedLevels)
assert.Equal(t, "https://docker.com/official/policy/v0.1", attestationPredicate.Policy.URI)
}

View File

@@ -1,96 +0,0 @@
package attest
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/docker/attest/pkg/attestation"
"github.com/docker/attest/pkg/oci"
"github.com/google/go-containerregistry/pkg/v1/mutate"
"github.com/google/go-containerregistry/pkg/v1/static"
"github.com/google/go-containerregistry/pkg/v1/types"
intoto "github.com/in-toto/in-toto-golang/in_toto"
"github.com/secure-systems-lab/go-securesystemslib/dsse"
)
// generateVSA generates a VSA from the attestation manifest
// TODO: remove signing logic and move generateVSA to attestation/vsa.go
func generateVSA(ctx context.Context, manifest attestation.AttestationManifest, signer dsse.SignerVerifier, opts *SigningOptions) (*mutate.Addendum, error) {
if len(manifest.Attestation.Layers) == 0 {
return nil, fmt.Errorf("no attestations found to generate VSA from")
}
sub := manifest.Attestation.Layers[0].Statement.Subject[0]
stype := manifest.Attestation.Layers[0].Statement.Type
uri, err := attestation.ToVSAResourceURI(sub)
if err != nil {
return nil, fmt.Errorf("failed to generate VSA resource URI: %w", err)
}
inputs := make([]attestation.VSAInputAttestation, 0, len(manifest.Attestation.Layers))
for _, att := range manifest.Attestation.Layers {
mt, err := att.Layer.MediaType()
if err != nil {
return nil, fmt.Errorf("failed to get layer media type: %w", err)
}
if !strings.HasSuffix(string(mt), "+dsse") {
continue
}
dgst, err := att.Layer.Digest()
if err != nil {
return nil, fmt.Errorf("failed to get layer digest: %w", err)
}
inputs = append(inputs, attestation.VSAInputAttestation{
Digest: map[string]string{"sha256": dgst.Hex},
MediaType: string(mt),
})
}
vsaStatement := &intoto.Statement{
StatementHeader: intoto.StatementHeader{
PredicateType: attestation.VSAPredicateType,
Type: stype,
Subject: manifest.Attestation.Layers[0].Statement.Subject,
},
Predicate: attestation.VSAPredicate{
Verifier: attestation.VSAVerifier{
ID: opts.VSAOptions.VerifierID,
},
TimeVerified: time.Now().UTC().Format(time.RFC3339),
ResourceUri: uri,
Policy: attestation.VSAPolicy{URI: opts.VSAOptions.PolicyURI},
VerificationResult: "PASSED",
VerifiedLevels: []string{opts.VSAOptions.BuildLevel},
InputAttestations: inputs,
},
}
payload, err := json.Marshal(vsaStatement)
if err != nil {
return nil, fmt.Errorf("failed to marshal statement: %w", err)
}
env, err := attestation.SignDSSE(ctx, payload, intoto.PayloadType, signer)
if err != nil {
return nil, fmt.Errorf("failed to sign statement: %w", err)
}
mediaType, err := attestation.DSSEMediaType(vsaStatement.PredicateType)
if err != nil {
return nil, fmt.Errorf("failed to get DSSE media type: %w", err)
}
data, err := json.Marshal(env)
if err != nil {
return nil, fmt.Errorf("failed to marshal envelope: %w", err)
}
mt := types.MediaType(mediaType)
newLayer := static.NewLayer(data, mt)
ann := make(map[string]string)
ann[InTotoReferenceLifecycleStage] = LifecycleStageExperimental
ann[oci.InTotoPredicateType] = attestation.VSAPredicateType
withAnnotations := mutate.Addendum{
Layer: newLayer,
Annotations: ann,
}
return &withAnnotations, nil
}

View File

@@ -34,12 +34,6 @@ type VSAInputAttestation struct {
MediaType string `json:"mediaType"`
}
type VSAOptions struct {
BuildLevel string
PolicyURI string
VerifierID string
}
func ToVSAResourceURI(sub intoto.Subject) (string, error) {
//parse purl
purl, err := packageurl.FromString(sub.Name)

View File

@@ -14,6 +14,7 @@ import (
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/layout"
"github.com/google/go-containerregistry/pkg/v1/remote"
"github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/common"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/package-url/packageurl-go"
"github.com/pkg/errors"
@@ -71,7 +72,7 @@ func attestationManifestFromOCILayout(path string, platformStr string) (*Attesta
}
}
for _, mf := range mfs2.Manifests {
if mf.Annotations[DockerReferenceType] != AttestationManifestType {
if mf.Annotations[att.DockerReferenceType] != AttestationManifestType {
continue
}
@@ -338,8 +339,8 @@ func imageDigestForPlatform(ix *v1.IndexManifest, platform *v1.Platform) (string
func attestationDigestForDigest(ix *v1.IndexManifest, imageDigest string, attestType string) (string, error) {
for _, m := range ix.Manifests {
if v, ok := m.Annotations["vnd.docker.reference.type"]; ok && v == attestType {
if d, ok := m.Annotations["vnd.docker.reference.digest"]; ok && d == imageDigest {
if v, ok := m.Annotations[att.DockerReferenceType]; ok && v == attestType {
if d, ok := m.Annotations[DockerReferenceDigest]; ok && d == imageDigest {
return m.Digest.String(), nil
}
}
@@ -393,3 +394,13 @@ func RefToPURL(ref string, platform string) (string, bool, error) {
p := packageurl.NewPackageURL("docker", ns, name, version, qualifiers, "")
return p.ToString(), isCanonical, nil
}
func SplitDigest(digest string) (*common.DigestSet, error) {
parts := strings.SplitN(digest, ":", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("invalid digest %q", digest)
}
return &common.DigestSet{
parts[0]: parts[1],
}, nil
}

View File

@@ -12,7 +12,6 @@ import (
)
const (
DockerReferenceType = "vnd.docker.reference.type"
DockerReferenceDigest = "vnd.docker.reference.digest"
AttestationManifestType = "attestation-manifest"
InTotoPredicateType = "in-toto.io/predicate-type"

View File

@@ -5,7 +5,6 @@ import (
"fmt"
"github.com/docker/attest/pkg/oci"
"github.com/open-policy-agent/opa/rego"
)
type policyEvaluatorCtxKeyType struct{}
@@ -27,5 +26,5 @@ func GetPolicyEvaluator(ctx context.Context) (PolicyEvaluator, error) {
}
type PolicyEvaluator interface {
Evaluate(ctx context.Context, resolver oci.AttestationResolver, policy []*PolicyFile, input *PolicyInput) (*rego.ResultSet, error)
Evaluate(ctx context.Context, resolver oci.AttestationResolver, pctx *Policy, input *PolicyInput) (*Result, error)
}

View File

@@ -4,37 +4,29 @@ import (
"context"
"github.com/docker/attest/pkg/oci"
"github.com/open-policy-agent/opa/rego"
)
type MockPolicyEvaluator struct {
EvaluateFunc func(ctx context.Context, resolver oci.AttestationResolver, policy []*PolicyFile, input *PolicyInput) (*rego.ResultSet, error)
EvaluateFunc func(ctx context.Context, resolver oci.AttestationResolver, pctx *Policy, input *PolicyInput) (*Result, error)
}
func (pe *MockPolicyEvaluator) Evaluate(ctx context.Context, resolver oci.AttestationResolver, policy []*PolicyFile, input *PolicyInput) (*rego.ResultSet, error) {
func (pe *MockPolicyEvaluator) Evaluate(ctx context.Context, resolver oci.AttestationResolver, pctx *Policy, input *PolicyInput) (*Result, error) {
if pe.EvaluateFunc != nil {
return pe.EvaluateFunc(ctx, resolver, policy, input)
return pe.EvaluateFunc(ctx, resolver, pctx, input)
}
return AllowedResult(), nil
}
func GetMockPolicy() PolicyEvaluator {
return &MockPolicyEvaluator{
EvaluateFunc: func(ctx context.Context, resolver oci.AttestationResolver, pfs []*PolicyFile, input *PolicyInput) (*rego.ResultSet, error) {
EvaluateFunc: func(ctx context.Context, resolver oci.AttestationResolver, pctx *Policy, input *PolicyInput) (*Result, error) {
return AllowedResult(), nil
},
}
}
func AllowedResult() *rego.ResultSet {
return &rego.ResultSet{
{
Bindings: rego.Vars{},
Expressions: []*rego.ExpressionValue{
{
Value: true,
},
},
},
func AllowedResult() *Result {
return &Result{
Success: true,
}
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/distribution/reference"
"github.com/docker/attest/pkg/oci"
"github.com/docker/attest/pkg/tuf"
intoto "github.com/in-toto/in-toto-golang/in_toto"
goyaml "gopkg.in/yaml.v3"
)
@@ -20,6 +21,26 @@ const (
PolicyMappingFileName = "mapping.yaml"
)
type Summary struct {
Subjects []intoto.Subject `json:"subjects"`
SLSALevels []string `json:"slsa_levels"`
Verifier string `json:"verifier"`
PolicyURI string `json:"policy_uri"`
}
type Violation struct {
Type string `json:"type"`
Description string `json:"description"`
Attestation *intoto.Statement `json:"attestation"`
Details map[string]any `json:"details"`
}
type Result struct {
Success bool `json:"success"`
Violations []Violation `json:"violations"`
Summary Summary `json:"summary"`
}
type PolicyMappings struct {
Version string `json:"version"`
Kind string `json:"kind"`
@@ -39,7 +60,7 @@ type PolicyMappingFile struct {
}
type PolicyMirror struct {
PolicyId string `json:"policy-id"`
PolicyId string `yaml:"policy-id"`
Mirror MirrorSpec `json:"mirror"`
}
@@ -60,6 +81,11 @@ type PolicyOptions struct {
LocalPolicyDir string
}
type Policy struct {
InputFiles []*PolicyFile
Query string
}
type PolicyInput struct {
Digest string `json:"digest"`
Purl string `json:"purl"`
@@ -71,7 +97,7 @@ type PolicyFile struct {
Content []byte
}
func resolveLocalPolicy(opts *PolicyOptions, mapping *PolicyMapping) ([]*PolicyFile, error) {
func resolveLocalPolicy(opts *PolicyOptions, mapping *PolicyMapping) (*Policy, error) {
if opts.LocalPolicyDir == "" {
return nil, fmt.Errorf("local policy dir not set")
}
@@ -88,10 +114,13 @@ func resolveLocalPolicy(opts *PolicyOptions, mapping *PolicyMapping) ([]*PolicyF
Content: fileContents,
})
}
return files, nil
policy := &Policy{
InputFiles: files,
}
return policy, nil
}
func loadLocalMappings(opts *PolicyOptions) (*PolicyMappings, error) {
func LoadLocalMappings(opts *PolicyOptions) (*PolicyMappings, error) {
if opts.LocalPolicyDir == "" {
return nil, nil
}
@@ -108,7 +137,7 @@ func loadLocalMappings(opts *PolicyOptions) (*PolicyMappings, error) {
return mappings, nil
}
func resolveTufPolicy(opts *PolicyOptions, mapping *PolicyMapping) ([]*PolicyFile, error) {
func resolveTufPolicy(opts *PolicyOptions, mapping *PolicyMapping) (*Policy, error) {
files := make([]*PolicyFile, 0, len(mapping.Files))
for _, f := range mapping.Files {
filename := f.Path
@@ -121,7 +150,10 @@ func resolveTufPolicy(opts *PolicyOptions, mapping *PolicyMapping) ([]*PolicyFil
Content: fileContents,
})
}
return files, nil
policy := &Policy{
InputFiles: files,
}
return policy, nil
}
func loadTufMappings(tufClient tuf.TUFClient, localTargetsDir string) (*PolicyMappings, error) {
@@ -163,7 +195,7 @@ func findPolicyMatch(named reference.Named, mappings *PolicyMappings) (*PolicyMa
return nil, nil
}
func ResolvePolicy(ctx context.Context, resolver oci.AttestationResolver, opts *PolicyOptions) ([]*PolicyFile, error) {
func ResolvePolicy(ctx context.Context, resolver oci.AttestationResolver, opts *PolicyOptions) (*Policy, error) {
imageName, err := resolver.ImageName(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get image name: %w", err)
@@ -172,7 +204,7 @@ func ResolvePolicy(ctx context.Context, resolver oci.AttestationResolver, opts *
if err != nil {
return nil, fmt.Errorf("failed to parse image name: %w", err)
}
localMappings, err := loadLocalMappings(opts)
localMappings, err := LoadLocalMappings(opts)
if err != nil {
return nil, fmt.Errorf("failed to load local policy mappings: %w", err)
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/docker/attest/pkg/policy"
"github.com/docker/attest/pkg/tuf"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func loadAttestation(t *testing.T, path string) *attestation.Envelope {
@@ -32,61 +33,37 @@ func TestRegoEvaluator_Evaluate(t *testing.T) {
ctx, _ := test.Setup(t)
TestDataPath := filepath.Join("..", "..", "test", "testdata")
MockTufRepo := filepath.Join(TestDataPath, "local-policy")
ExampleAttestation := filepath.Join(TestDataPath, "example_attestation.json")
VSA := filepath.Join(TestDataPath, "vsa.json")
re := policy.NewRegoEvaluator(true)
defaultInput := &policy.PolicyInput{
Digest: "sha256:test-digest",
Purl: "test-purl",
IsCanonical: true,
}
defaultResolver := oci.MockResolver{
Envs: []*attestation.Envelope{loadAttestation(t, ExampleAttestation)},
}
vsaResolver := oci.MockResolver{
Envs: []*attestation.Envelope{loadAttestation(t, ExampleAttestation), loadAttestation(t, VSA)},
}
testCases := []struct {
repo string
expectSuccess bool
input *policy.PolicyInput
isCanonical bool
resolver oci.AttestationResolver
policy *policy.PolicyOptions
}{
{repo: "testdata/mock-tuf-allow", expectSuccess: true, input: defaultInput, resolver: defaultResolver},
{repo: "testdata/mock-tuf-deny", expectSuccess: false, input: defaultInput, resolver: defaultResolver},
{repo: "testdata/mock-tuf-verify-sig", expectSuccess: true, input: defaultInput, resolver: defaultResolver},
{repo: "testdata/mock-tuf-wrong-key", expectSuccess: false, input: defaultInput, resolver: defaultResolver},
{repo: MockTufRepo, expectSuccess: true, input: &policy.PolicyInput{
Digest: "sha256:da8b190665956ea07890a0273e2a9c96bfe291662f08e2860e868eef69c34620",
Purl: "pkg:docker/test-image?digest=sha256%da8b190665956ea07890a0273e2a9c96bfe291662f08e2860e868eef69c34620&platform=linux%2Famd64",
IsCanonical: true,
}, resolver: vsaResolver},
{repo: MockTufRepo, expectSuccess: true, input: &policy.PolicyInput{
Digest: "sha256:da8b190665956ea07890a0273e2a9c96bfe291662f08e2860e868eef69c34620",
Purl: "pkg:docker/test-image@test?platform=linux%2Famd64",
IsCanonical: false,
}, resolver: vsaResolver},
// not a doi
{repo: MockTufRepo, expectSuccess: false, input: defaultInput, resolver: vsaResolver, policy: &policy.PolicyOptions{
LocalPolicyDir: "testdata/mock-tuf-deny",
}},
// digest mismatch
{repo: MockTufRepo, expectSuccess: false, input: &policy.PolicyInput{
Digest: "sha256:test-digest-wrong",
Purl: "test-purl",
IsCanonical: false,
}, resolver: vsaResolver},
{repo: "testdata/mock-tuf-allow", expectSuccess: true, isCanonical: false, resolver: defaultResolver},
{repo: "testdata/mock-tuf-deny", expectSuccess: false, isCanonical: false, resolver: defaultResolver},
{repo: "testdata/mock-tuf-verify-sig", expectSuccess: true, isCanonical: false, resolver: defaultResolver},
{repo: "testdata/mock-tuf-wrong-key", expectSuccess: false, isCanonical: false, resolver: defaultResolver},
{repo: "testdata/mock-tuf-allow-canonical", expectSuccess: true, isCanonical: true, resolver: defaultResolver},
{repo: "testdata/mock-tuf-allow-canonical", expectSuccess: false, isCanonical: false, resolver: defaultResolver},
}
for _, tc := range testCases {
t.Run(tc.repo, func(t *testing.T) {
input := &policy.PolicyInput{
Digest: "sha256:test-digest",
Purl: "test-purl",
IsCanonical: tc.isCanonical,
}
tufClient := tuf.NewMockTufClient(tc.repo, test.CreateTempDir(t, "", "tuf-dest"))
if tc.policy == nil {
tc.policy = &policy.PolicyOptions{
@@ -95,17 +72,29 @@ func TestRegoEvaluator_Evaluate(t *testing.T) {
}
}
policyFiles, err := policy.ResolvePolicy(ctx, tc.resolver, tc.policy)
policy, err := policy.ResolvePolicy(ctx, tc.resolver, tc.policy)
assert.NoErrorf(t, err, "failed to resolve policy")
rs, err := re.Evaluate(ctx, tc.resolver, policyFiles, tc.input)
result, err := re.Evaluate(ctx, tc.resolver, policy, input)
require.NoErrorf(t, err, "Evaluate failed")
if tc.expectSuccess {
assert.NoErrorf(t, err, "Evaluate failed")
assert.True(t, rs.Allowed(), "Evaluate should have succeeded")
assert.True(t, result.Success, "Evaluate should have succeeded")
} else {
assert.False(t, rs.Allowed(), "Evaluate should have failed")
assert.False(t, result.Success, "Evaluate should have failed")
}
})
}
}
func TestLoadingMappings(t *testing.T) {
opts := &policy.PolicyOptions{
LocalPolicyDir: filepath.Join("testdata", "mock-tuf-allow"),
}
policyMappings, err := policy.LoadLocalMappings(opts)
require.NoError(t, err)
assert.Equal(t, len(policyMappings.Mirrors), 1)
for _, mirror := range policyMappings.Mirrors {
assert.Equal(t, "docker-official-images", mirror.PolicyId)
}
}

View File

@@ -23,17 +23,20 @@ import (
type regoEvaluator struct {
debug bool
query string
}
const (
DefaultQuery = "result := data.attest.result"
resultBinding = "result"
)
func NewRegoEvaluator(debug bool) PolicyEvaluator {
return &regoEvaluator{
debug: debug,
query: "data.attest.allow",
}
}
func (re *regoEvaluator) Evaluate(ctx context.Context, resolver oci.AttestationResolver, files []*PolicyFile, input *PolicyInput) (*rego.ResultSet, error) {
func (re *regoEvaluator) Evaluate(ctx context.Context, resolver oci.AttestationResolver, pctx *Policy, input *PolicyInput) (*Result, error) {
var regoOpts []func(*rego.Rego)
// Create a new in-memory store
@@ -45,7 +48,7 @@ func (re *regoEvaluator) Evaluate(ctx context.Context, resolver oci.AttestationR
return nil, err
}
for _, target := range files {
for _, target := range pctx.InputFiles {
// load yaml as data (no rego opt for this!?)
if filepath.Ext(target.Path) == ".yaml" {
yamlData, err := loadYAML(target.Path, target.Content)
@@ -74,12 +77,15 @@ func (re *regoEvaluator) Evaluate(ctx context.Context, resolver oci.AttestationR
rego.Dump(os.Stderr),
)
}
query := DefaultQuery
if pctx.Query != "" {
query = pctx.Query
}
regoOpts = append(regoOpts,
rego.Query(re.query),
rego.StrictBuiltinErrors(true),
rego.Query(query),
rego.Input(input),
rego.Store(store),
rego.GenerateJSON(jsonGenerator[Result]()),
)
for _, custom := range RegoFunctions(resolver) {
regoOpts = append(regoOpts, custom.Func)
@@ -87,11 +93,50 @@ func (re *regoEvaluator) Evaluate(ctx context.Context, resolver oci.AttestationR
r := rego.New(regoOpts...)
rs, err := r.Eval(ctx)
return &rs, err
if err != nil {
return nil, err
}
if len(rs) == 0 {
return nil, fmt.Errorf("no policy evaluation result")
}
binding, ok := rs[0].Bindings[resultBinding]
if !ok {
return nil, fmt.Errorf("failed to extract verification result")
}
result, ok := binding.(Result)
if !ok {
return nil, fmt.Errorf("failed to extract verification result")
}
return &result, nil
}
func jsonGenerator[T any]() func(t *ast.Term, ec *rego.EvalContext) (any, error) {
return func(t *ast.Term, ec *rego.EvalContext) (any, error) {
// TODO: this is horrible - we're converting the AST to JSON and then back to AST, then using ast.As to convert it to a struct
// We can't use ast.As directly because it fails if the AST contains a set
json, err := ast.JSON(t.Value)
if err != nil {
return nil, err
}
v, err := ast.InterfaceToValue(json)
if err != nil {
return nil, err
}
var result T
err = ast.As(v, &result)
if err != nil {
return nil, err
}
return result, nil
}
}
var dynamicObj = types.NewObject(nil, types.NewDynamicProperty(types.S, types.A))
var arrayObj = types.NewArray(nil, dynamicObj)
var setObj = types.NewSet(dynamicObj)
var verifyDecl = &ast.Builtin{
Name: "attestations.verify_envelope",
Decl: types.NewFunction(types.Args(dynamicObj, arrayObj), dynamicObj),
@@ -99,7 +144,7 @@ var verifyDecl = &ast.Builtin{
}
var attestDecl = &ast.Builtin{
Name: "attestations.attestation",
Decl: types.NewFunction(types.Args(types.S), dynamicObj),
Decl: types.NewFunction(types.Args(types.S), setObj),
Nondeterministic: true,
}
@@ -153,12 +198,13 @@ func fetchIntotoAttestations(resolver oci.AttestationResolver) func(rego.Builtin
values[i] = ast.NewTerm(value)
}
// Wrap the values in an ast.Array and convert it to an ast.Term.
array := ast.NewTerm(ast.NewArray(values...))
// Wrap the values in an ast.Set and convert it to an ast.Term.
set := ast.NewTerm(ast.NewSet(values...))
return array, nil
return set, nil
}
}
func verifyIntotoEnvelope(rCtx rego.BuiltinContext, envTerm, keysTerm *ast.Term) (*ast.Term, error) {
env := new(att.Envelope)
var keys att.Keys

View File

@@ -0,0 +1,7 @@
package attest
import rego.v1
result := {
"success": input.isCanonical,
}

View File

@@ -0,0 +1,16 @@
# map repos to policies
version: v1
kind: policy-mapping
policies:
- origin:
domain: docker.io
prefix: library/
id: docker-official-images
description: Docker Official Images
files:
- path: doi/policy.rego
mirrors:
- policy-id: docker-official-images
mirror:
domains: [localhost:5001, registry.local:5000]
prefix: ""

View File

@@ -2,4 +2,6 @@ package attest
import rego.v1
allow := true
result := {
"success": true,
}

View File

@@ -9,3 +9,8 @@ policies:
description: Docker Official Images
files:
- path: doi/policy.rego
mirrors:
- policy-id: docker-official-images
mirror:
domains: [localhost:5001, registry.local:5000]
prefix: ""

View File

@@ -2,4 +2,6 @@ package attest
import rego.v1
allow := false
result := {
"success": false,
}

View File

@@ -3,13 +3,17 @@ package attest
import rego.v1
keys := [{
"id": "a0c296026645799b2a297913878e81b0aefff2a0c301e97232f717e14402f3e4",
"key": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEgH23D1i2+ZIOtVjmfB7iFvX8AhVN\n9CPJ4ie9axw+WRHozGnRy99U2dRge3zueBBg2MweF0zrToXGig2v3YOrdw==\n-----END PUBLIC KEY-----",
"from": "2023-12-15T14:00:00Z",
"to": null
"id": "a0c296026645799b2a297913878e81b0aefff2a0c301e97232f717e14402f3e4",
"key": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEgH23D1i2+ZIOtVjmfB7iFvX8AhVN\n9CPJ4ie9axw+WRHozGnRy99U2dRge3zueBBg2MweF0zrToXGig2v3YOrdw==\n-----END PUBLIC KEY-----",
"from": "2023-12-15T14:00:00Z",
"to": null
}]
allow if {
success if {
some env in attestations.attestation("foo")
statement := attestations.verify_envelope(env, keys)
}
result := {
"success": success
}

View File

@@ -2,18 +2,20 @@ package attest
import rego.v1
keys := {
"a0c296026645799b2a297913878e81b0aefff2a0c301e97232f717e14402f3e4": {
"id": "a0c296026645799b2a297913878e81b0aefff2a0c301e97232f717e14402f3e4",
"key": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEHyZpSgzvqFqNv7f3x7865OS38rAb\nQMcff55zM2UH/KR3Pr84a8QsGDNgaNGzJQJWjtMSgfV8WnNoffNK+svFNg==\n-----END PUBLIC KEY-----",
"from": "2023-12-15T14:00:00Z",
"to": null,
}
}
keys := [{
"id": "a0c296026645799b2a297913878e81b0aefff2a0c301e97232f717e14402f3e4",
"key": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEHyZpSgzvqFqNv7f3x7865OS38rAb\nQMcff55zM2UH/KR3Pr84a8QsGDNgaNGzJQJWjtMSgfV8WnNoffNK+svFNg==\n-----END PUBLIC KEY-----",
"from": "2023-12-15T14:00:00Z",
"to": null,
}]
allow if {
default success := false
success if {
some env in attestations.attestation("foo")
statement := attestations.verify_envelope(env, keys)
}
allow := true
result := {
"success": success
}

View File

@@ -6,6 +6,8 @@ import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/pem"
"fmt"
"github.com/docker/attest/internal/util"
@@ -45,6 +47,32 @@ func (s *ECDSA256_SignerVerifier) Verify(ctx context.Context, data []byte, sig [
return nil
}
func LoadKeyPair(priv []byte) (dsse.SignerVerifier, error) {
privateKey, err := parsePriv(priv)
if err != nil {
return nil, err
}
return &ECDSA256_SignerVerifier{
Signer: privateKey,
}, nil
}
func parsePriv(privkeyBytes []byte) (*ecdsa.PrivateKey, error) {
p, _ := pem.Decode(privkeyBytes)
if p == nil {
return nil, fmt.Errorf("privkey file does not contain any PEM data")
}
if p.Type != "EC PRIVATE KEY" {
return nil, fmt.Errorf("privkey file does not contain a priavte key")
}
privKey, err := x509.ParseECPrivateKey(p.Bytes)
if err != nil {
return nil, fmt.Errorf("error failed to parse public key: %w", err)
}
return privKey, nil
}
func GenKeyPair() (dsse.SignerVerifier, error) {
signer, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {

View File

@@ -0,0 +1,48 @@
package attest
import rego.v1
keys := [{
"id": "6b241993defaba26558c64f94a94303ce860e7ad9163d801495c91cf57197c75",
"key": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEZmicqYSY38DprGr42jU0V3ND0ROj\nzSRH1+yjsxhh0bi52Hh/DuOhrSq2KJ5a09lW3ybnDjljowbkof0Y1i9Oow==\n-----END PUBLIC KEY-----",
"from": "2023-12-15T14:00:00Z",
"to": null,
# this key is still active
"status": "active",
"signing-format": "dssev1",
}]
atts := union({
attestations.attestation("https://slsa.dev/provenance/v0.2"),
attestations.attestation("https://spdx.dev/Document"),
})
statements contains s if {
some att in atts
s := attestations.verify_envelope(att, keys)
}
subjects contains subject if {
some statement in statements
some subject in statement.subject
}
violations contains v if {
v := {
"type": "missing_attestation",
"description": "Attestation missing for subject",
"attestation": null,
"details": {},
}
}
result := {
"success": false,
"violations": violations,
"summary": {
"subjects": subjects,
"slsa_levels": ["SLSA_BUILD_LEVEL_3"],
"verifier": "docker-official-images",
"policy_uri": "https://docker.com/official/policy/v0.1",
},
}

View File

@@ -8,11 +8,4 @@ policies:
id: test-images
description: Local test images
files:
- path: doi/data.yaml
- path: doi/policy.rego
mirrors:
- policy-id: test-images
mirror:
domains: [localhost:5001]
prefix: ""

View File

@@ -0,0 +1,39 @@
package attest
import rego.v1
keys := [{
"id": "6b241993defaba26558c64f94a94303ce860e7ad9163d801495c91cf57197c75",
"key": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEZmicqYSY38DprGr42jU0V3ND0ROj\nzSRH1+yjsxhh0bi52Hh/DuOhrSq2KJ5a09lW3ybnDjljowbkof0Y1i9Oow==\n-----END PUBLIC KEY-----",
"from": "2023-12-15T14:00:00Z",
"to": null,
# this key is still active
"status": "active",
"signing-format": "dssev1",
}]
atts := union({
attestations.attestation("https://slsa.dev/provenance/v0.2"),
attestations.attestation("https://spdx.dev/Document"),
})
statements contains s if {
some att in atts
s := attestations.verify_envelope(att, keys)
}
subjects contains subject if {
some statement in statements
some subject in statement.subject
}
result := {
"success": true,
"violations": set(),
"summary": {
"subjects": subjects,
"slsa_levels": ["SLSA_BUILD_LEVEL_3"],
"verifier": "docker-official-images",
"policy_uri": "https://docker.com/official/policy/v0.1",
},
}

View File

@@ -0,0 +1,11 @@
# map repos to policies
version: v1
kind: policy-mapping
policies:
- origin:
domain: docker.io
prefix: library/
id: test-images
description: Local test images
files:
- path: doi/policy.rego

View File

@@ -1,58 +0,0 @@
config:
doi:
keys:
- id: "f6a29392b1c08891ff456100aa448b4f6bf9c315850e11cc0883fe9c3c4412db"
key: |
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE+XOm2uWjLJhpsJtHCFdGic26suOy
mCl2pBgCof+AHGFZFca40JL833OT+nRSZJRMPKBGibWqsjFrLdRCkOB7bA==
-----END PUBLIC KEY-----
from: "2024-01-01T00:00:00Z"
to: "2024-01-15T12:00:00Z"
# this key was rotated at a planned time
status: "rotated"
signing-format: "dssev1"
- id: "e6f4c70fbba21cbcac44915fff53fd2fdf90dd8849445795fe58014c2b5f8c64"
key: |
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEZSkTE3si/JkRbuLjaYraS3//YBnX
8KtEcgdYKZQPl2DnSl4gPsu3KiVeEBWp5GK06IoZlcBAL3NF0OsUUP+yVg==
-----END PUBLIC KEY-----
from: "2024-01-15T12:00:00Z"
to: "2024-01-15T14:00:00Z"
# this key was leaked at a known time, so it revoked from that time
# this behaves the same way as "rotated" but might give another failure message
status: "revoked"
signing-format: "dssev1"
- id: "d45980c5cf39a5e1bab9febe3f16c1c0820b97a8fd061b0064e54b0826e856e4"
key: |
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEafssq2x1EDQcKDZhuSrCOxWWl5D4
JBa9iDJYDnLZp9kPKvv4RnD4rz7Ucfmd0l/zzM45qT29fSBTlguKmnOA8A==
-----END PUBLIC KEY-----
# this key was leaked at an unknown time, so it's completely distrusted
distrust: true
status: "revoked"
signing-format: "dssev1"
- id: "a0c296026645799b2a297913878e81b0aefff2a0c301e97232f717e14402f3e4"
key: |
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEgH23D1i2+ZIOtVjmfB7iFvX8AhVN
9CPJ4ie9axw+WRHozGnRy99U2dRge3zueBBg2MweF0zrToXGig2v3YOrdw==
-----END PUBLIC KEY-----
from: "2023-12-15T14:00:00Z"
to: null
# this key is still active
status: "active"
signing-format: "dssev1"
- id: "b281835e00059de24fb06bd6db06eb0e4a33d7bd7210d7027c209f14b19e812a"
key: |
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEgE4Jz6FrLc3lp/YRlbuwOjK4n6ac
jVkSDAmFhi3Ir2Jy+cKeEB7iRPcLvBy9qoMZ9E93m1NdWY6KtDo+Qi52Rg==
-----END PUBLIC KEY-----
from: "2024-01-15T14:00:00Z"
to: null
# this key is still active
status: "active"
signing-format: "dssev1"

View File

@@ -1,49 +0,0 @@
package attest
import rego.v1
import data.config
splitDigest := split(input.digest, ":")
digestType := splitDigest[0]
digest := splitDigest[1]
allow if {
some env in attestations.attestation("https://slsa.dev/verification_summary/v0.1")
some statement in verified_statements(config.doi.keys, env)
}
verified_statements(keys, env) := statements if {
statements := {statement |
statement := attestations.verify_envelope(env, keys)
some subject in statement.subject
valid_subject(subject)
}
}
valid_subject(sub) if {
print("valid_subject")
print("sub.digest[digestType]:", sub.digest[digestType])
print("digest", digest)
sub.digest[digestType] == digest
print("digest matches")
valid_subject_name(sub.name)
}
valid_subject_name(name) if {
input.isCanonical
print("is canonical, ignoring name")
}
valid_subject_name(name) if {
not input.isCanonical
print("valid_subject_name...")
print("name:", name)
print("input.purl:", input.purl)
name == input.purl
print("name match")
}

View File

@@ -1,25 +0,0 @@
package attest
import rego.v1
config := {"keys": []}
envs := [{"env": "test"}]
purl := "pkg:docker/library/alpine:1.2.3"
statement := {"subject": [{"name": purl, "digest": {"sha256": "dea014f47cd49d694d3a68564eb9e6ae38a7ee9624fd52ec05ccbef3f3fab8a0"}}]}
input_digest := "sha256:dea014f47cd49d694d3a68564eb9e6ae38a7ee9624fd52ec05ccbef3f3fab8a0"
test_with_mock_data if {
allow with attestations.attestation as envs
with attestations.verify_envelope as statement
with input.digest as input_digest
with input.purl as purl
with input.canonical as false
}
layout_digest := "sha256:da8b190665956ea07890a0273e2a9c96bfe291662f08e2860e868eef69c34620"
outout_purl := "pkg:docker/test-image@test?platform=linux%2Famd64"
test_with_signed_oci_layout if {
allow with input.digest as layout_digest
with input.purl as outout_purl
with input.canonical as false
}

5
test/testdata/test-signing-key.pem vendored Normal file
View File

@@ -0,0 +1,5 @@
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIKZEqmmd++eAY3bmPoBdY6nC2wLy4da2yeVZNKCp6Oj2oAoGCCqGSM49
AwEHoUQDQgAEZmicqYSY38DprGr42jU0V3ND0ROjzSRH1+yjsxhh0bi52Hh/DuOh
rSq2KJ5a09lW3ybnDjljowbkof0Y1i9Oow==
-----END EC PRIVATE KEY-----