diff --git a/README.md b/README.md index 161acd9b..06561188 100644 --- a/README.md +++ b/README.md @@ -345,7 +345,11 @@ 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", + "downloadLocation": "https://docker.github.io/tuf-staging/targets/docker/d71d6b8f49fcba1295b16f5394dd5863a14e4277eb663d66d8c48e392509afe0.policy.rego", + "digest": { + "sha256": "d71d6b8f49fcba1295b16f5394dd5863a14e4277eb663d66d8c48e392509afe0" + } }, "verificationResult": "PASSED", "verifiedLevels": ["SLSA_BUILD_LEVEL_3"] diff --git a/pkg/attest/verify.go b/pkg/attest/verify.go index c1d6540e..d30082e1 100644 --- a/pkg/attest/verify.go +++ b/pkg/attest/verify.go @@ -86,6 +86,8 @@ func toVerificationResult(p *policy.Policy, input *policy.Input, result *policy. return nil, err } + vsaPolicy := attestation.VSAPolicy{URI: result.Summary.PolicyURI, DownloadLocation: p.URI, Digest: p.Digest} + return &VerificationResult{ Policy: p, Outcome: outcome, @@ -103,7 +105,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 abf80488..b386619f 100644 --- a/pkg/attest/verify_test.go +++ b/pkg/attest/verify_test.go @@ -112,7 +112,9 @@ 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.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) } func TestVerificationFailure(t *testing.T) { @@ -162,7 +164,9 @@ 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.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) } func TestSignVerify(t *testing.T) { diff --git a/pkg/attestation/vsa.go b/pkg/attestation/vsa.go index 0e5804e9..e5806016 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,7 +26,9 @@ type VSAVerifier struct { } type VSAPolicy struct { - URI string `json:"uri"` + URI string `json:"uri,omitempty"` + Digest map[string]string `json:"digest"` + DownloadLocation string `json:"downloadLocation,omitempty"` } type VSAInputAttestation struct { diff --git a/pkg/config/config.go b/pkg/config/config.go index e724c2d9..ae127028 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 df09e4c3..e738d954 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 cfab2554..6a82d150 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,24 @@ 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)} + } + } + if URI == "" { + return nil, fmt.Errorf("no policy file found in policy mapping") } policy := &Policy{ InputFiles: files, Mapping: mapping, + URI: URI, + Digest: digest, } if imageName != matchedName { policy.ResolvedName = matchedName @@ -41,21 +58,37 @@ 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} + } + } + if URI == "" { + return nil, fmt.Errorf("no policy file found in policy mapping") } policy := &Policy{ InputFiles: files, Mapping: mapping, + URI: URI, + Digest: digest, } if imageName != matchedName { policy.ResolvedName = matchedName diff --git a/pkg/policy/policy_test.go b/pkg/policy/policy_test.go index f185f7ef..b282daf5 100644 --- a/pkg/policy/policy_test.go +++ b/pkg/policy/policy_test.go @@ -32,7 +32,7 @@ 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" TestDataPath := filepath.Join("..", "..", "test", "testdata") ExampleAttestation := filepath.Join(TestDataPath, "example_attestation.json") @@ -43,22 +43,23 @@ 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 }{ {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, resolveErrorStr: "no policy file found in policy mapping"}, } for _, tc := range testCases { @@ -86,9 +87,9 @@ 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") 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 00000000..148070a9 --- /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 new file mode 100644 index 00000000..e71ba542 --- /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.yaml +rules: + - pattern: "^docker[.]io/library/(.*)$" + policy-id: docker-official-images diff --git a/pkg/policy/types.go b/pkg/policy/types.go index 54cc993e..a122264e 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 bf9ddbca..b6e44495 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 ae795456..9e0f8707 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 { @@ -24,10 +26,11 @@ func NewMockTufClient(srcPath string, dstPath string) *MockTufClient { } } -func (dc *MockTufClient) DownloadTarget(target string, filePath string) (actualFilePath string, data []byte, err error) { - src, err := os.Open(filepath.Join(dc.srcPath, target)) +func (dc *MockTufClient) DownloadTarget(target string, filePath string) (file *TargetFile, err error) { + targetPath := filepath.Join(dc.srcPath, target) + src, err := os.Open(targetPath) if err != nil { - return "", nil, err + return nil, err } defer src.Close() @@ -40,11 +43,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 +56,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, 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 14fee910..cfcc9136 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,69 @@ func NewClient(initialRoot []byte, tufPath, metadataSource, targetsSource string return client, nil } +func (t *Client) generateTargetURI(target *metadata.TargetFiles, digest string) (string, error) { + switch fetcher := t.cfg.Fetcher.(type) { + case *RegistryFetcher: + return fmt.Sprintf("%s@sha256:%s", t.cfg.RemoteTargetsURL, digest), nil + case *fetcher.DefaultFetcher: + 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) + } +} + // 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 ddd935b9..934f4dcb 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 f751d1eb..3c965638 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) }