From d2a8348ae8889836cf1939a1ee780ee0297f096e Mon Sep 17 00:00:00 2001 From: mrjoelkamp Date: Wed, 14 Aug 2024 09:16:24 -0500 Subject: [PATCH 1/9] feat: generate vsa policy value from file --- pkg/attest/verify.go | 8 ++++- pkg/attest/verify_test.go | 6 ++-- pkg/attestation/vsa.go | 3 +- pkg/config/config.go | 4 +-- pkg/mirror/targets.go | 8 ++--- pkg/policy/policy.go | 31 +++++++++++++++-- pkg/policy/types.go | 2 ++ pkg/tuf/example_registry_test.go | 5 +-- pkg/tuf/mock.go | 12 +++---- pkg/tuf/registry.go | 4 +-- pkg/tuf/registry_test.go | 2 +- pkg/tuf/tuf.go | 60 ++++++++++++++++++++++++++------ pkg/tuf/tuf_test.go | 4 +-- pkg/tuf/version.go | 4 +-- 14 files changed, 114 insertions(+), 39 deletions(-) diff --git a/pkg/attest/verify.go b/pkg/attest/verify.go index c1d6540..1b29bcd 100644 --- a/pkg/attest/verify.go +++ b/pkg/attest/verify.go @@ -86,6 +86,12 @@ func toVerificationResult(p *policy.Policy, input *policy.Input, result *policy. return nil, err } + vsaPolicy := attestation.VSAPolicy{URI: result.Summary.PolicyURI, Digest: p.Digest} + // if the policy URI is not set by the result summary then use the policy's target URI + if vsaPolicy.URI == "" { + vsaPolicy = attestation.VSAPolicy{URI: p.URI, Digest: p.Digest} + } + return &VerificationResult{ Policy: p, Outcome: outcome, @@ -103,7 +109,7 @@ func toVerificationResult(p *policy.Policy, input *policy.Input, result *policy. }, TimeVerified: time.Now().UTC().Format(time.RFC3339), ResourceURI: resourceURI, - Policy: attestation.VSAPolicy{URI: result.Summary.PolicyURI}, + Policy: vsaPolicy, VerificationResult: outcomeStr, VerifiedLevels: result.Summary.SLSALevels, }, diff --git a/pkg/attest/verify_test.go b/pkg/attest/verify_test.go index abf8048..2a493f8 100644 --- a/pkg/attest/verify_test.go +++ b/pkg/attest/verify_test.go @@ -112,7 +112,8 @@ func TestVSA(t *testing.T) { 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) + assert.Equal(t, PassPolicyDir+"/policy.rego", attestationPredicate.Policy.URI) + assert.Equal(t, map[string]string{"sha256": "d71d6b8f49fcba1295b16f5394dd5863a14e4277eb663d66d8c48e392509afe0"}, attestationPredicate.Policy.Digest) } func TestVerificationFailure(t *testing.T) { @@ -162,7 +163,8 @@ func TestVerificationFailure(t *testing.T) { 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) + assert.Equal(t, FailPolicyDir+"/policy.rego", attestationPredicate.Policy.URI) + assert.Equal(t, map[string]string{"sha256": "ad045e1bd7cd602d90196acf68f2c57d7b51565d59e6e30e30d94ae86aa16201"}, attestationPredicate.Policy.Digest) } func TestSignVerify(t *testing.T) { diff --git a/pkg/attestation/vsa.go b/pkg/attestation/vsa.go index 0e5804e..82f7022 100644 --- a/pkg/attestation/vsa.go +++ b/pkg/attestation/vsa.go @@ -26,7 +26,8 @@ type VSAVerifier struct { } type VSAPolicy struct { - URI string `json:"uri"` + URI string `json:"uri"` + Digest map[string]string `json:"digest"` } type VSAInputAttestation struct { diff --git a/pkg/config/config.go b/pkg/config/config.go index e724c2d..ae12702 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -36,13 +36,13 @@ func LoadTUFMappings(tufClient tuf.Downloader, localTargetsDir string) (*PolicyM return nil, fmt.Errorf("tuf client not set") } filename := MappingFilename - _, fileContents, err := tufClient.DownloadTarget(filename, filepath.Join(localTargetsDir, filename)) + file, err := tufClient.DownloadTarget(filename, filepath.Join(localTargetsDir, filename)) if err != nil { return nil, fmt.Errorf("failed to download policy mapping file %s: %w", filename, err) } mappings := &policyMappingsFile{} - err = yaml.Unmarshal(fileContents, mappings) + err = yaml.Unmarshal(file.Data, mappings) if err != nil { return nil, fmt.Errorf("failed to unmarshal policy mapping file %s: %w", filename, err) } diff --git a/pkg/mirror/targets.go b/pkg/mirror/targets.go index df09e4c..e738d95 100644 --- a/pkg/mirror/targets.go +++ b/pkg/mirror/targets.go @@ -23,7 +23,7 @@ func (m *TUFMirror) GetTUFTargetMirrors() ([]*Image, error) { targets := md.Targets[metadata.TARGETS].Signed.Targets for _, t := range targets { // download target file - _, data, err := m.TUFClient.DownloadTarget(t.Path, filepath.Join(m.tufPath, "download")) + file, err := m.TUFClient.DownloadTarget(t.Path, filepath.Join(m.tufPath, "download")) if err != nil { return nil, fmt.Errorf("failed to download target %s: %w", t.Path, err) } @@ -38,7 +38,7 @@ func (m *TUFMirror) GetTUFTargetMirrors() ([]*Image, error) { } name := hash.String() + "." + t.Path ann := map[string]string{tufFileAnnotation: name} - layer := mutate.Addendum{Layer: static.NewLayer(data, tufTargetMediaType), Annotations: ann} + layer := mutate.Addendum{Layer: static.NewLayer(file.Data, tufTargetMediaType), Annotations: ann} img, err = mutate.Append(img, layer) if err != nil { return nil, fmt.Errorf("failed to append role layer to image: %w", err) @@ -69,7 +69,7 @@ func (m *TUFMirror) GetDelegatedTargetMirrors() ([]*Index, error) { // for each target file, create an image with the target file as a layer for _, target := range roleMeta.Signed.Targets { // download target file - _, data, err := m.TUFClient.DownloadTarget(target.Path, filepath.Join(m.tufPath, "download")) + file, err := m.TUFClient.DownloadTarget(target.Path, filepath.Join(m.tufPath, "download")) if err != nil { return nil, fmt.Errorf("failed to download target %s: %w", target.Path, err) } @@ -89,7 +89,7 @@ func (m *TUFMirror) GetDelegatedTargetMirrors() ([]*Index, error) { } name := hash.String() + "." + filename ann := map[string]string{tufFileAnnotation: name} - layer := mutate.Addendum{Layer: static.NewLayer(data, tufTargetMediaType), Annotations: ann} + layer := mutate.Addendum{Layer: static.NewLayer(file.Data, tufTargetMediaType), Annotations: ann} img, err = mutate.Append(img, layer) if err != nil { return nil, fmt.Errorf("failed to append role layer to image: %w", err) diff --git a/pkg/policy/policy.go b/pkg/policy/policy.go index cfab255..fa569cd 100644 --- a/pkg/policy/policy.go +++ b/pkg/policy/policy.go @@ -8,6 +8,7 @@ import ( "path/filepath" "github.com/distribution/reference" + "github.com/docker/attest/internal/util" "github.com/docker/attest/pkg/attestation" "github.com/docker/attest/pkg/config" "github.com/docker/attest/pkg/oci" @@ -17,6 +18,8 @@ func resolveLocalPolicy(opts *Options, mapping *config.PolicyMapping, imageName if opts.LocalPolicyDir == "" { return nil, fmt.Errorf("local policy dir not set") } + var URI string + var digest map[string]string files := make([]*File, 0, len(mapping.Files)) for _, f := range mapping.Files { filename := f.Path @@ -29,10 +32,21 @@ func resolveLocalPolicy(opts *Options, mapping *config.PolicyMapping, imageName Path: filename, Content: fileContents, }) + // if the file is a policy file, store the URI and digest + if filepath.Ext(filename) == ".rego" { + // TODO: support multiple rego files, need some way to identify the main policy file + if URI != "" { + return nil, fmt.Errorf("multiple policy files found in policy mapping") + } + URI = filePath + digest = map[string]string{"sha256": util.SHA256Hex(fileContents)} + } } policy := &Policy{ InputFiles: files, Mapping: mapping, + URI: URI, + Digest: digest, } if imageName != matchedName { policy.ResolvedName = matchedName @@ -41,21 +55,34 @@ func resolveLocalPolicy(opts *Options, mapping *config.PolicyMapping, imageName } func resolveTUFPolicy(opts *Options, mapping *config.PolicyMapping, imageName string, matchedName string) (*Policy, error) { + var URI string + var digest map[string]string files := make([]*File, 0, len(mapping.Files)) for _, f := range mapping.Files { filename := f.Path - _, fileContents, err := opts.TUFClient.DownloadTarget(filename, filepath.Join(opts.LocalTargetsDir, filename)) + file, err := opts.TUFClient.DownloadTarget(filename, filepath.Join(opts.LocalTargetsDir, filename)) if err != nil { return nil, fmt.Errorf("failed to download policy file %s: %w", filename, err) } files = append(files, &File{ Path: filename, - Content: fileContents, + Content: file.Data, }) + // if the file is a policy file, store the URI and digest + if filepath.Ext(filename) == ".rego" { + // TODO: support multiple rego files, need some way to identify the main policy file + if URI != "" { + return nil, fmt.Errorf("multiple policy files found in policy mapping") + } + URI = file.TargetURI + digest = map[string]string{"sha256": file.Digest} + } } policy := &Policy{ InputFiles: files, Mapping: mapping, + URI: URI, + Digest: digest, } if imageName != matchedName { policy.ResolvedName = matchedName diff --git a/pkg/policy/types.go b/pkg/policy/types.go index 54cc993..a122264 100644 --- a/pkg/policy/types.go +++ b/pkg/policy/types.go @@ -40,6 +40,8 @@ type Policy struct { Query string Mapping *config.PolicyMapping ResolvedName string + URI string + Digest map[string]string } type Input struct { diff --git a/pkg/tuf/example_registry_test.go b/pkg/tuf/example_registry_test.go index bf9ddbc..b6e4449 100644 --- a/pkg/tuf/example_registry_test.go +++ b/pkg/tuf/example_registry_test.go @@ -28,16 +28,13 @@ func ExampleNewClient_registry() { // get trusted tuf metadata trustedMetadata := registryClient.GetMetadata() - if err != nil { - panic(err) - } // top-level target files targets := trustedMetadata.Targets[metadata.TARGETS].Signed.Targets for _, t := range targets { // download target files - _, _, err := registryClient.DownloadTarget(t.Path, filepath.Join(tufOutputPath, "download")) + _, err := registryClient.DownloadTarget(t.Path, filepath.Join(tufOutputPath, "download")) if err != nil { panic(err) } diff --git a/pkg/tuf/mock.go b/pkg/tuf/mock.go index ae79545..5189483 100644 --- a/pkg/tuf/mock.go +++ b/pkg/tuf/mock.go @@ -24,10 +24,10 @@ func NewMockTufClient(srcPath string, dstPath string) *MockTufClient { } } -func (dc *MockTufClient) DownloadTarget(target string, filePath string) (actualFilePath string, data []byte, err error) { +func (dc *MockTufClient) DownloadTarget(target string, filePath string) (file *TargetFile, err error) { src, err := os.Open(filepath.Join(dc.srcPath, target)) if err != nil { - return "", nil, err + return nil, err } defer src.Close() @@ -40,11 +40,11 @@ func (dc *MockTufClient) DownloadTarget(target string, filePath string) (actualF err = os.MkdirAll(filepath.Dir(dstFilePath), os.ModePerm) if err != nil { - return "", nil, err + return nil, err } dst, err := os.Create(dstFilePath) if err != nil { - return "", nil, err + return nil, err } defer dst.Close() @@ -53,10 +53,10 @@ func (dc *MockTufClient) DownloadTarget(target string, filePath string) (actualF b, err := io.ReadAll(tee) if err != nil { - return "", nil, err + return nil, err } - return dstFilePath, b, nil + return &TargetFile{ActualFilePath: dstFilePath, Data: b}, nil } type MockVersionChecker struct { diff --git a/pkg/tuf/registry.go b/pkg/tuf/registry.go index fa47cc3..09af4d8 100644 --- a/pkg/tuf/registry.go +++ b/pkg/tuf/registry.go @@ -81,7 +81,7 @@ func NewRegistryFetcher(metadataRepo, metadataTag, targetsRepo string) *Registry func (d *RegistryFetcher) DownloadFile(urlPath string, maxLength int64, timeout time.Duration) ([]byte, error) { d.timeout = timeout - imgRef, fileName, err := d.parseImgRef(urlPath) + imgRef, fileName, err := d.ParseImgRef(urlPath) if err != nil { return nil, err } @@ -186,7 +186,7 @@ func getDataFromLayer(fileLayer v1.Layer, maxLength int64) ([]byte, error) { } // parseImgRef maintains the Fetcher interface by parsing a URL path to an image reference and file name. -func (d *RegistryFetcher) parseImgRef(urlPath string) (imgRef, fileName string, err error) { +func (d *RegistryFetcher) ParseImgRef(urlPath string) (imgRef, fileName string, err error) { // Check if repo is target or metadata if strings.Contains(urlPath, d.targetsRepo) { // determine if the target path contains subdirectories and set image name accordingly diff --git a/pkg/tuf/registry_test.go b/pkg/tuf/registry_test.go index 45ead27..6e3123b 100644 --- a/pkg/tuf/registry_test.go +++ b/pkg/tuf/registry_test.go @@ -206,7 +206,7 @@ func TestParseImgRef(t *testing.T) { metadataTag: LatestTag, targetsRepo: targetsRepo, } - imgRef, file, err := d.parseImgRef(tc.ref) + imgRef, file, err := d.ParseImgRef(tc.ref) assert.NoError(t, err) assert.Equal(t, tc.expectedRef, imgRef) assert.Equal(t, tc.expectedFile, file) diff --git a/pkg/tuf/tuf.go b/pkg/tuf/tuf.go index 14fee91..06c24af 100644 --- a/pkg/tuf/tuf.go +++ b/pkg/tuf/tuf.go @@ -36,7 +36,7 @@ var ( ) type Downloader interface { - DownloadTarget(target, filePath string) (actualFilePath string, data []byte, err error) + DownloadTarget(target, filePath string) (file *TargetFile, err error) } type Client struct { @@ -44,6 +44,13 @@ type Client struct { cfg *config.UpdaterConfig } +type TargetFile struct { + ActualFilePath string + TargetURI string + Digest string + Data []byte +} + // NewClient creates a new TUF client. func NewClient(initialRoot []byte, tufPath, metadataSource, targetsSource string, versionChecker VersionChecker) (*Client, error) { var tufSource Source @@ -119,40 +126,73 @@ func NewClient(initialRoot []byte, tufPath, metadataSource, targetsSource string return client, nil } +func (t *Client) generateTargetURI(target *metadata.TargetFiles, digest string) (string, error) { + targetBaseURL := ensureTrailingSlash(t.cfg.RemoteTargetsURL) + targetRemotePath := target.Path + if t.cfg.PrefixTargetsWithHash { + baseName := filepath.Base(targetRemotePath) + dirName, ok := strings.CutSuffix(targetRemotePath, "/"+baseName) + if !ok { + // . + targetRemotePath = fmt.Sprintf("%s.%s", digest, baseName) + } else { + // /. + targetRemotePath = fmt.Sprintf("%s/%s.%s", dirName, digest, baseName) + } + } + fullURL := fmt.Sprintf("%s%s", targetBaseURL, targetRemotePath) + + switch fetcher := t.cfg.Fetcher.(type) { + case *RegistryFetcher: + ref, _, err := fetcher.ParseImgRef(fullURL) + if err != nil { + return "", fmt.Errorf("failed to parse image reference: %w", err) + } + return ref, nil + case *fetcher.DefaultFetcher: + return fullURL, nil + default: + return "", fmt.Errorf("unsupported fetcher type: %T", fetcher) + } +} + // DownloadTarget downloads the target file using Updater. The Updater gets the target // information, verifies if the target is already cached, and if it is not cached, // downloads the target file. -func (t *Client) DownloadTarget(target string, filePath string) (actualFilePath string, data []byte, err error) { +func (t *Client) DownloadTarget(target string, filePath string) (file *TargetFile, err error) { // search if the desired target is available targetInfo, err := t.updater.GetTargetInfo(target) if err != nil { - return "", nil, err + return nil, err } // check if filePath exists and create the directory if it doesn't if _, err := os.Stat(filepath.Dir(filePath)); os.IsNotExist(err) { err = os.MkdirAll(filepath.Dir(filePath), os.ModePerm) if err != nil { - return "", nil, fmt.Errorf("failed to create target download directory '%s': %w", filepath.Dir(filePath), err) + return nil, fmt.Errorf("failed to create target download directory '%s': %w", filepath.Dir(filePath), err) } } // target is available, so let's see if the target is already present locally - actualFilePath, data, err = t.updater.FindCachedTarget(targetInfo, filePath) + actualFilePath, data, err := t.updater.FindCachedTarget(targetInfo, filePath) if err != nil { - return "", nil, fmt.Errorf("failed while finding a cached target: %w", err) + return nil, fmt.Errorf("failed while finding a cached target: %w", err) } if data != nil { - return actualFilePath, data, err + digest := util.SHA256Hex(data) + uri, err := t.generateTargetURI(targetInfo, digest) + return &TargetFile{ActualFilePath: actualFilePath, TargetURI: uri, Data: data, Digest: digest}, err } // target is not present locally, so let's try to download it actualFilePath, data, err = t.updater.DownloadTarget(targetInfo, filePath, "") if err != nil { - return "", nil, fmt.Errorf("failed to download target file %s - %w", target, err) + return nil, fmt.Errorf("failed to download target file %s - %w", target, err) } - - return actualFilePath, data, err + digest := util.SHA256Hex(data) + uri, err := t.generateTargetURI(targetInfo, digest) + return &TargetFile{ActualFilePath: actualFilePath, TargetURI: uri, Data: data, Digest: digest}, err } func (t *Client) GetMetadata() trustedmetadata.TrustedMetadata { diff --git a/pkg/tuf/tuf_test.go b/pkg/tuf/tuf_test.go index ddd935b..934f4dc 100644 --- a/pkg/tuf/tuf_test.go +++ b/pkg/tuf/tuf_test.go @@ -122,14 +122,14 @@ func TestDownloadTarget(t *testing.T) { targets := trustedMetadata.Targets[metadata.TARGETS].Signed.Targets for _, target := range targets { // download target files - _, _, err := tufClient.DownloadTarget(target.Path, filepath.Join(tufPath, "download")) + _, err := tufClient.DownloadTarget(target.Path, filepath.Join(tufPath, "download")) assert.NoErrorf(t, err, "Failed to download target: %v", err) } // download delegated target targetInfo, err := tufClient.updater.GetTargetInfo(delegatedTargetFile) assert.NoError(t, err) - _, _, err = tufClient.DownloadTarget(targetInfo.Path, filepath.Join(tufPath, targetInfo.Path)) + _, err = tufClient.DownloadTarget(targetInfo.Path, filepath.Join(tufPath, targetInfo.Path)) assert.NoError(t, err) } } diff --git a/pkg/tuf/version.go b/pkg/tuf/version.go index f751d1e..3c96563 100644 --- a/pkg/tuf/version.go +++ b/pkg/tuf/version.go @@ -67,11 +67,11 @@ func (vc *DefaultVersionChecker) CheckVersion(client Downloader) error { // see https://github.com/Masterminds/semver/blob/v3.2.1/README.md#checking-version-constraints // for more information on the expected format of the version constraints in the TUF repo - _, versionConstraintsBytes, err := client.DownloadTarget("version-constraints", "") + target, err := client.DownloadTarget("version-constraints", "") if err != nil { return fmt.Errorf("failed to download version-constraints: %w", err) } - versionConstraints, err := semver.NewConstraint(string(versionConstraintsBytes)) + versionConstraints, err := semver.NewConstraint(string(target.Data)) if err != nil { return fmt.Errorf("failed to parse minimum version: %w", err) } From 6de792c1b5cb4f17f9c5266a580fc2e405f9c7a6 Mon Sep 17 00:00:00 2001 From: mrjoelkamp Date: Wed, 14 Aug 2024 11:33:15 -0500 Subject: [PATCH 2/9] docs: update README with policy.digest --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 161acd9..b588997 100644 --- a/README.md +++ b/README.md @@ -345,7 +345,8 @@ The VSA can be signed and published to the registry using the signing functions "timeVerified": "2024-04-19T08:00:00.01Z", "resourceUri": "pkg:docker/example.org/example-image@1.0?platform=linux%2Famd64&digest=sha256%3A49f717386e5462e945232569a97a05831cb83bef8c3369be3bb7ea1793686960", "policy": { - "uri": "https://example.org/internal-policy/v1" + "uri": "https://example.org/internal-policy/v1", + "digest": {"sha256": "d71d6b8f49fcba1295b16f5394dd5863a14e4277eb663d66d8c48e392509afe0"} }, "verificationResult": "PASSED", "verifiedLevels": ["SLSA_BUILD_LEVEL_3"] From 2bf7dec72e55f5667d9004245442f0c4cc94e166 Mon Sep 17 00:00:00 2001 From: mrjoelkamp Date: Wed, 14 Aug 2024 12:32:51 -0500 Subject: [PATCH 3/9] feat: add policy.downloadLocation --- README.md | 1 + pkg/attest/verify.go | 6 +----- pkg/attest/verify_test.go | 6 ++++-- pkg/attestation/vsa.go | 7 ++++--- pkg/tuf/registry.go | 4 ++-- pkg/tuf/registry_test.go | 2 +- pkg/tuf/tuf.go | 6 +----- 7 files changed, 14 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index b588997..8b320a1 100644 --- a/README.md +++ b/README.md @@ -346,6 +346,7 @@ The VSA can be signed and published to the registry using the signing functions "resourceUri": "pkg:docker/example.org/example-image@1.0?platform=linux%2Famd64&digest=sha256%3A49f717386e5462e945232569a97a05831cb83bef8c3369be3bb7ea1793686960", "policy": { "uri": "https://example.org/internal-policy/v1", + "downloadLocation": "https://docker.github.io/tuf-staging/targets/docker/d71d6b8f49fcba1295b16f5394dd5863a14e4277eb663d66d8c48e392509afe0.policy.rego", "digest": {"sha256": "d71d6b8f49fcba1295b16f5394dd5863a14e4277eb663d66d8c48e392509afe0"} }, "verificationResult": "PASSED", diff --git a/pkg/attest/verify.go b/pkg/attest/verify.go index 1b29bcd..d30082e 100644 --- a/pkg/attest/verify.go +++ b/pkg/attest/verify.go @@ -86,11 +86,7 @@ func toVerificationResult(p *policy.Policy, input *policy.Input, result *policy. return nil, err } - vsaPolicy := attestation.VSAPolicy{URI: result.Summary.PolicyURI, Digest: p.Digest} - // if the policy URI is not set by the result summary then use the policy's target URI - if vsaPolicy.URI == "" { - vsaPolicy = attestation.VSAPolicy{URI: p.URI, Digest: p.Digest} - } + vsaPolicy := attestation.VSAPolicy{URI: result.Summary.PolicyURI, DownloadLocation: p.URI, Digest: p.Digest} return &VerificationResult{ Policy: p, diff --git a/pkg/attest/verify_test.go b/pkg/attest/verify_test.go index 2a493f8..b386619 100644 --- a/pkg/attest/verify_test.go +++ b/pkg/attest/verify_test.go @@ -112,7 +112,8 @@ func TestVSA(t *testing.T) { 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, PassPolicyDir+"/policy.rego", attestationPredicate.Policy.URI) + assert.Equal(t, PassPolicyDir+"/policy.rego", attestationPredicate.Policy.DownloadLocation) + assert.Equal(t, "https://docker.com/official/policy/v0.1", attestationPredicate.Policy.URI) assert.Equal(t, map[string]string{"sha256": "d71d6b8f49fcba1295b16f5394dd5863a14e4277eb663d66d8c48e392509afe0"}, attestationPredicate.Policy.Digest) } @@ -163,7 +164,8 @@ func TestVerificationFailure(t *testing.T) { 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, FailPolicyDir+"/policy.rego", attestationPredicate.Policy.URI) + assert.Equal(t, FailPolicyDir+"/policy.rego", attestationPredicate.Policy.DownloadLocation) + assert.Equal(t, "https://docker.com/official/policy/v0.1", attestationPredicate.Policy.URI) assert.Equal(t, map[string]string{"sha256": "ad045e1bd7cd602d90196acf68f2c57d7b51565d59e6e30e30d94ae86aa16201"}, attestationPredicate.Policy.Digest) } diff --git a/pkg/attestation/vsa.go b/pkg/attestation/vsa.go index 82f7022..e580601 100644 --- a/pkg/attestation/vsa.go +++ b/pkg/attestation/vsa.go @@ -16,7 +16,7 @@ type VSAPredicate struct { TimeVerified string `json:"timeVerified"` ResourceURI string `json:"resourceUri"` Policy VSAPolicy `json:"policy"` - InputAttestations []VSAInputAttestation `json:"inputAttestations"` + InputAttestations []VSAInputAttestation `json:"inputAttestations,omitempty"` VerificationResult string `json:"verificationResult"` VerifiedLevels []string `json:"verifiedLevels"` } @@ -26,8 +26,9 @@ type VSAVerifier struct { } type VSAPolicy struct { - URI string `json:"uri"` - Digest map[string]string `json:"digest"` + URI string `json:"uri,omitempty"` + Digest map[string]string `json:"digest"` + DownloadLocation string `json:"downloadLocation,omitempty"` } type VSAInputAttestation struct { diff --git a/pkg/tuf/registry.go b/pkg/tuf/registry.go index 09af4d8..fa47cc3 100644 --- a/pkg/tuf/registry.go +++ b/pkg/tuf/registry.go @@ -81,7 +81,7 @@ func NewRegistryFetcher(metadataRepo, metadataTag, targetsRepo string) *Registry func (d *RegistryFetcher) DownloadFile(urlPath string, maxLength int64, timeout time.Duration) ([]byte, error) { d.timeout = timeout - imgRef, fileName, err := d.ParseImgRef(urlPath) + imgRef, fileName, err := d.parseImgRef(urlPath) if err != nil { return nil, err } @@ -186,7 +186,7 @@ func getDataFromLayer(fileLayer v1.Layer, maxLength int64) ([]byte, error) { } // parseImgRef maintains the Fetcher interface by parsing a URL path to an image reference and file name. -func (d *RegistryFetcher) ParseImgRef(urlPath string) (imgRef, fileName string, err error) { +func (d *RegistryFetcher) parseImgRef(urlPath string) (imgRef, fileName string, err error) { // Check if repo is target or metadata if strings.Contains(urlPath, d.targetsRepo) { // determine if the target path contains subdirectories and set image name accordingly diff --git a/pkg/tuf/registry_test.go b/pkg/tuf/registry_test.go index 6e3123b..45ead27 100644 --- a/pkg/tuf/registry_test.go +++ b/pkg/tuf/registry_test.go @@ -206,7 +206,7 @@ func TestParseImgRef(t *testing.T) { metadataTag: LatestTag, targetsRepo: targetsRepo, } - imgRef, file, err := d.ParseImgRef(tc.ref) + imgRef, file, err := d.parseImgRef(tc.ref) assert.NoError(t, err) assert.Equal(t, tc.expectedRef, imgRef) assert.Equal(t, tc.expectedFile, file) diff --git a/pkg/tuf/tuf.go b/pkg/tuf/tuf.go index 06c24af..c54eec0 100644 --- a/pkg/tuf/tuf.go +++ b/pkg/tuf/tuf.go @@ -144,11 +144,7 @@ func (t *Client) generateTargetURI(target *metadata.TargetFiles, digest string) switch fetcher := t.cfg.Fetcher.(type) { case *RegistryFetcher: - ref, _, err := fetcher.ParseImgRef(fullURL) - if err != nil { - return "", fmt.Errorf("failed to parse image reference: %w", err) - } - return ref, nil + return fmt.Sprintf("%s@sha256:%s", t.cfg.RemoteTargetsURL, digest), nil case *fetcher.DefaultFetcher: return fullURL, nil default: From 7c0966de81dab7d472b924a9087796320126120d Mon Sep 17 00:00:00 2001 From: Joel Kamp Date: Wed, 14 Aug 2024 14:39:06 -0500 Subject: [PATCH 4/9] Update README.md Co-authored-by: David Dooling <141646279+whalelines@users.noreply.github.com> --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8b320a1..0656118 100644 --- a/README.md +++ b/README.md @@ -347,7 +347,9 @@ The VSA can be signed and published to the registry using the signing functions "policy": { "uri": "https://example.org/internal-policy/v1", "downloadLocation": "https://docker.github.io/tuf-staging/targets/docker/d71d6b8f49fcba1295b16f5394dd5863a14e4277eb663d66d8c48e392509afe0.policy.rego", - "digest": {"sha256": "d71d6b8f49fcba1295b16f5394dd5863a14e4277eb663d66d8c48e392509afe0"} + "digest": { + "sha256": "d71d6b8f49fcba1295b16f5394dd5863a14e4277eb663d66d8c48e392509afe0" + } }, "verificationResult": "PASSED", "verifiedLevels": ["SLSA_BUILD_LEVEL_3"] From cb475076500a7c15ee6a3dc0fe898f402094f929 Mon Sep 17 00:00:00 2001 From: mrjoelkamp Date: Wed, 14 Aug 2024 14:54:21 -0500 Subject: [PATCH 5/9] chore: pr comments --- pkg/tuf/mock.go | 7 +++++-- pkg/tuf/tuf.go | 2 ++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/pkg/tuf/mock.go b/pkg/tuf/mock.go index 5189483..9e0f870 100644 --- a/pkg/tuf/mock.go +++ b/pkg/tuf/mock.go @@ -4,6 +4,8 @@ import ( "io" "os" "path/filepath" + + "github.com/docker/attest/internal/util" ) type MockTufClient struct { @@ -25,7 +27,8 @@ func NewMockTufClient(srcPath string, dstPath string) *MockTufClient { } func (dc *MockTufClient) DownloadTarget(target string, filePath string) (file *TargetFile, err error) { - src, err := os.Open(filepath.Join(dc.srcPath, target)) + targetPath := filepath.Join(dc.srcPath, target) + src, err := os.Open(targetPath) if err != nil { return nil, err } @@ -56,7 +59,7 @@ func (dc *MockTufClient) DownloadTarget(target string, filePath string) (file *T return nil, err } - return &TargetFile{ActualFilePath: dstFilePath, Data: b}, nil + return &TargetFile{ActualFilePath: dstFilePath, TargetURI: targetPath, Data: b, Digest: util.SHA256Hex(b)}, nil } type MockVersionChecker struct { diff --git a/pkg/tuf/tuf.go b/pkg/tuf/tuf.go index c54eec0..71c949c 100644 --- a/pkg/tuf/tuf.go +++ b/pkg/tuf/tuf.go @@ -129,6 +129,8 @@ func NewClient(initialRoot []byte, tufPath, metadataSource, targetsSource string func (t *Client) generateTargetURI(target *metadata.TargetFiles, digest string) (string, error) { targetBaseURL := ensureTrailingSlash(t.cfg.RemoteTargetsURL) targetRemotePath := target.Path + // if PrefixTargetsWithHash is set, we need to prefix the target name with the hash and handle subdirectories + // similar logic to https://github.com/theupdateframework/go-tuf/blob/f95222bdd22d2ac4e5b8ed6fe912b645e213c3b5/metadata/updater/updater.go#L227-L247 if t.cfg.PrefixTargetsWithHash { baseName := filepath.Base(targetRemotePath) dirName, ok := strings.CutSuffix(targetRemotePath, "/"+baseName) From 059ee8926c9ba1e5049c32e75b5f5494b1ae34ab Mon Sep 17 00:00:00 2001 From: mrjoelkamp Date: Wed, 14 Aug 2024 15:27:02 -0500 Subject: [PATCH 6/9] refactor: move fullURL only needed for DefaultFetcher --- pkg/tuf/tuf.go | 34 ++++++++++++++++------------------ 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/pkg/tuf/tuf.go b/pkg/tuf/tuf.go index 71c949c..cfcc913 100644 --- a/pkg/tuf/tuf.go +++ b/pkg/tuf/tuf.go @@ -127,28 +127,26 @@ func NewClient(initialRoot []byte, tufPath, metadataSource, targetsSource string } func (t *Client) generateTargetURI(target *metadata.TargetFiles, digest string) (string, error) { - targetBaseURL := ensureTrailingSlash(t.cfg.RemoteTargetsURL) - targetRemotePath := target.Path - // if PrefixTargetsWithHash is set, we need to prefix the target name with the hash and handle subdirectories - // similar logic to https://github.com/theupdateframework/go-tuf/blob/f95222bdd22d2ac4e5b8ed6fe912b645e213c3b5/metadata/updater/updater.go#L227-L247 - if t.cfg.PrefixTargetsWithHash { - baseName := filepath.Base(targetRemotePath) - dirName, ok := strings.CutSuffix(targetRemotePath, "/"+baseName) - if !ok { - // . - targetRemotePath = fmt.Sprintf("%s.%s", digest, baseName) - } else { - // /. - targetRemotePath = fmt.Sprintf("%s/%s.%s", dirName, digest, baseName) - } - } - fullURL := fmt.Sprintf("%s%s", targetBaseURL, targetRemotePath) - switch fetcher := t.cfg.Fetcher.(type) { case *RegistryFetcher: return fmt.Sprintf("%s@sha256:%s", t.cfg.RemoteTargetsURL, digest), nil case *fetcher.DefaultFetcher: - return fullURL, nil + targetBaseURL := ensureTrailingSlash(t.cfg.RemoteTargetsURL) + targetRemotePath := target.Path + // if PrefixTargetsWithHash is set, we need to prefix the target name with the hash and handle subdirectories + // similar logic to https://github.com/theupdateframework/go-tuf/blob/f95222bdd22d2ac4e5b8ed6fe912b645e213c3b5/metadata/updater/updater.go#L227-L247 + if t.cfg.PrefixTargetsWithHash { + baseName := filepath.Base(targetRemotePath) + dirName, ok := strings.CutSuffix(targetRemotePath, "/"+baseName) + if !ok { + // . + targetRemotePath = fmt.Sprintf("%s.%s", digest, baseName) + } else { + // /. + targetRemotePath = fmt.Sprintf("%s/%s.%s", dirName, digest, baseName) + } + } + return fmt.Sprintf("%s%s", targetBaseURL, targetRemotePath), nil default: return "", fmt.Errorf("unsupported fetcher type: %T", fetcher) } From 8d8f09661f981341edf8540acb5432ca6ab3ad90 Mon Sep 17 00:00:00 2001 From: mrjoelkamp Date: Wed, 14 Aug 2024 16:10:54 -0500 Subject: [PATCH 7/9] test: add mapping no rego test --- pkg/policy/policy_test.go | 30 ++++++++++++------- .../mock-tuf-no-rego/doi/policy.not-rego | 1 + .../testdata/mock-tuf-no-rego/mapping.yaml | 11 +++++++ 3 files changed, 31 insertions(+), 11 deletions(-) create mode 100644 pkg/policy/testdata/mock-tuf-no-rego/doi/policy.not-rego create mode 100644 pkg/policy/testdata/mock-tuf-no-rego/mapping.yaml diff --git a/pkg/policy/policy_test.go b/pkg/policy/policy_test.go index f185f7e..d5d4b09 100644 --- a/pkg/policy/policy_test.go +++ b/pkg/policy/policy_test.go @@ -32,7 +32,8 @@ func loadAttestation(t *testing.T, path string) *attestation.Envelope { func TestRegoEvaluator_Evaluate(t *testing.T) { ctx, _ := test.Setup(t) - errorStr := "failed to resolve policy by id: policy with id non-existent-policy-id not found" + resolveErrorStr := "failed to resolve policy by id: policy with id non-existent-policy-id not found" + evalErrorStr := "rego_parse_error:" TestDataPath := filepath.Join("..", "..", "test", "testdata") ExampleAttestation := filepath.Join(TestDataPath, "example_attestation.json") @@ -43,22 +44,24 @@ func TestRegoEvaluator_Evaluate(t *testing.T) { } testCases := []struct { - repo string - expectSuccess bool - isCanonical bool - resolver attestation.Resolver - policy *policy.Options - policyID string - errorStr string + repo string + expectSuccess bool + isCanonical bool + resolver attestation.Resolver + policy *policy.Options + policyID string + resolveErrorStr string + evalErrorStr string }{ {repo: "testdata/mock-tuf-allow", expectSuccess: true, isCanonical: false, resolver: defaultResolver}, {repo: "testdata/mock-tuf-allow", expectSuccess: true, isCanonical: false, resolver: defaultResolver, policyID: "docker-official-images"}, - {repo: "testdata/mock-tuf-allow", expectSuccess: false, isCanonical: false, resolver: defaultResolver, policyID: "non-existent-policy-id", errorStr: errorStr}, + {repo: "testdata/mock-tuf-allow", expectSuccess: false, isCanonical: false, resolver: defaultResolver, policyID: "non-existent-policy-id", resolveErrorStr: resolveErrorStr}, {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}, + {repo: "testdata/mock-tuf-no-rego", expectSuccess: false, isCanonical: false, resolver: defaultResolver, evalErrorStr: evalErrorStr}, } for _, tc := range testCases { @@ -86,14 +89,19 @@ func TestRegoEvaluator_Evaluate(t *testing.T) { resolver, err := policy.CreateImageDetailsResolver(src) require.NoError(t, err) policy, err := policy.ResolvePolicy(ctx, resolver, tc.policy) - if tc.errorStr != "" { + if tc.resolveErrorStr != "" { require.Error(t, err) - assert.Contains(t, err.Error(), tc.errorStr) + assert.Contains(t, err.Error(), tc.resolveErrorStr) return } require.NoErrorf(t, err, "failed to resolve policy") require.NotNil(t, policy, "policy should not be nil") result, err := re.Evaluate(ctx, tc.resolver, policy, input) + if tc.evalErrorStr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.evalErrorStr) + return + } require.NoErrorf(t, err, "Evaluate failed") if tc.expectSuccess { diff --git a/pkg/policy/testdata/mock-tuf-no-rego/doi/policy.not-rego b/pkg/policy/testdata/mock-tuf-no-rego/doi/policy.not-rego new file mode 100644 index 0000000..aa3513e --- /dev/null +++ b/pkg/policy/testdata/mock-tuf-no-rego/doi/policy.not-rego @@ -0,0 +1 @@ +this isn't a rego policy \ No newline at end of file diff --git a/pkg/policy/testdata/mock-tuf-no-rego/mapping.yaml b/pkg/policy/testdata/mock-tuf-no-rego/mapping.yaml new file mode 100644 index 0000000..2f0c252 --- /dev/null +++ b/pkg/policy/testdata/mock-tuf-no-rego/mapping.yaml @@ -0,0 +1,11 @@ +# map repos to policies +version: v1 +kind: policy-mapping +policies: + - id: docker-official-images + description: Docker Official Images + files: + - path: doi/policy.not-rego +rules: + - pattern: "^docker[.]io/library/(.*)$" + policy-id: docker-official-images From 5f17f972299ef390b85d3e6c1cc066643dad0bf9 Mon Sep 17 00:00:00 2001 From: mrjoelkamp Date: Wed, 14 Aug 2024 16:13:36 -0500 Subject: [PATCH 8/9] test: change test to use yaml file instead --- pkg/policy/policy_test.go | 2 +- pkg/policy/testdata/mock-tuf-no-rego/doi/policy.not-rego | 1 - pkg/policy/testdata/mock-tuf-no-rego/doi/policy.yaml | 1 + pkg/policy/testdata/mock-tuf-no-rego/mapping.yaml | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) delete mode 100644 pkg/policy/testdata/mock-tuf-no-rego/doi/policy.not-rego create mode 100644 pkg/policy/testdata/mock-tuf-no-rego/doi/policy.yaml diff --git a/pkg/policy/policy_test.go b/pkg/policy/policy_test.go index d5d4b09..55d0d11 100644 --- a/pkg/policy/policy_test.go +++ b/pkg/policy/policy_test.go @@ -33,7 +33,7 @@ func loadAttestation(t *testing.T, path string) *attestation.Envelope { func TestRegoEvaluator_Evaluate(t *testing.T) { ctx, _ := test.Setup(t) resolveErrorStr := "failed to resolve policy by id: policy with id non-existent-policy-id not found" - evalErrorStr := "rego_parse_error:" + evalErrorStr := "no policy evaluation result" TestDataPath := filepath.Join("..", "..", "test", "testdata") ExampleAttestation := filepath.Join(TestDataPath, "example_attestation.json") diff --git a/pkg/policy/testdata/mock-tuf-no-rego/doi/policy.not-rego b/pkg/policy/testdata/mock-tuf-no-rego/doi/policy.not-rego deleted file mode 100644 index aa3513e..0000000 --- a/pkg/policy/testdata/mock-tuf-no-rego/doi/policy.not-rego +++ /dev/null @@ -1 +0,0 @@ -this isn't a rego policy \ No newline at end of file diff --git a/pkg/policy/testdata/mock-tuf-no-rego/doi/policy.yaml b/pkg/policy/testdata/mock-tuf-no-rego/doi/policy.yaml new file mode 100644 index 0000000..148070a --- /dev/null +++ b/pkg/policy/testdata/mock-tuf-no-rego/doi/policy.yaml @@ -0,0 +1 @@ +policy: "this is not rego" diff --git a/pkg/policy/testdata/mock-tuf-no-rego/mapping.yaml b/pkg/policy/testdata/mock-tuf-no-rego/mapping.yaml index 2f0c252..e71ba54 100644 --- a/pkg/policy/testdata/mock-tuf-no-rego/mapping.yaml +++ b/pkg/policy/testdata/mock-tuf-no-rego/mapping.yaml @@ -5,7 +5,7 @@ policies: - id: docker-official-images description: Docker Official Images files: - - path: doi/policy.not-rego + - path: doi/policy.yaml rules: - pattern: "^docker[.]io/library/(.*)$" policy-id: docker-official-images From 52499053d2d3695cfee979680afbd99d03ead27b Mon Sep 17 00:00:00 2001 From: mrjoelkamp Date: Wed, 14 Aug 2024 16:25:41 -0500 Subject: [PATCH 9/9] feat: add no policy file error --- pkg/policy/policy.go | 6 ++++++ pkg/policy/policy_test.go | 9 +-------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/pkg/policy/policy.go b/pkg/policy/policy.go index fa569cd..6a82d15 100644 --- a/pkg/policy/policy.go +++ b/pkg/policy/policy.go @@ -42,6 +42,9 @@ func resolveLocalPolicy(opts *Options, mapping *config.PolicyMapping, imageName digest = map[string]string{"sha256": util.SHA256Hex(fileContents)} } } + if URI == "" { + return nil, fmt.Errorf("no policy file found in policy mapping") + } policy := &Policy{ InputFiles: files, Mapping: mapping, @@ -78,6 +81,9 @@ func resolveTUFPolicy(opts *Options, mapping *config.PolicyMapping, imageName st digest = map[string]string{"sha256": file.Digest} } } + if URI == "" { + return nil, fmt.Errorf("no policy file found in policy mapping") + } policy := &Policy{ InputFiles: files, Mapping: mapping, diff --git a/pkg/policy/policy_test.go b/pkg/policy/policy_test.go index 55d0d11..b282daf 100644 --- a/pkg/policy/policy_test.go +++ b/pkg/policy/policy_test.go @@ -33,7 +33,6 @@ func loadAttestation(t *testing.T, path string) *attestation.Envelope { func TestRegoEvaluator_Evaluate(t *testing.T) { ctx, _ := test.Setup(t) resolveErrorStr := "failed to resolve policy by id: policy with id non-existent-policy-id not found" - evalErrorStr := "no policy evaluation result" TestDataPath := filepath.Join("..", "..", "test", "testdata") ExampleAttestation := filepath.Join(TestDataPath, "example_attestation.json") @@ -51,7 +50,6 @@ func TestRegoEvaluator_Evaluate(t *testing.T) { policy *policy.Options policyID string resolveErrorStr string - evalErrorStr string }{ {repo: "testdata/mock-tuf-allow", expectSuccess: true, isCanonical: false, resolver: defaultResolver}, {repo: "testdata/mock-tuf-allow", expectSuccess: true, isCanonical: false, resolver: defaultResolver, policyID: "docker-official-images"}, @@ -61,7 +59,7 @@ func TestRegoEvaluator_Evaluate(t *testing.T) { {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}, - {repo: "testdata/mock-tuf-no-rego", expectSuccess: false, isCanonical: false, resolver: defaultResolver, evalErrorStr: evalErrorStr}, + {repo: "testdata/mock-tuf-no-rego", expectSuccess: false, isCanonical: false, resolver: defaultResolver, resolveErrorStr: "no policy file found in policy mapping"}, } for _, tc := range testCases { @@ -97,11 +95,6 @@ func TestRegoEvaluator_Evaluate(t *testing.T) { require.NoErrorf(t, err, "failed to resolve policy") require.NotNil(t, policy, "policy should not be nil") result, err := re.Evaluate(ctx, tc.resolver, policy, input) - if tc.evalErrorStr != "" { - require.Error(t, err) - assert.Contains(t, err.Error(), tc.evalErrorStr) - return - } require.NoErrorf(t, err, "Evaluate failed") if tc.expectSuccess {