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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 ®o.ResultSet{
|
||||
{
|
||||
Bindings: rego.Vars{},
|
||||
Expressions: []*rego.ExpressionValue{
|
||||
{
|
||||
Value: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
func AllowedResult() *Result {
|
||||
return &Result{
|
||||
Success: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ®oEvaluator{
|
||||
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
|
||||
|
||||
7
pkg/policy/testdata/mock-tuf-allow-canonical/doi/policy.rego
vendored
Normal file
7
pkg/policy/testdata/mock-tuf-allow-canonical/doi/policy.rego
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
package attest
|
||||
|
||||
import rego.v1
|
||||
|
||||
result := {
|
||||
"success": input.isCanonical,
|
||||
}
|
||||
16
pkg/policy/testdata/mock-tuf-allow-canonical/mapping.yaml
vendored
Normal file
16
pkg/policy/testdata/mock-tuf-allow-canonical/mapping.yaml
vendored
Normal 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: ""
|
||||
@@ -2,4 +2,6 @@ package attest
|
||||
|
||||
import rego.v1
|
||||
|
||||
allow := true
|
||||
result := {
|
||||
"success": true,
|
||||
}
|
||||
|
||||
@@ -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: ""
|
||||
|
||||
@@ -2,4 +2,6 @@ package attest
|
||||
|
||||
import rego.v1
|
||||
|
||||
allow := false
|
||||
result := {
|
||||
"success": false,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
48
test/testdata/local-policy-fail/doi/policy.rego
vendored
Normal file
48
test/testdata/local-policy-fail/doi/policy.rego
vendored
Normal 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",
|
||||
},
|
||||
}
|
||||
@@ -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: ""
|
||||
39
test/testdata/local-policy-pass/doi/policy.rego
vendored
Normal file
39
test/testdata/local-policy-pass/doi/policy.rego
vendored
Normal 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",
|
||||
},
|
||||
}
|
||||
11
test/testdata/local-policy-pass/mapping.yaml
vendored
Normal file
11
test/testdata/local-policy-pass/mapping.yaml
vendored
Normal 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
|
||||
58
test/testdata/local-policy/doi/data.yaml
vendored
58
test/testdata/local-policy/doi/data.yaml
vendored
@@ -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"
|
||||
49
test/testdata/local-policy/doi/policy.rego
vendored
49
test/testdata/local-policy/doi/policy.rego
vendored
@@ -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")
|
||||
}
|
||||
25
test/testdata/local-policy/doi/policy_test.rego
vendored
25
test/testdata/local-policy/doi/policy_test.rego
vendored
@@ -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
5
test/testdata/test-signing-key.pem
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
-----BEGIN EC PRIVATE KEY-----
|
||||
MHcCAQEEIKZEqmmd++eAY3bmPoBdY6nC2wLy4da2yeVZNKCp6Oj2oAoGCCqGSM49
|
||||
AwEHoUQDQgAEZmicqYSY38DprGr42jU0V3ND0ROjzSRH1+yjsxhh0bi52Hh/DuOh
|
||||
rSq2KJ5a09lW3ybnDjljowbkof0Y1i9Oow==
|
||||
-----END EC PRIVATE KEY-----
|
||||
Reference in New Issue
Block a user