18 Commits

Author SHA1 Message Date
Joel Kamp
a4a0bf3cbe Merge pull request #124 from docker/feat-generate-vsa-policy-uri
feat: add `digest` and `downloadLocation` to VSA policy
2024-08-14 16:50:16 -05:00
mrjoelkamp
52499053d2 feat: add no policy file error 2024-08-14 16:25:41 -05:00
mrjoelkamp
5f17f97229 test: change test to use yaml file instead 2024-08-14 16:13:36 -05:00
mrjoelkamp
8d8f09661f test: add mapping no rego test 2024-08-14 16:10:54 -05:00
mrjoelkamp
059ee8926c refactor: move fullURL only needed for DefaultFetcher 2024-08-14 15:27:02 -05:00
mrjoelkamp
cb47507650 chore: pr comments 2024-08-14 15:01:01 -05:00
Joel Kamp
7c0966de81 Update README.md
Co-authored-by: David Dooling <141646279+whalelines@users.noreply.github.com>
2024-08-14 14:39:06 -05:00
mrjoelkamp
2bf7dec72e feat: add policy.downloadLocation 2024-08-14 12:52:36 -05:00
mrjoelkamp
6de792c1b5 docs: update README with policy.digest 2024-08-14 11:33:15 -05:00
mrjoelkamp
d2a8348ae8 feat: generate vsa policy value from file 2024-08-14 10:57:15 -05:00
Joel Kamp
8c6df28540 Merge pull request #122 from docker/feat-mirror-empty-config-image
feat: mirror empty config image
2024-08-13 10:09:35 -05:00
mrjoelkamp
5162cfa404 refactor: ensure tests are in correct pkg 2024-08-13 10:03:33 -05:00
mrjoelkamp
72f6517b2c refactor: move empty config image test 2024-08-13 08:26:36 -05:00
mrjoelkamp
84cadeb97e feat: output comments 2024-08-13 08:13:27 -05:00
mrjoelkamp
57a61cc266 fix: e2e auth test 2024-08-12 16:54:44 -05:00
mrjoelkamp
5a772633b0 feat: use EmptyConfigImage for mirror 2024-08-12 16:43:42 -05:00
mrjoelkamp
1febc55a19 fix: cyclical imports 2024-08-12 16:36:18 -05:00
mrjoelkamp
0db96d56aa fix: err check not needed 2024-08-12 14:20:24 -05:00
59 changed files with 838 additions and 696 deletions

View File

@@ -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"]

View File

@@ -2,22 +2,13 @@ package test
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/docker/attest/pkg/attestation"
"github.com/docker/attest/pkg/policy"
"github.com/docker/attest/pkg/signerverifier"
"github.com/docker/attest/pkg/tlog"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/layout"
"github.com/google/go-containerregistry/pkg/v1/partial"
intoto "github.com/in-toto/in-toto-golang/in_toto"
"github.com/secure-systems-lab/go-securesystemslib/dsse"
)
@@ -30,6 +21,8 @@ const (
AWSKMSKeyARN = "arn:aws:kms:us-east-1:175142243308:alias/doi-signing" // sandbox
)
var UnsignedTestImage = filepath.Join("..", "..", "test", "testdata", "unsigned-test-image")
func CreateTempDir(t *testing.T, dir, pattern string) string {
// Create a temporary directory for output oci layout
tempDir, err := os.MkdirTemp(dir, pattern)
@@ -89,108 +82,3 @@ func Setup(t *testing.T) (context.Context, dsse.SignerVerifier) {
return ctx, signer
}
type AnnotatedStatement struct {
OCIDescriptor *v1.Descriptor
InTotoStatement *intoto.Statement
Annotations map[string]string
}
func ExtractStatementsFromIndex(idx v1.ImageIndex, mediaType string) ([]*AnnotatedStatement, error) {
mfs2, err := idx.IndexManifest()
if err != nil {
return nil, fmt.Errorf("failed to extract IndexManifest from ImageIndex: %w", err)
}
var statements []*AnnotatedStatement
for i := range mfs2.Manifests {
mf := &mfs2.Manifests[i]
if mf.Annotations[attestation.DockerReferenceType] != "attestation-manifest" {
continue
}
attestationImage, err := idx.Image(mf.Digest)
if err != nil {
return nil, fmt.Errorf("failed to extract attestation image with digest %s: %w", mf.Digest.String(), err)
}
layers, err := attestationImage.Layers()
if err != nil {
return nil, fmt.Errorf("failed to extract layers from attestation image: %w", err)
}
for _, layer := range layers {
// parse layer blob as json
mt, err := layer.MediaType()
if err != nil {
return nil, fmt.Errorf("failed to get layer media type: %w", err)
}
if string(mt) != mediaType {
continue
}
r, err := layer.Uncompressed()
if err != nil {
return nil, fmt.Errorf("failed to get layer contents: %w", err)
}
defer r.Close()
inTotoStatement := new(intoto.Statement)
var desc *v1.Descriptor
if strings.HasSuffix(string(mt), "+dsse") {
env := new(attestation.Envelope)
err = json.NewDecoder(r).Decode(env)
if err != nil {
return nil, fmt.Errorf("failed to decode env: %w", err)
}
payload, err := base64.StdEncoding.Strict().DecodeString(env.Payload)
if err != nil {
return nil, fmt.Errorf("failed to decode payload: %w", err)
}
err = json.Unmarshal([]byte(payload), inTotoStatement)
if err != nil {
return nil, fmt.Errorf("failed to decode %s statement: %w", mediaType, err)
}
} else {
desc := new(v1.Descriptor)
err = json.NewDecoder(r).Decode(desc)
if err != nil {
return nil, fmt.Errorf("failed to decode statement: %w", err)
}
}
layerDesc, err := partial.Descriptor(layer)
if err != nil {
return nil, fmt.Errorf("failed to get descriptor for layer: %w", err)
}
annotations := make(map[string]string)
for k, v := range layerDesc.Annotations {
annotations[k] = v
}
statements = append(statements, &AnnotatedStatement{
OCIDescriptor: desc,
InTotoStatement: inTotoStatement,
Annotations: annotations,
})
}
}
return statements, nil
}
func ExtractAnnotatedStatements(path string, mediaType string) ([]*AnnotatedStatement, error) {
idx, err := layout.ImageIndexFromPath(path)
if err != nil {
return nil, fmt.Errorf("failed to load image index: %w", err)
}
idxm, err := idx.IndexManifest()
if err != nil {
return nil, fmt.Errorf("failed to get digest: %w", err)
}
idxDigest := idxm.Manifests[0].Digest
mfs, err := idx.ImageIndex(idxDigest)
if err != nil {
return nil, fmt.Errorf("failed to extract ImageIndex for digest %s: %w", idxDigest.String(), err)
}
return ExtractStatementsFromIndex(mfs, mediaType)
}

2
pkg/attest/README.md Normal file
View File

@@ -0,0 +1,2 @@
## attest
This package implements the top-level signing and verification methods.

View File

@@ -12,14 +12,14 @@ import (
// this is only relevant if there are (unsigned) in-toto statements.
func SignStatements(ctx context.Context, idx v1.ImageIndex, signer dsse.SignerVerifier, opts *attestation.SigningOptions) ([]*attestation.Manifest, error) {
// extract attestation manifests from index
attestationManifests, err := attestation.GetAttestationManifestsFromIndex(idx)
attestationManifests, err := attestation.ManifestsFromIndex(idx)
if err != nil {
return nil, fmt.Errorf("failed to load attestation manifests from index: %w", err)
}
// sign every attestation layer in each manifest
for _, manifest := range attestationManifests {
for _, layer := range manifest.OriginalLayers {
err = manifest.AddAttestation(ctx, signer, layer.Statement, opts)
err = manifest.Add(ctx, signer, layer.Statement, opts)
if err != nil {
return nil, fmt.Errorf("failed to sign attestation layer %w", err)
}

View File

@@ -1,10 +1,6 @@
package attest
import (
"encoding/json"
"fmt"
"net/http/httptest"
"net/url"
"path/filepath"
"testing"
@@ -12,10 +8,6 @@ import (
"github.com/docker/attest/pkg/attestation"
"github.com/docker/attest/pkg/oci"
"github.com/docker/attest/pkg/policy"
"github.com/google/go-containerregistry/pkg/registry"
v1 "github.com/google/go-containerregistry/pkg/v1"
"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"
v02 "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v0.2"
"github.com/stretchr/testify/assert"
@@ -23,7 +15,6 @@ import (
)
var (
UnsignedTestImage = filepath.Join("..", "..", "test", "testdata", "unsigned-test-image")
NoProvenanceImage = filepath.Join("..", "..", "test", "testdata", "no-provenance-image")
PassPolicyDir = filepath.Join("..", "..", "test", "testdata", "local-policy-pass")
PassMirrorPolicyDir = filepath.Join("..", "..", "test", "testdata", "local-policy-mirror")
@@ -42,8 +33,8 @@ func TestSignVerifyOCILayout(t *testing.T) {
expectedAttestations int
replace bool
}{
{"signed replaced", UnsignedTestImage, 0, 4, true},
{"without replace", UnsignedTestImage, 4, 4, false},
{"signed replaced", test.UnsignedTestImage, 0, 4, true},
{"without replace", test.UnsignedTestImage, 4, 4, false},
// image without provenance doesn't fail
{"no provenance (replace)", NoProvenanceImage, 0, 2, true},
{"no provenance (no replace)", NoProvenanceImage, 2, 2, false},
@@ -70,10 +61,10 @@ func TestSignVerifyOCILayout(t *testing.T) {
require.NoError(t, err)
assert.Equalf(t, OutcomeSuccess, policy.Outcome, "Policy should have been found")
var allEnvelopes []*test.AnnotatedStatement
var allEnvelopes []*attestation.AnnotatedStatement
for _, predicate := range []string{intoto.PredicateSPDX, v02.PredicateSLSAProvenance, attestation.VSAPredicateType} {
mt, _ := attestation.DSSEMediaType(predicate)
statements, err := test.ExtractAnnotatedStatements(outputLayout, mt)
statements, err := attestation.ExtractAnnotatedStatements(outputLayout, mt)
require.NoError(t, err)
allEnvelopes = append(allEnvelopes, statements...)
@@ -83,150 +74,9 @@ 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(outputLayout, intoto.PayloadType)
statements, err := attestation.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 TestAddSignedLayerAnnotations(t *testing.T) {
ctx, signer := test.Setup(t)
testCases := []struct {
name string
replace bool
}{
{"replaced", true},
{"not replaced", false},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
data := []byte("signed")
testLayer := static.NewLayer(data, types.MediaType(intoto.PayloadType))
mediaType := types.OCIManifestSchema1
opts := &attestation.SigningOptions{}
originalLayer := &attestation.Layer{
Layer: testLayer,
Statement: &intoto.Statement{
StatementHeader: intoto.StatementHeader{
PredicateType: attestation.VSAPredicateType,
},
},
Annotations: map[string]string{"test": "test"},
}
manifest := &attestation.Manifest{
OriginalDescriptor: &v1.Descriptor{
MediaType: mediaType,
},
OriginalLayers: []*attestation.Layer{
originalLayer,
},
SubjectDescriptor: &v1.Descriptor{},
}
err := manifest.AddAttestation(ctx, signer, originalLayer.Statement, opts)
require.NoError(t, err)
newImg, err := manifest.BuildAttestationImage(attestation.WithReplacedLayers(tc.replace))
require.NoError(t, err)
mf, _ := newImg.RawManifest()
type Annotations struct {
Annotations map[string]string `json:"annotations"`
}
type Layers struct {
Layers []Annotations `json:"layers"`
}
l := &Layers{}
err = json.Unmarshal(mf, l)
require.NoError(t, err)
_, ok := l.Layers[0].Annotations["test"]
assert.Truef(t, ok, "missing annotations")
})
}
}
func TestSimpleStatementSigning(t *testing.T) {
ctx, signer := test.Setup(t)
empty := types.MediaType("application/vnd.oci.empty.v1+json")
testCases := []struct {
name string
replace bool
}{
{"replaced", true},
{"not replaced", false},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
opts := &attestation.SigningOptions{}
statement := &intoto.Statement{
StatementHeader: intoto.StatementHeader{
PredicateType: attestation.VSAPredicateType,
},
}
statement2 := &intoto.Statement{
StatementHeader: intoto.StatementHeader{
PredicateType: attestation.VSAPredicateType,
},
}
digest, err := v1.NewHash("sha256:da8b190665956ea07890a0273e2a9c96bfe291662f08e2860e868eef69c34620")
require.NoError(t, err)
subject := &v1.Descriptor{
MediaType: "application/vnd.oci.image.manifest.v1+json",
Digest: digest,
}
manifest, err := NewAttestationManifest(subject)
require.NoError(t, err)
err = manifest.AddAttestation(ctx, signer, statement, opts)
require.NoError(t, err)
err = manifest.AddAttestation(ctx, signer, statement2, opts)
require.NoError(t, err)
// fake that the manfifest was loaded from a real image
manifest.OriginalLayers = manifest.SignedLayers
envelopes, err := oci.ExtractEnvelopes(manifest, attestation.VSAPredicateType)
require.NoError(t, err)
assert.Len(t, envelopes, 2)
newImg, err := manifest.BuildAttestationImage(attestation.WithReplacedLayers(tc.replace))
require.NoError(t, err)
layers, err := newImg.Layers()
require.NoError(t, err)
if tc.replace {
assert.Len(t, layers, 2)
} else {
assert.Len(t, layers, 4)
}
newImgs, err := manifest.BuildReferringArtifacts()
require.NoError(t, err)
assert.Len(t, newImgs, 2)
for _, img := range newImgs {
mf, err := img.Manifest()
require.NoError(t, err)
assert.Contains(t, mf.ArtifactType, "application/vnd.in-toto")
assert.Contains(t, mf.ArtifactType, "+dsse")
assert.Equal(t, subject.MediaType, mf.MediaType)
assert.Equal(t, empty, mf.Config.MediaType)
assert.Equal(t, int64(2), mf.Config.Size)
assert.Equal(t, "{}", string(mf.Config.Data))
layers, err := img.Layers()
require.NoError(t, err)
assert.Len(t, layers, 1)
}
server := httptest.NewServer(registry.New(registry.WithReferrersSupport(true)))
defer server.Close()
u, err := url.Parse(server.URL)
require.NoError(t, err)
indexName := fmt.Sprintf("%s/repo:root", u.Host)
output, err := oci.ParseImageSpecs(indexName)
require.NoError(t, err)
err = oci.SaveReferrers(manifest, output)
require.NoError(t, err)
})
}
}

View File

@@ -11,7 +11,6 @@ import (
"github.com/docker/attest/pkg/config"
"github.com/docker/attest/pkg/oci"
"github.com/docker/attest/pkg/policy"
v1 "github.com/google/go-containerregistry/pkg/v1"
intoto "github.com/in-toto/in-toto-golang/in_toto"
)
@@ -87,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,
@@ -104,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,
},
@@ -112,7 +113,7 @@ func toVerificationResult(p *policy.Policy, input *policy.Input, result *policy.
}, nil
}
func VerifyAttestations(ctx context.Context, resolver oci.AttestationResolver, pctx *policy.Policy) (*VerificationResult, error) {
func VerifyAttestations(ctx context.Context, resolver attestation.Resolver, pctx *policy.Policy) (*VerificationResult, error) {
desc, err := resolver.ImageDescriptor(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get image descriptor: %w", err)
@@ -164,13 +165,3 @@ func VerifyAttestations(ctx context.Context, resolver oci.AttestationResolver, p
verificationResult.SubjectDescriptor = desc
return verificationResult, nil
}
func NewAttestationManifest(subject *v1.Descriptor) (*attestation.Manifest, error) {
return &attestation.Manifest{
OriginalDescriptor: &v1.Descriptor{
MediaType: "application/vnd.oci.image.manifest.v1+json",
},
OriginalLayers: []*attestation.Layer{},
SubjectDescriptor: subject,
}, nil
}

View File

@@ -31,7 +31,7 @@ func TestVerifyAttestations(t *testing.T) {
env := new(attestation.Envelope)
err = json.Unmarshal(ex, env)
assert.NoError(t, err)
resolver := &oci.MockResolver{
resolver := &attestation.MockResolver{
Envs: []*attestation.Envelope{env},
}
@@ -47,7 +47,7 @@ func TestVerifyAttestations(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
mockPE := policy.MockPolicyEvaluator{
EvaluateFunc: func(_ context.Context, _ oci.AttestationResolver, _ *policy.Policy, _ *policy.Input) (*policy.Result, error) {
EvaluateFunc: func(_ context.Context, _ attestation.Resolver, _ *policy.Policy, _ *policy.Input) (*policy.Result, error) {
return policy.AllowedResult(), tc.policyEvaluationError
},
}
@@ -72,7 +72,7 @@ func TestVSA(t *testing.T) {
outputLayout := test.CreateTempDir(t, "", TestTempDir)
opts := &attestation.SigningOptions{}
attIdx, err := oci.IndexFromPath(UnsignedTestImage)
attIdx, err := oci.IndexFromPath(test.UnsignedTestImage)
assert.NoError(t, err)
signedManifests, err := SignStatements(ctx, attIdx.Index, signer, opts)
require.NoError(t, err)
@@ -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) {
@@ -122,7 +124,7 @@ func TestVerificationFailure(t *testing.T) {
outputLayout := test.CreateTempDir(t, "", TestTempDir)
opts := &attestation.SigningOptions{}
attIdx, err := oci.IndexFromPath(UnsignedTestImage)
attIdx, err := oci.IndexFromPath(test.UnsignedTestImage)
assert.NoError(t, err)
signedManifests, err := SignStatements(ctx, attIdx.Index, signer, opts)
require.NoError(t, err)
@@ -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) {
@@ -185,7 +189,7 @@ func TestSignVerify(t *testing.T) {
{name: "mirror no match", signTL: true, policyDir: PassMirrorPolicyDir, imageName: "incorrect.org/library/test-image:test", expectError: true},
}
attIdx, err := oci.IndexFromPath(UnsignedTestImage)
attIdx, err := oci.IndexFromPath(test.UnsignedTestImage)
assert.NoError(t, err)
for _, tc := range testCases {

View File

@@ -0,0 +1,4 @@
## attestations
This package is for components that deal with the creation, storage, and retrieval of signed attestions using OCI.
For more generic OCI components see the `oci` package.

View File

@@ -1,14 +1,17 @@
package attestation
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"maps"
"strings"
"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/layout"
"github.com/google/go-containerregistry/pkg/v1/match"
"github.com/google/go-containerregistry/pkg/v1/mutate"
"github.com/google/go-containerregistry/pkg/v1/partial"
@@ -18,8 +21,19 @@ import (
"github.com/secure-systems-lab/go-securesystemslib/dsse"
)
// GetAttestationManifestsFromIndex extracts all attestation manifests from an index.
func GetAttestationManifestsFromIndex(index v1.ImageIndex) ([]*Manifest, error) {
// NewManifest creates a new attestation manifest from a descriptor.
func NewManifest(subject *v1.Descriptor) (*Manifest, error) {
return &Manifest{
OriginalDescriptor: &v1.Descriptor{
MediaType: "application/vnd.oci.image.manifest.v1+json",
},
OriginalLayers: []*Layer{},
SubjectDescriptor: subject,
}, nil
}
// ManifestsFromIndex extracts all attestation manifests from an index.
func ManifestsFromIndex(index v1.ImageIndex) ([]*Manifest, error) {
idx, err := index.IndexManifest()
if err != nil {
return nil, fmt.Errorf("failed to extract IndexManifest from ImageIndex: %w", err)
@@ -42,7 +56,7 @@ func GetAttestationManifestsFromIndex(index v1.ImageIndex) ([]*Manifest, error)
if err != nil {
return nil, fmt.Errorf("failed to extract attestation image with digest %s: %w", desc.Digest.String(), err)
}
attestationLayers, err := GetAttestationsFromImage(attestationImage)
attestationLayers, err := layersFromImage(attestationImage)
if err != nil {
return nil, fmt.Errorf("failed to get attestations from image: %w", err)
}
@@ -57,8 +71,8 @@ func GetAttestationManifestsFromIndex(index v1.ImageIndex) ([]*Manifest, error)
return attestationManifests, nil
}
// GetAttestationsFromImage extracts all attestation layers from an image.
func GetAttestationsFromImage(image v1.Image) ([]*Layer, error) {
// LayersFromImage extracts all attestation layers from an image.
func layersFromImage(image v1.Image) ([]*Layer, error) {
layers, err := image.Layers()
if err != nil {
return nil, fmt.Errorf("failed to extract layers from image: %w", err)
@@ -94,7 +108,7 @@ func GetAttestationsFromImage(image v1.Image) ([]*Layer, error) {
return attestationLayers, nil
}
func (manifest *Manifest) AddAttestation(ctx context.Context, signer dsse.SignerVerifier, statement *intoto.Statement, opts *SigningOptions) error {
func (manifest *Manifest) Add(ctx context.Context, signer dsse.SignerVerifier, statement *intoto.Statement, opts *SigningOptions) error {
layer, err := createSignedImageLayer(ctx, statement, signer, opts)
if err != nil {
return fmt.Errorf("failed to create signed layer: %w", err)
@@ -105,7 +119,7 @@ func (manifest *Manifest) AddAttestation(ctx context.Context, signer dsse.Signer
func createSignedImageLayer(ctx context.Context, statement *intoto.Statement, signer dsse.SignerVerifier, opts *SigningOptions) (*Layer, error) {
// sign the statement
env, err := SignInTotoStatement(ctx, statement, signer, opts)
env, err := signInTotoStatement(ctx, statement, signer, opts)
if err != nil {
return nil, fmt.Errorf("failed to sign statement: %w", err)
}
@@ -128,7 +142,7 @@ func createSignedImageLayer(ctx context.Context, statement *intoto.Statement, si
}, nil
}
func SignInTotoStatement(ctx context.Context, statement *intoto.Statement, signer dsse.SignerVerifier, opts *SigningOptions) (*Envelope, error) {
func signInTotoStatement(ctx context.Context, statement *intoto.Statement, signer dsse.SignerVerifier, opts *SigningOptions) (*Envelope, error) {
payload, err := json.Marshal(statement)
if err != nil {
return nil, fmt.Errorf("failed to marshal statement: %w", err)
@@ -140,12 +154,12 @@ func SignInTotoStatement(ctx context.Context, statement *intoto.Statement, signe
return env, nil
}
func UpdateIndexImage(
func updateImageIndex(
idx v1.ImageIndex,
manifest *Manifest,
options ...func(*ManifestImageOptions) error,
) (v1.ImageIndex, error) {
image, err := manifest.BuildAttestationImage(options...)
image, err := manifest.BuildImage(options...)
if err != nil {
return nil, fmt.Errorf("failed to build image: %w", err)
}
@@ -170,7 +184,7 @@ func UpdateIndexImage(
func UpdateIndexImages(idx v1.ImageIndex, manifest []*Manifest, options ...func(*ManifestImageOptions) error) (v1.ImageIndex, error) {
var err error
for _, m := range manifest {
idx, err = UpdateIndexImage(idx, m, options...)
idx, err = updateImageIndex(idx, m, options...)
if err != nil {
return nil, fmt.Errorf("failed to add image to index: %w", err)
}
@@ -204,7 +218,7 @@ func WithReplacedLayers(replaceLayers bool) func(*ManifestImageOptions) error {
}
// build an image with signed attestations, optionally replacing existing layers with signed layers.
func (manifest *Manifest) BuildAttestationImage(options ...func(*ManifestImageOptions) error) (v1.Image, error) {
func (manifest *Manifest) BuildImage(options ...func(*ManifestImageOptions) error) (v1.Image, error) {
opts, err := newOptions(options...)
if err != nil {
return nil, fmt.Errorf("failed to create options: %w", err)
@@ -229,7 +243,7 @@ func (manifest *Manifest) BuildAttestationImage(options ...func(*ManifestImageOp
}
// so that we attach all attestations to a single attestations image - as per current buildkit
opts.laxReferrers = true
newImg, err := buildImage(resultLayers, manifest.OriginalDescriptor, manifest.SubjectDescriptor, opts)
newImg, err := buildImageFromLayers(resultLayers, manifest.OriginalDescriptor, manifest.SubjectDescriptor, opts)
if err != nil {
return nil, fmt.Errorf("failed to build image: %w", err)
}
@@ -241,7 +255,7 @@ func (manifest *Manifest) BuildReferringArtifacts() ([]v1.Image, error) {
var images []v1.Image
for _, layer := range manifest.SignedLayers {
opts := &ManifestImageOptions{}
newImg, err := buildImage([]*Layer{layer}, manifest.OriginalDescriptor, manifest.SubjectDescriptor, opts)
newImg, err := buildImageFromLayers([]*Layer{layer}, manifest.OriginalDescriptor, manifest.SubjectDescriptor, opts)
if err != nil {
return nil, fmt.Errorf("failed to build image: %w", err)
}
@@ -250,8 +264,8 @@ func (manifest *Manifest) BuildReferringArtifacts() ([]v1.Image, error) {
return images, nil
}
// build an image containing only layers.
func buildImage(layers []*Layer, manifest *v1.Descriptor, subject *v1.Descriptor, opts *ManifestImageOptions) (v1.Image, error) {
// build an image containing only layers provided.
func buildImageFromLayers(layers []*Layer, manifest *v1.Descriptor, subject *v1.Descriptor, opts *ManifestImageOptions) (v1.Image, error) {
newImg := empty.Image
var err error
if len(layers) == 0 {
@@ -296,46 +310,135 @@ func buildImage(layers []*Layer, manifest *v1.Descriptor, subject *v1.Descriptor
}
if !opts.laxReferrers {
// as per https://github.com/opencontainers/image-spec/blob/main/manifest.md#guidance-for-an-empty-descriptor
newImg = &EmptyConfigImage{newImg}
newImg = &oci.EmptyConfigImage{Image: newImg}
}
return newImg, nil
}
type EmptyConfigImage struct {
v1.Image
}
func (i *EmptyConfigImage) RawConfigFile() ([]byte, error) {
return []byte("{}"), nil
}
func (i *EmptyConfigImage) Manifest() (*v1.Manifest, error) {
mf, err := i.Image.Manifest()
func ExtractEnvelopes(manifest *Manifest, predicateType string) ([]*Envelope, error) {
var envs []*Envelope
dsseMediaType, err := DSSEMediaType(predicateType)
if err != nil {
return nil, fmt.Errorf("failed to get manifest: %w", err)
return nil, fmt.Errorf("failed to get DSSE media type for predicate '%s': %w", predicateType, err)
}
mf.Config = v1.Descriptor{
MediaType: "application/vnd.oci.empty.v1+json",
Size: 2,
Digest: v1.Hash{Algorithm: "sha256", Hex: "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"},
Data: []byte("{}"),
for _, attestationLayer := range manifest.OriginalLayers {
mt, err := attestationLayer.Layer.MediaType()
if err != nil {
return nil, fmt.Errorf("failed to get layer media type: %w", err)
}
if string(mt) == dsseMediaType {
reader, err := attestationLayer.Layer.Uncompressed()
if err != nil {
return nil, fmt.Errorf("failed to get layer contents: %w", err)
}
defer reader.Close()
env := new(Envelope)
err = json.NewDecoder(reader).Decode(&env)
if err != nil {
return nil, fmt.Errorf("failed to decode envelope: %w", err)
}
envs = append(envs, env)
}
}
return mf, nil
return envs, nil
}
func (i *EmptyConfigImage) RawManifest() ([]byte, error) {
mf, err := i.Manifest()
func ExtractStatementsFromIndex(idx v1.ImageIndex, mediaType string) ([]*AnnotatedStatement, error) {
mfs2, err := idx.IndexManifest()
if err != nil {
return nil, fmt.Errorf("failed to get manifest: %w", err)
return nil, fmt.Errorf("failed to extract IndexManifest from ImageIndex: %w", err)
}
return json.Marshal(mf)
var statements []*AnnotatedStatement
for i := range mfs2.Manifests {
mf := &mfs2.Manifests[i]
if mf.Annotations[DockerReferenceType] != "attestation-manifest" {
continue
}
attestationImage, err := idx.Image(mf.Digest)
if err != nil {
return nil, fmt.Errorf("failed to extract attestation image with digest %s: %w", mf.Digest.String(), err)
}
layers, err := attestationImage.Layers()
if err != nil {
return nil, fmt.Errorf("failed to extract layers from attestation image: %w", err)
}
for _, layer := range layers {
// parse layer blob as json
mt, err := layer.MediaType()
if err != nil {
return nil, fmt.Errorf("failed to get layer media type: %w", err)
}
if string(mt) != mediaType {
continue
}
r, err := layer.Uncompressed()
if err != nil {
return nil, fmt.Errorf("failed to get layer contents: %w", err)
}
defer r.Close()
inTotoStatement := new(intoto.Statement)
var desc *v1.Descriptor
if strings.HasSuffix(string(mt), "+dsse") {
env := new(Envelope)
err = json.NewDecoder(r).Decode(env)
if err != nil {
return nil, fmt.Errorf("failed to decode env: %w", err)
}
payload, err := base64.StdEncoding.Strict().DecodeString(env.Payload)
if err != nil {
return nil, fmt.Errorf("failed to decode payload: %w", err)
}
err = json.Unmarshal([]byte(payload), inTotoStatement)
if err != nil {
return nil, fmt.Errorf("failed to decode %s statement: %w", mediaType, err)
}
} else {
desc := new(v1.Descriptor)
err = json.NewDecoder(r).Decode(desc)
if err != nil {
return nil, fmt.Errorf("failed to decode statement: %w", err)
}
}
layerDesc, err := partial.Descriptor(layer)
if err != nil {
return nil, fmt.Errorf("failed to get descriptor for layer: %w", err)
}
annotations := make(map[string]string)
for k, v := range layerDesc.Annotations {
annotations[k] = v
}
statements = append(statements, &AnnotatedStatement{
OCIDescriptor: desc,
InTotoStatement: inTotoStatement,
Annotations: annotations,
})
}
}
return statements, nil
}
func (i *EmptyConfigImage) Digest() (v1.Hash, error) {
mb, err := i.RawManifest()
func ExtractAnnotatedStatements(path string, mediaType string) ([]*AnnotatedStatement, error) {
idx, err := layout.ImageIndexFromPath(path)
if err != nil {
return v1.Hash{}, err
return nil, fmt.Errorf("failed to load image index: %w", err)
}
digest, _, err := v1.SHA256(bytes.NewReader(mb))
return digest, err
idxm, err := idx.IndexManifest()
if err != nil {
return nil, fmt.Errorf("failed to get digest: %w", err)
}
idxDigest := idxm.Manifests[0].Digest
mfs, err := idx.ImageIndex(idxDigest)
if err != nil {
return nil, fmt.Errorf("failed to extract ImageIndex for digest %s: %w", idxDigest.String(), err)
}
return ExtractStatementsFromIndex(mfs, mediaType)
}

View File

@@ -1,21 +1,18 @@
package test
package attestation_test
import (
"path/filepath"
"testing"
"github.com/docker/attest/internal/test"
"github.com/docker/attest/pkg/attestation"
intoto "github.com/in-toto/in-toto-golang/in_toto"
"github.com/stretchr/testify/assert"
)
var UnsignedTestImage = filepath.Join("..", "..", "test", "testdata", "unsigned-test-image")
const (
ExpectedStatements = 4
)
const ExpectedStatements = 4
func TestExtractAnnotatedStatements(t *testing.T) {
statements, err := ExtractAnnotatedStatements(UnsignedTestImage, intoto.PayloadType)
statements, err := attestation.ExtractAnnotatedStatements(test.UnsignedTestImage, intoto.PayloadType)
assert.NoError(t, err)
assert.Equalf(t, len(statements), ExpectedStatements, "expected %d statement, got %d", ExpectedStatements, len(statements))
}

View File

@@ -4,7 +4,6 @@ import (
"context"
"time"
"github.com/docker/attest/pkg/attest"
"github.com/docker/attest/pkg/attestation"
"github.com/docker/attest/pkg/oci"
"github.com/docker/attest/pkg/signerverifier"
@@ -62,13 +61,13 @@ func ExampleManifest() {
}
// create a new manifest to hold the attestation
manifest, err := attest.NewAttestationManifest(desc)
manifest, err := attestation.NewManifest(desc)
if err != nil {
panic(err)
}
// sign and add the attestation to the manifest
err = manifest.AddAttestation(context.Background(), signer, statement, opts)
err = manifest.Add(context.Background(), signer, statement, opts)
if err != nil {
panic(err)
}
@@ -79,7 +78,11 @@ func ExampleManifest() {
}
// save the manifest to the registry as a referrers artifact
err = oci.SaveReferrers(manifest, output)
artifacts, err := manifest.BuildReferringArtifacts()
if err != nil {
panic(err)
}
err = oci.SaveImagesNoTag(artifacts, output)
if err != nil {
panic(err)
}

View File

@@ -1,36 +1,35 @@
package oci
package attestation
import (
"context"
"encoding/json"
"fmt"
"github.com/docker/attest/pkg/attestation"
att "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/layout"
)
// implementation of AttestationResolver that closes over attestations from an oci layout.
// implementation of Resolver that closes over attestations from an oci layout.
type LayoutResolver struct {
*attestation.Manifest
*ImageSpec
*Manifest
*oci.ImageSpec
}
func NewOCILayoutAttestationResolver(src *ImageSpec) (*LayoutResolver, error) {
func NewOCILayoutResolver(src *oci.ImageSpec) (*LayoutResolver, error) {
r := &LayoutResolver{
ImageSpec: src,
}
_, err := r.fetchAttestationManifest()
_, err := r.fetchManifest()
if err != nil {
return nil, err
}
return r, nil
}
func (r *LayoutResolver) fetchAttestationManifest() (*attestation.Manifest, error) {
func (r *LayoutResolver) fetchManifest() (*Manifest, error) {
if r.Manifest == nil {
m, err := attestationManifestFromOCILayout(r.Identifier, r.ImageSpec.Platform)
m, err := manifestFromOCILayout(r.Identifier, r.ImageSpec.Platform)
if err != nil {
return nil, err
}
@@ -40,9 +39,9 @@ func (r *LayoutResolver) fetchAttestationManifest() (*attestation.Manifest, erro
return r.Manifest, nil
}
func (r *LayoutResolver) Attestations(_ context.Context, predicateType string) ([]*att.Envelope, error) {
var envs []*att.Envelope
dsseMediaType, err := attestation.DSSEMediaType(predicateType)
func (r *LayoutResolver) Attestations(_ context.Context, predicateType string) ([]*Envelope, error) {
var envs []*Envelope
dsseMediaType, err := DSSEMediaType(predicateType)
if err != nil {
return nil, fmt.Errorf("failed to get DSSE media type for predicate '%s': %w", predicateType, err)
}
@@ -55,7 +54,7 @@ func (r *LayoutResolver) Attestations(_ context.Context, predicateType string) (
if mts != dsseMediaType {
continue
}
env := new(att.Envelope)
env := new(Envelope)
// parse layer blob as json
r, err := attestationLayer.Layer.Uncompressed()
if err != nil {
@@ -83,7 +82,7 @@ func (r *LayoutResolver) ImagePlatform(_ context.Context) (*v1.Platform, error)
return r.ImageSpec.Platform, nil
}
func attestationManifestFromOCILayout(path string, platform *v1.Platform) (*attestation.Manifest, error) {
func manifestFromOCILayout(path string, platform *v1.Platform) (*Manifest, error) {
idx, err := layout.ImageIndexFromPath(path)
if err != nil {
return nil, err
@@ -120,11 +119,11 @@ func attestationManifestFromOCILayout(path string, platform *v1.Platform) (*atte
}
for i := range mfs2.Manifests {
mf := &mfs2.Manifests[i]
if mf.Annotations[att.DockerReferenceType] != attestation.AttestationManifestType {
if mf.Annotations[DockerReferenceType] != AttestationManifestType {
continue
}
if mf.Annotations[att.DockerReferenceDigest] != subjectDescriptor.Digest.String() {
if mf.Annotations[DockerReferenceDigest] != subjectDescriptor.Digest.String() {
continue
}
@@ -132,11 +131,11 @@ func attestationManifestFromOCILayout(path string, platform *v1.Platform) (*atte
if err != nil {
return nil, fmt.Errorf("failed to extract attestation image with digest %s: %w", mf.Digest.String(), err)
}
layers, err := attestation.GetAttestationsFromImage(attestationImage)
layers, err := layersFromImage(attestationImage)
if err != nil {
return nil, fmt.Errorf("failed to get attestations from image: %w", err)
}
attest := &attestation.Manifest{
attest := &Manifest{
OriginalLayers: layers,
OriginalDescriptor: mf,
SubjectName: idxDescriptor.Annotations["org.opencontainers.image.ref.name"],

View File

@@ -1,4 +1,4 @@
package oci_test
package attestation_test
import (
"strings"
@@ -24,7 +24,7 @@ func TestAttestationFromOCILayout(t *testing.T) {
}
opts := &attestation.SigningOptions{}
attIdx, err := oci.IndexFromPath(oci.UnsignedTestImage)
attIdx, err := oci.IndexFromPath(test.UnsignedTestImage)
require.NoError(t, err)
signedManifests, err := attest.SignStatements(ctx, attIdx.Index, signer, opts)
require.NoError(t, err)

View File

@@ -1,17 +1,17 @@
package oci
package attestation
import (
"context"
"github.com/docker/attest/pkg/attestation"
"github.com/docker/attest/pkg/oci"
v1 "github.com/google/go-containerregistry/pkg/v1"
)
type MockResolver struct {
Envs []*attestation.Envelope
Envs []*Envelope
}
func (r MockResolver) Attestations(_ context.Context, _ string) ([]*attestation.Envelope, error) {
func (r MockResolver) Attestations(_ context.Context, _ string) ([]*Envelope, error) {
return r.Envs, nil
}
@@ -32,7 +32,7 @@ func (r MockResolver) ImageDescriptor(_ context.Context) (*v1.Descriptor, error)
}
func (r MockResolver) ImagePlatform(_ context.Context) (*v1.Platform, error) {
return ParsePlatform("linux/amd64")
return oci.ParsePlatform("linux/amd64")
}
type MockRegistryResolver struct {

View File

@@ -1,22 +1,21 @@
package oci
package attestation
import (
"context"
"fmt"
"strings"
"github.com/docker/attest/pkg/attestation"
att "github.com/docker/attest/pkg/attestation"
"github.com/docker/attest/pkg/oci"
"github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/v1/remote"
)
type ReferrersResolver struct {
referrersRepo string
ImageDetailsResolver
oci.ImageDetailsResolver
}
func NewReferrersAttestationResolver(src ImageDetailsResolver, options ...func(*ReferrersResolver) error) (*ReferrersResolver, error) {
func NewReferrersResolver(src oci.ImageDetailsResolver, options ...func(*ReferrersResolver) error) (*ReferrersResolver, error) {
res := &ReferrersResolver{
ImageDetailsResolver: src,
}
@@ -36,8 +35,8 @@ func WithReferrersRepo(repo string) func(*ReferrersResolver) error {
}
}
func (r *ReferrersResolver) resolveAttestations(ctx context.Context, predicateType string) ([]*attestation.Manifest, error) {
dsseMediaType, err := attestation.DSSEMediaType(predicateType)
func (r *ReferrersResolver) resolveAttestations(ctx context.Context, predicateType string) ([]*Manifest, error) {
dsseMediaType, err := DSSEMediaType(predicateType)
if err != nil {
return nil, fmt.Errorf("failed to get DSSE media type for predicate '%s': %w", predicateType, err)
}
@@ -54,19 +53,16 @@ func (r *ReferrersResolver) resolveAttestations(ctx context.Context, predicateTy
return nil, fmt.Errorf("failed to get descriptor: %w", err)
}
subjectDigest := desc.Digest.String()
if err != nil {
return nil, fmt.Errorf("failed to get digest: %w", err)
}
var referrersSubjectRef name.Digest
if r.referrersRepo != "" {
referrersSubjectRef, err = name.NewDigest(fmt.Sprintf("%s@%s", strings.TrimPrefix(r.referrersRepo, RegistryPrefix), subjectDigest))
referrersSubjectRef, err = name.NewDigest(fmt.Sprintf("%s@%s", strings.TrimPrefix(r.referrersRepo, oci.RegistryPrefix), subjectDigest))
if err != nil {
return nil, fmt.Errorf("failed to create referrers reference: %w", err)
}
} else {
referrersSubjectRef = subjectRef.Context().Digest(subjectDigest)
}
options := WithOptions(ctx, nil)
options := oci.WithOptions(ctx, nil)
options = append(options, remote.WithFilter("artifactType", dsseMediaType))
referrersIndex, err := remote.Referrers(referrersSubjectRef, options...)
if err != nil {
@@ -76,16 +72,16 @@ func (r *ReferrersResolver) resolveAttestations(ctx context.Context, predicateTy
if err != nil {
return nil, fmt.Errorf("failed to get index manifest: %w", err)
}
aManifests := make([]*attestation.Manifest, 0)
aManifests := make([]*Manifest, 0)
for i := range referrersIndexManifest.Manifests {
m := referrersIndexManifest.Manifests[i]
remoteRef := referrersSubjectRef.Context().Digest(m.Digest.String())
options = WithOptions(ctx, nil)
options = oci.WithOptions(ctx, nil)
attestationImage, err := remote.Image(remoteRef, options...)
if err != nil {
return nil, fmt.Errorf("failed to get referred image: %w", err)
}
layers, err := attestation.GetAttestationsFromImage(attestationImage)
layers, err := layersFromImage(attestationImage)
if err != nil {
return nil, fmt.Errorf("failed to get attestations from image: %w", err)
}
@@ -99,7 +95,7 @@ func (r *ReferrersResolver) resolveAttestations(ctx context.Context, predicateTy
if string(mt) != dsseMediaType {
return nil, fmt.Errorf("expected layer media type %s, got %s", dsseMediaType, mt)
}
attest := &attestation.Manifest{
attest := &Manifest{
SubjectName: imageName,
OriginalLayers: layers,
OriginalDescriptor: &m,
@@ -110,12 +106,12 @@ func (r *ReferrersResolver) resolveAttestations(ctx context.Context, predicateTy
return aManifests, nil
}
func (r *ReferrersResolver) Attestations(ctx context.Context, predicateType string) ([]*att.Envelope, error) {
func (r *ReferrersResolver) Attestations(ctx context.Context, predicateType string) ([]*Envelope, error) {
manifests, err := r.resolveAttestations(ctx, predicateType)
if err != nil {
return nil, fmt.Errorf("failed to resolve attestations: %w", err)
}
var envs []*att.Envelope
var envs []*Envelope
for _, attest := range manifests {
es, err := ExtractEnvelopes(attest, predicateType)
if err != nil {

View File

@@ -8,7 +8,6 @@ import (
"testing"
"github.com/docker/attest/internal/test"
"github.com/docker/attest/internal/util"
"github.com/docker/attest/pkg/attest"
"github.com/docker/attest/pkg/attestation"
"github.com/docker/attest/pkg/config"
@@ -16,14 +15,12 @@ import (
"github.com/docker/attest/pkg/policy"
"github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/registry"
"github.com/google/go-containerregistry/pkg/v1/empty"
"github.com/google/go-containerregistry/pkg/v1/remote"
"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")
PassPolicyDir = filepath.Join("..", "..", "test", "testdata", "local-policy-pass")
LocalPolicy = filepath.Join("..", "..", "test", "testdata", "local-policy")
@@ -94,7 +91,7 @@ func TestAttestationReferenceTypes(t *testing.T) {
opts := &attestation.SigningOptions{
SkipTL: true,
}
attIdx, err := oci.IndexFromPath(UnsignedTestImage)
attIdx, err := oci.IndexFromPath(test.UnsignedTestImage)
require.NoError(t, err)
indexName := fmt.Sprintf("%s/repo:root", u.Host)
@@ -120,7 +117,9 @@ func TestAttestationReferenceTypes(t *testing.T) {
output, err := oci.ParseImageSpec(outputRepo)
require.NoError(t, err)
for _, attIdx := range signedManifests {
err = oci.SaveReferrers(attIdx, []*oci.ImageSpec{output})
images, err := attIdx.BuildReferringArtifacts()
require.NoError(t, err)
err = oci.SaveImagesNoTag(images, []*oci.ImageSpec{output})
require.NoError(t, err)
}
@@ -213,7 +212,7 @@ func TestReferencesInDifferentRepo(t *testing.T) {
opts := &attestation.SigningOptions{
SkipTL: true,
}
attIdx, err := oci.IndexFromPath(UnsignedTestImage)
attIdx, err := oci.IndexFromPath(test.UnsignedTestImage)
require.NoError(t, err)
indexName := fmt.Sprintf("%s/%s:latest", serverURL.Host, repoName)
@@ -226,7 +225,7 @@ func TestReferencesInDifferentRepo(t *testing.T) {
// push signed attestation image to the ref server
for _, signedManifest := range signedManifests {
// push references using subject-digest.att convention
image, err := signedManifest.BuildAttestationImage()
image, err := signedManifest.BuildImage()
require.NoError(t, err)
err = oci.PushImageToRegistry(image, fmt.Sprintf("%s/%s:tag-does-not-matter", refServerURL.Host, repoName))
require.NoError(t, err)
@@ -239,7 +238,7 @@ func TestReferencesInDifferentRepo(t *testing.T) {
opts := &attestation.SigningOptions{
SkipTL: true,
}
attIdx, err := oci.IndexFromPath(UnsignedTestImage)
attIdx, err := oci.IndexFromPath(test.UnsignedTestImage)
require.NoError(t, err)
indexName := fmt.Sprintf("%s/%s:latest", serverURL.Host, repoName)
@@ -294,7 +293,7 @@ func TestCorrectArtifactTypeInTagFallback(t *testing.T) {
opts := &attestation.SigningOptions{
SkipTL: true,
}
attIdx, err := oci.IndexFromPath(UnsignedTestImage)
attIdx, err := oci.IndexFromPath(test.UnsignedTestImage)
require.NoError(t, err)
indexName := fmt.Sprintf("%s/%s:latest", serverURL.Host, repoName)
@@ -327,14 +326,3 @@ func TestCorrectArtifactTypeInTagFallback(t *testing.T) {
}
}
}
func TestEmptyConfigImageDigest(t *testing.T) {
empty := empty.Image
img := attestation.EmptyConfigImage{empty}
mf, err := img.RawManifest()
require.NoError(t, err)
hash := util.SHA256Hex(mf)
digest, err := img.Digest()
require.NoError(t, err)
assert.Equal(t, digest.Hex, hash)
}

101
pkg/attestation/registry.go Normal file
View File

@@ -0,0 +1,101 @@
package attestation
import (
"context"
"fmt"
"github.com/docker/attest/pkg/oci"
"github.com/google/go-containerregistry/pkg/name"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/remote"
)
type RegistryResolver struct {
*oci.RegistryImageDetailsResolver
*Manifest
}
func NewRegistryResolver(src *oci.RegistryImageDetailsResolver) (*RegistryResolver, error) {
return &RegistryResolver{
RegistryImageDetailsResolver: src,
}, nil
}
func (r *RegistryResolver) Attestations(ctx context.Context, predicateType string) ([]*Envelope, error) {
if r.Manifest == nil {
attest, err := FetchManifest(ctx, r.Identifier, r.ImageSpec.Platform)
if err != nil {
return nil, err
}
r.Manifest = attest
}
return ExtractEnvelopes(r.Manifest, predicateType)
}
func attestationDigestForImage(ix *v1.IndexManifest, imageDigest string, attestType string) (string, error) {
for i := range ix.Manifests {
m := &ix.Manifests[i]
if v, ok := m.Annotations[DockerReferenceType]; ok && v == attestType {
if d, ok := m.Annotations[DockerReferenceDigest]; ok && d == imageDigest {
return m.Digest.String(), nil
}
}
}
return "", fmt.Errorf("no attestation found for image %s", imageDigest)
}
func FetchManifest(ctx context.Context, image string, platform *v1.Platform) (*Manifest, error) {
// we want to get to the image index, so ignoring platform for now
options := oci.WithOptions(ctx, nil)
ref, err := name.ParseReference(image)
if err != nil {
return nil, fmt.Errorf("failed to parse reference: %w", err)
}
index, err := remote.Index(ref, options...)
if err != nil {
return nil, fmt.Errorf("failed to get index: %w", err)
}
indexManifest, err := index.IndexManifest()
if err != nil {
return nil, fmt.Errorf("failed to get index manifest: %w", err)
}
subjectDescriptor, err := oci.ImageDescriptor(indexManifest, platform)
if err != nil {
return nil, fmt.Errorf("failed to obtain image for platform: %w", err)
}
digest := subjectDescriptor.Digest.String()
ref, err = name.ParseReference(fmt.Sprintf("%s@%s", ref.Context().Name(), digest))
if err != nil {
return nil, fmt.Errorf("failed to parse attestation reference: %w", err)
}
attestationDigest, err := attestationDigestForImage(indexManifest, digest, "attestation-manifest")
if err != nil {
return nil, fmt.Errorf("failed to obtain attestation for image: %w", err)
}
ref, err = name.ParseReference(fmt.Sprintf("%s@%s", ref.Context().Name(), attestationDigest))
if err != nil {
return nil, fmt.Errorf("failed to parse attestation reference: %w", err)
}
remoteDescriptor, err := remote.Get(ref, options...)
if err != nil {
return nil, fmt.Errorf("failed to get attestation: %w", err)
}
attestationImage, err := remoteDescriptor.Image()
if err != nil {
return nil, fmt.Errorf("failed to get attestation image: %w", err)
}
layers, err := layersFromImage(attestationImage)
if err != nil {
return nil, fmt.Errorf("failed to get attestations from image: %w", err)
}
attest := &Manifest{
OriginalLayers: layers,
OriginalDescriptor: &remoteDescriptor.Descriptor,
SubjectName: image,
SubjectDescriptor: subjectDescriptor,
}
return attest, nil
}

View File

@@ -1,4 +1,4 @@
package oci_test
package attestation_test
import (
"fmt"
@@ -25,7 +25,7 @@ func TestRegistry(t *testing.T) {
require.NoError(t, err)
opts := &attestation.SigningOptions{}
attIdx, err := oci.IndexFromPath(oci.UnsignedTestImage)
attIdx, err := oci.IndexFromPath(test.UnsignedTestImage)
require.NoError(t, err)
signedManifests, err := attest.SignStatements(ctx, attIdx.Index, signer, opts)
require.NoError(t, err)

View File

@@ -0,0 +1,12 @@
package attestation
import (
"context"
"github.com/docker/attest/pkg/oci"
)
type Resolver interface {
oci.ImageDetailsResolver
Attestations(ctx context.Context, mediaType string) ([]*Envelope, error)
}

View File

@@ -6,12 +6,19 @@ import (
"crypto/rand"
"encoding/json"
"fmt"
"net/http/httptest"
"net/url"
"testing"
"time"
"github.com/docker/attest/internal/test"
"github.com/docker/attest/pkg/attestation"
"github.com/docker/attest/pkg/oci"
"github.com/docker/attest/pkg/signerverifier"
"github.com/google/go-containerregistry/pkg/registry"
v1 "github.com/google/go-containerregistry/pkg/v1"
"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/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -149,3 +156,146 @@ func TestSignVerifyAttestation(t *testing.T) {
})
}
}
func TestAddSignedLayerAnnotations(t *testing.T) {
ctx, signer := test.Setup(t)
testCases := []struct {
name string
replace bool
}{
{"replaced", true},
{"not replaced", false},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
data := []byte("signed")
testLayer := static.NewLayer(data, types.MediaType(intoto.PayloadType))
mediaType := types.OCIManifestSchema1
opts := &attestation.SigningOptions{}
originalLayer := &attestation.Layer{
Layer: testLayer,
Statement: &intoto.Statement{
StatementHeader: intoto.StatementHeader{
PredicateType: attestation.VSAPredicateType,
},
},
Annotations: map[string]string{"test": "test"},
}
manifest := &attestation.Manifest{
OriginalDescriptor: &v1.Descriptor{
MediaType: mediaType,
},
OriginalLayers: []*attestation.Layer{
originalLayer,
},
SubjectDescriptor: &v1.Descriptor{},
}
err := manifest.Add(ctx, signer, originalLayer.Statement, opts)
require.NoError(t, err)
newImg, err := manifest.BuildImage(attestation.WithReplacedLayers(tc.replace))
require.NoError(t, err)
mf, _ := newImg.RawManifest()
type Annotations struct {
Annotations map[string]string `json:"annotations"`
}
type Layers struct {
Layers []Annotations `json:"layers"`
}
l := &Layers{}
err = json.Unmarshal(mf, l)
require.NoError(t, err)
_, ok := l.Layers[0].Annotations["test"]
assert.Truef(t, ok, "missing annotations")
})
}
}
func TestSimpleStatementSigning(t *testing.T) {
ctx, signer := test.Setup(t)
empty := types.MediaType("application/vnd.oci.empty.v1+json")
testCases := []struct {
name string
replace bool
}{
{"replaced", true},
{"not replaced", false},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
opts := &attestation.SigningOptions{}
statement := &intoto.Statement{
StatementHeader: intoto.StatementHeader{
PredicateType: attestation.VSAPredicateType,
},
}
statement2 := &intoto.Statement{
StatementHeader: intoto.StatementHeader{
PredicateType: attestation.VSAPredicateType,
},
}
digest, err := v1.NewHash("sha256:da8b190665956ea07890a0273e2a9c96bfe291662f08e2860e868eef69c34620")
require.NoError(t, err)
subject := &v1.Descriptor{
MediaType: "application/vnd.oci.image.manifest.v1+json",
Digest: digest,
}
manifest, err := attestation.NewManifest(subject)
require.NoError(t, err)
err = manifest.Add(ctx, signer, statement, opts)
require.NoError(t, err)
err = manifest.Add(ctx, signer, statement2, opts)
require.NoError(t, err)
// fake that the manfifest was loaded from a real image
manifest.OriginalLayers = manifest.SignedLayers
envelopes, err := attestation.ExtractEnvelopes(manifest, attestation.VSAPredicateType)
require.NoError(t, err)
assert.Len(t, envelopes, 2)
newImg, err := manifest.BuildImage(attestation.WithReplacedLayers(tc.replace))
require.NoError(t, err)
layers, err := newImg.Layers()
require.NoError(t, err)
if tc.replace {
assert.Len(t, layers, 2)
} else {
assert.Len(t, layers, 4)
}
newImgs, err := manifest.BuildReferringArtifacts()
require.NoError(t, err)
assert.Len(t, newImgs, 2)
for _, img := range newImgs {
mf, err := img.Manifest()
require.NoError(t, err)
assert.Contains(t, mf.ArtifactType, "application/vnd.in-toto")
assert.Contains(t, mf.ArtifactType, "+dsse")
assert.Equal(t, subject.MediaType, mf.MediaType)
assert.Equal(t, empty, mf.Config.MediaType)
assert.Equal(t, int64(2), mf.Config.Size)
assert.Equal(t, "{}", string(mf.Config.Data))
layers, err := img.Layers()
require.NoError(t, err)
assert.Len(t, layers, 1)
}
server := httptest.NewServer(registry.New(registry.WithReferrersSupport(true)))
defer server.Close()
u, err := url.Parse(server.URL)
require.NoError(t, err)
indexName := fmt.Sprintf("%s/repo:root", u.Host)
output, err := oci.ParseImageSpecs(indexName)
require.NoError(t, err)
artifacts, err := manifest.BuildReferringArtifacts()
require.NoError(t, err)
err = oci.SaveImagesNoTag(artifacts, output)
require.NoError(t, err)
})
}
}

View File

@@ -64,6 +64,12 @@ type Extension struct {
Ext *DockerDSSEExtension `json:"ext"`
}
type AnnotatedStatement struct {
OCIDescriptor *v1.Descriptor
InTotoStatement *intoto.Statement
Annotations map[string]string
}
type DockerDSSEExtension struct {
TL *DockerTLExtension `json:"tl"`
}
@@ -83,6 +89,12 @@ type SigningOptions struct {
SkipTL bool
}
type Options struct {
NoReferrers bool
Attach bool
ReferrersRepo string
}
func DSSEMediaType(predicateType string) (string, error) {
var predicateName string
switch predicateType {

View File

@@ -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 {

View File

@@ -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)
}

2
pkg/mirror/README.md Normal file
View File

@@ -0,0 +1,2 @@
## mirror
This package contains components to mirror TUF metadata and targets to OCI.

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"strconv"
"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/mutate"
@@ -17,7 +18,7 @@ import (
// -----------------
// GetMetadataManifest returns an image with TUF root metadata as layers.
func (m *TUFMirror) GetMetadataManifest(metadataURL string) (v1.Image, error) {
func (m *TUFMirror) GetMetadataManifest(metadataURL string) (*oci.EmptyConfigImage, error) {
metadata, err := m.getMetadataMirror(metadataURL)
if err != nil {
return nil, fmt.Errorf("failed to get metadata: %w", err)
@@ -26,7 +27,7 @@ func (m *TUFMirror) GetMetadataManifest(metadataURL string) (v1.Image, error) {
if err != nil {
return nil, fmt.Errorf("failed to build metadata manifest: %w", err)
}
return manifest, nil
return &oci.EmptyConfigImage{Image: manifest}, nil
}
// getMetadataMirror returns a TufMetadata struct with TUF metadata as map of file names to bytes.
@@ -183,7 +184,7 @@ func (m *TUFMirror) buildDelegatedMetadataManifests(delegated []DelegatedTargetM
if err != nil {
return nil, fmt.Errorf("failed to append delegated targets layer to image: %w", err)
}
manifests = append(manifests, &Image{Image: img, Tag: role.Name})
manifests = append(manifests, &Image{Image: &oci.EmptyConfigImage{Image: img}, Tag: role.Name})
}
return manifests, nil
}

View File

@@ -16,15 +16,20 @@ import (
"github.com/theupdateframework/go-tuf/v2/metadata"
)
const (
metadataPath = "/metadata"
targetsPath = "/targets"
)
func TestGetTufMetadataMirror(t *testing.T) {
server := httptest.NewServer(http.FileServer(http.Dir(filepath.Join("..", "..", "test", "testdata", "tuf", "test-repo"))))
defer server.Close()
path := test.CreateTempDir(t, "", "tuf_temp")
m, err := NewTUFMirror(embed.RootDev.Data, path, server.URL+"/metadata", server.URL+"/targets", tuf.NewMockVersionChecker())
m, err := NewTUFMirror(embed.RootDev.Data, path, server.URL+metadataPath, server.URL+targetsPath, tuf.NewMockVersionChecker())
assert.NoError(t, err)
tufMetadata, err := m.getMetadataMirror(server.URL + "/metadata")
tufMetadata, err := m.getMetadataMirror(server.URL + metadataPath)
assert.NoError(t, err)
// check that all roles are not empty
@@ -39,10 +44,10 @@ func TestGetMetadataManifest(t *testing.T) {
defer server.Close()
path := test.CreateTempDir(t, "", "tuf_temp")
m, err := NewTUFMirror(embed.RootDev.Data, path, server.URL+"/metadata", server.URL+"/targets", tuf.NewMockVersionChecker())
m, err := NewTUFMirror(embed.RootDev.Data, path, server.URL+metadataPath, server.URL+targetsPath, tuf.NewMockVersionChecker())
assert.NoError(t, err)
img, err := m.GetMetadataManifest(server.URL + "/metadata")
img, err := m.GetMetadataManifest(server.URL + metadataPath)
assert.NoError(t, err)
assert.NotNil(t, img)
@@ -78,7 +83,7 @@ func TestGetDelegatedMetadataMirrors(t *testing.T) {
defer server.Close()
path := test.CreateTempDir(t, "", "tuf_temp")
m, err := NewTUFMirror(embed.RootDev.Data, path, server.URL+"/metadata", server.URL+"/targets", tuf.NewMockVersionChecker())
m, err := NewTUFMirror(embed.RootDev.Data, path, server.URL+metadataPath, server.URL+targetsPath, tuf.NewMockVersionChecker())
assert.NoError(t, err)
delegations, err := m.GetDelegatedMetadataMirrors()

View File

@@ -5,6 +5,7 @@ import (
"path/filepath"
"strings"
"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/mutate"
@@ -22,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)
}
@@ -37,12 +38,12 @@ 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)
}
targetMirrors = append(targetMirrors, &Image{Image: img, Tag: name})
targetMirrors = append(targetMirrors, &Image{Image: &oci.EmptyConfigImage{Image: img}, Tag: name})
}
return targetMirrors, nil
}
@@ -68,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)
}
@@ -88,14 +89,15 @@ 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)
}
emptyConfigImage := &oci.EmptyConfigImage{Image: img}
// append image to index with annotation
index = mutate.AppendManifests(index, mutate.IndexAddendum{
Add: img,
Add: emptyConfigImage,
Descriptor: v1.Descriptor{
Annotations: map[string]string{
tufFileAnnotation: fmt.Sprintf("%s/%s", subdir, name),

View File

@@ -27,7 +27,7 @@ func TestGetTufTargetsMirror(t *testing.T) {
defer server.Close()
path := test.CreateTempDir(t, "", "tuf_temp")
m, err := NewTUFMirror(embed.RootDev.Data, path, server.URL+"/metadata", server.URL+"/targets", tuf.NewMockVersionChecker())
m, err := NewTUFMirror(embed.RootDev.Data, path, server.URL+metadataPath, server.URL+targetsPath, tuf.NewMockVersionChecker())
assert.NoError(t, err)
targets, err := m.GetTUFTargetMirrors()
@@ -61,7 +61,7 @@ func TestTargetDelegationMetadata(t *testing.T) {
defer server.Close()
path := test.CreateTempDir(t, "", "tuf_temp")
tm, err := NewTUFMirror(embed.RootDev.Data, path, server.URL+"/metadata", server.URL+"/targets", tuf.NewMockVersionChecker())
tm, err := NewTUFMirror(embed.RootDev.Data, path, server.URL+metadataPath, server.URL+targetsPath, tuf.NewMockVersionChecker())
assert.NoError(t, err)
targets, err := tm.TUFClient.LoadDelegatedTargets("test-role", "targets")
@@ -74,7 +74,7 @@ func TestGetDelegatedTargetMirrors(t *testing.T) {
defer server.Close()
path := test.CreateTempDir(t, "", "tuf_temp")
m, err := NewTUFMirror(embed.RootDev.Data, path, server.URL+"/metadata", server.URL+"/targets", tuf.NewMockVersionChecker())
m, err := NewTUFMirror(embed.RootDev.Data, path, server.URL+metadataPath, server.URL+targetsPath, tuf.NewMockVersionChecker())
assert.NoError(t, err)
mirrors, err := m.GetDelegatedTargetMirrors()

View File

@@ -1,6 +1,7 @@
package mirror
import (
"github.com/docker/attest/pkg/oci"
"github.com/docker/attest/pkg/tuf"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/theupdateframework/go-tuf/v2/metadata"
@@ -32,7 +33,7 @@ type DelegatedTargetMetadata struct {
}
type Image struct {
Image v1.Image
Image *oci.EmptyConfigImage
Tag string
}

2
pkg/oci/README.md Normal file
View File

@@ -0,0 +1,2 @@
## oci
This package is for generic OCI components. For attestation specific components see the `attestation` package.

View File

@@ -1,19 +1,17 @@
//go:build e2e
package mirror_test
package oci_test
import (
"path/filepath"
"testing"
"github.com/docker/attest/internal/test"
"github.com/docker/attest/pkg/oci"
"github.com/stretchr/testify/require"
)
func TestRegistryAuth(t *testing.T) {
UnsignedTestImage := filepath.Join("..", "..", "test", "testdata", "unsigned-test-image")
attIdx, err := oci.IndexFromPath(UnsignedTestImage)
attIdx, err := oci.IndexFromPath(test.UnsignedTestImage)
require.NoError(t, err)
// test cases for ecr, gcr and dockerhub
testCases := []struct {

View File

@@ -2,14 +2,11 @@ package oci
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/containerd/platforms"
"github.com/distribution/reference"
"github.com/docker/attest/pkg/attestation"
att "github.com/docker/attest/pkg/attestation"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/remote"
"github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/common"
@@ -45,36 +42,7 @@ func WithOptions(ctx context.Context, platform *v1.Platform) []remote.Option {
return options
}
func ExtractEnvelopes(manifest *attestation.Manifest, predicateType string) ([]*att.Envelope, error) {
var envs []*att.Envelope
dsseMediaType, err := attestation.DSSEMediaType(predicateType)
if err != nil {
return nil, fmt.Errorf("failed to get DSSE media type for predicate '%s': %w", predicateType, err)
}
for _, attestationLayer := range manifest.OriginalLayers {
mt, err := attestationLayer.Layer.MediaType()
if err != nil {
return nil, fmt.Errorf("failed to get layer media type: %w", err)
}
if string(mt) == dsseMediaType {
reader, err := attestationLayer.Layer.Uncompressed()
if err != nil {
return nil, fmt.Errorf("failed to get layer contents: %w", err)
}
defer reader.Close()
env := new(att.Envelope)
err = json.NewDecoder(reader).Decode(&env)
if err != nil {
return nil, fmt.Errorf("failed to decode envelope: %w", err)
}
envs = append(envs, env)
}
}
return envs, nil
}
func imageDescriptor(ix *v1.IndexManifest, platform *v1.Platform) (*v1.Descriptor, error) {
func ImageDescriptor(ix *v1.IndexManifest, platform *v1.Platform) (*v1.Descriptor, error) {
for i := range ix.Manifests {
m := &ix.Manifests[i]
if (m.MediaType == ocispec.MediaTypeImageManifest || m.MediaType == "application/vnd.docker.distribution.manifest.v2+json") && m.Platform.Equals(*platform) {
@@ -84,18 +52,6 @@ func imageDescriptor(ix *v1.IndexManifest, platform *v1.Platform) (*v1.Descripto
return nil, fmt.Errorf("no image found for platform %v", platform)
}
func attestationDigestForDigest(ix *v1.IndexManifest, imageDigest string, attestType string) (string, error) {
for i := range ix.Manifests {
m := &ix.Manifests[i]
if v, ok := m.Annotations[att.DockerReferenceType]; ok && v == attestType {
if d, ok := m.Annotations[att.DockerReferenceDigest]; ok && d == imageDigest {
return m.Digest.String(), nil
}
}
}
return "", fmt.Errorf("no attestation found for image %s", imageDigest)
}
func RefToPURL(ref string, platform *v1.Platform) (string, bool, error) {
var isCanonical bool
named, err := reference.ParseNormalizedNamed(ref)
@@ -150,7 +106,7 @@ func SplitDigest(digest string) (common.DigestSet, error) {
}
func ReplaceTagInSpec(src *ImageSpec, digest v1.Hash) (*ImageSpec, error) {
newName, err := replaceTag(src.Identifier, digest)
newName, err := ReplaceTag(src.Identifier, digest)
if err != nil {
return nil, fmt.Errorf("failed to parse repo name: %w", err)
}
@@ -162,7 +118,7 @@ func ReplaceTagInSpec(src *ImageSpec, digest v1.Hash) (*ImageSpec, error) {
}
// so that the index tag is replaced with a tag unique to the image digest and doesn't overwrite it.
func replaceTag(image string, digest v1.Hash) (string, error) {
func ReplaceTag(image string, digest v1.Hash) (string, error) {
if strings.HasPrefix(image, LocalPrefix) {
return image, nil
}

View File

@@ -1,56 +1,55 @@
package oci
package oci_test
import (
"path/filepath"
"testing"
"github.com/docker/attest/internal/test"
"github.com/docker/attest/pkg/oci"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/layout"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var UnsignedTestImage = filepath.Join("..", "..", "test", "testdata", "unsigned-test-image")
func TestRefToPurl(t *testing.T) {
arm, err := ParsePlatform("arm64/linux")
arm, err := oci.ParsePlatform("arm64/linux")
require.NoError(t, err)
purl, canonical, err := RefToPURL("alpine", arm)
purl, canonical, err := oci.RefToPURL("alpine", arm)
assert.NoError(t, err)
assert.Equal(t, "pkg:docker/alpine@latest?platform=arm64%2Flinux", purl)
assert.False(t, canonical)
purl, canonical, err = RefToPURL("alpine:123", arm)
purl, canonical, err = oci.RefToPURL("alpine:123", arm)
assert.NoError(t, err)
assert.Equal(t, "pkg:docker/alpine@123?platform=arm64%2Flinux", purl)
assert.False(t, canonical)
purl, canonical, err = RefToPURL("google/alpine:123", arm)
purl, canonical, err = oci.RefToPURL("google/alpine:123", arm)
assert.NoError(t, err)
assert.Equal(t, "pkg:docker/google/alpine@123?platform=arm64%2Flinux", purl)
assert.False(t, canonical)
purl, canonical, err = RefToPURL("library/alpine:123", arm)
purl, canonical, err = oci.RefToPURL("library/alpine:123", arm)
assert.NoError(t, err)
assert.Equal(t, "pkg:docker/alpine@123?platform=arm64%2Flinux", purl)
assert.False(t, canonical)
purl, canonical, err = RefToPURL("docker.io/library/alpine:123", arm)
purl, canonical, err = oci.RefToPURL("docker.io/library/alpine:123", arm)
assert.NoError(t, err)
assert.Equal(t, "pkg:docker/alpine@123?platform=arm64%2Flinux", purl)
assert.False(t, canonical)
purl, canonical, err = RefToPURL("localhost:5001/library/alpine:123", arm)
purl, canonical, err = oci.RefToPURL("localhost:5001/library/alpine:123", arm)
assert.NoError(t, err)
assert.Equal(t, "pkg:docker/localhost%3A5001/library/alpine@123?platform=arm64%2Flinux", purl)
assert.False(t, canonical)
purl, canonical, err = RefToPURL("localhost:5001/alpine:123", arm)
purl, canonical, err = oci.RefToPURL("localhost:5001/alpine:123", arm)
assert.NoError(t, err)
assert.Equal(t, "pkg:docker/localhost%3A5001/alpine@123?platform=arm64%2Flinux", purl)
assert.False(t, canonical)
purl, canonical, err = RefToPURL("localhost:5001/alpine@sha256:c5b1261d6d3e43071626931fc004f70149baeba2c8ec672bd4f27761f8e1ad6b", arm)
purl, canonical, err = oci.RefToPURL("localhost:5001/alpine@sha256:c5b1261d6d3e43071626931fc004f70149baeba2c8ec672bd4f27761f8e1ad6b", arm)
assert.NoError(t, err)
assert.Equal(t, "pkg:docker/localhost%3A5001/alpine?digest=sha256%3Ac5b1261d6d3e43071626931fc004f70149baeba2c8ec672bd4f27761f8e1ad6b&platform=arm64%2Flinux", purl)
assert.True(t, canonical)
@@ -58,7 +57,7 @@ func TestRefToPurl(t *testing.T) {
// Test fix for https://github.com/docker/secure-artifacts-team-issues/issues/202
func TestImageDigestForPlatform(t *testing.T) {
idx, err := layout.ImageIndexFromPath(UnsignedTestImage)
idx, err := layout.ImageIndexFromPath(test.UnsignedTestImage)
assert.NoError(t, err)
idxm, err := idx.IndexManifest()
@@ -72,16 +71,16 @@ func TestImageDigestForPlatform(t *testing.T) {
mfs2, err := mfs.IndexManifest()
assert.NoError(t, err)
p, err := ParsePlatform("linux/amd64")
p, err := oci.ParsePlatform("linux/amd64")
assert.NoError(t, err)
desc, err := imageDescriptor(mfs2, p)
desc, err := oci.ImageDescriptor(mfs2, p)
assert.NoError(t, err)
digest := desc.Digest.String()
assert.Equal(t, "sha256:da8b190665956ea07890a0273e2a9c96bfe291662f08e2860e868eef69c34620", digest)
p, err = ParsePlatform("linux/arm64")
p, err = oci.ParsePlatform("linux/arm64")
assert.NoError(t, err)
desc, err = imageDescriptor(mfs2, p)
desc, err = oci.ImageDescriptor(mfs2, p)
assert.NoError(t, err)
digest = desc.Digest.String()
assert.Equal(t, "sha256:7a76cec943853f9f7105b1976afa1bf7cd5bb6afc4e9d5852dd8da7cf81ae86e", digest)
@@ -95,14 +94,14 @@ func TestWithoutTag(t *testing.T) {
{name: "image:tag", expected: "index.docker.io/library/image"},
{name: "image", expected: "index.docker.io/library/image"},
{name: "image:sha256-digest.att", expected: "index.docker.io/library/image"},
{name: RegistryPrefix + "image:tag", expected: RegistryPrefix + "index.docker.io/library/image"},
{name: oci.RegistryPrefix + "image:tag", expected: oci.RegistryPrefix + "index.docker.io/library/image"},
{name: "image@sha256:166710df254975d4a6c4c407c315951c22753dcaa829e020a3fd5d18fff70dd2", expected: "index.docker.io/library/image"},
{name: RegistryPrefix + "image@sha256:166710df254975d4a6c4c407c315951c22753dcaa829e020a3fd5d18fff70dd2", expected: RegistryPrefix + "index.docker.io/library/image"},
{name: RegistryPrefix + "127.0.0.1:36555/repo:latest", expected: RegistryPrefix + "127.0.0.1:36555/repo"},
{name: oci.RegistryPrefix + "image@sha256:166710df254975d4a6c4c407c315951c22753dcaa829e020a3fd5d18fff70dd2", expected: oci.RegistryPrefix + "index.docker.io/library/image"},
{name: oci.RegistryPrefix + "127.0.0.1:36555/repo:latest", expected: oci.RegistryPrefix + "127.0.0.1:36555/repo"},
}
for _, c := range tc {
t.Run(c.name, func(t *testing.T) {
notag, _ := WithoutTag(c.name)
notag, _ := oci.WithoutTag(c.name)
assert.Equal(t, c.expected, notag)
})
}
@@ -116,11 +115,11 @@ func TestReplaceTag(t *testing.T) {
{name: "image:tag", expected: "index.docker.io/library/image:sha256-digest.att"},
{name: "image", expected: "index.docker.io/library/image:sha256-digest.att"},
{name: "image:sha256-digest.att", expected: "index.docker.io/library/image:sha256-digest.att"},
{name: RegistryPrefix + "image:tag", expected: RegistryPrefix + "index.docker.io/library/image:sha256-digest.att"},
{name: oci.RegistryPrefix + "image:tag", expected: oci.RegistryPrefix + "index.docker.io/library/image:sha256-digest.att"},
{name: "image@sha256:166710df254975d4a6c4c407c315951c22753dcaa829e020a3fd5d18fff70dd2", expected: "index.docker.io/library/image:sha256-digest.att"},
{name: LocalPrefix + "foobar", expected: LocalPrefix + "foobar"},
{name: RegistryPrefix + "image@sha256:166710df254975d4a6c4c407c315951c22753dcaa829e020a3fd5d18fff70dd2", expected: RegistryPrefix + "index.docker.io/library/image:sha256-digest.att"},
{name: RegistryPrefix + "127.0.0.1:36555/repo:latest", expected: RegistryPrefix + "127.0.0.1:36555/repo:sha256-digest.att"},
{name: oci.LocalPrefix + "foobar", expected: oci.LocalPrefix + "foobar"},
{name: oci.RegistryPrefix + "image@sha256:166710df254975d4a6c4c407c315951c22753dcaa829e020a3fd5d18fff70dd2", expected: oci.RegistryPrefix + "index.docker.io/library/image:sha256-digest.att"},
{name: oci.RegistryPrefix + "127.0.0.1:36555/repo:latest", expected: oci.RegistryPrefix + "127.0.0.1:36555/repo:sha256-digest.att"},
}
digest := v1.Hash{
@@ -129,7 +128,7 @@ func TestReplaceTag(t *testing.T) {
}
for _, c := range tc {
t.Run(c.name, func(t *testing.T) {
replaced, err := replaceTag(c.name, digest)
replaced, err := oci.ReplaceTag(c.name, digest)
require.NoError(t, err)
assert.Equal(t, c.expected, replaced)
})

View File

@@ -4,7 +4,6 @@ import (
"fmt"
"os"
"github.com/docker/attest/pkg/attestation"
"github.com/google/go-containerregistry/pkg/name"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/empty"
@@ -13,6 +12,7 @@ import (
"github.com/google/go-containerregistry/pkg/v1/remote"
)
// PushImageToRegistry pushes an image to the registry with the specified name.
func PushImageToRegistry(image v1.Image, imageName string) error {
ref, err := name.ParseReference(imageName)
if err != nil {
@@ -23,6 +23,7 @@ func PushImageToRegistry(image v1.Image, imageName string) error {
return remote.Write(ref, image, MultiKeychainOption())
}
// PushIndexToRegistry pushes an index to the registry with the specified name.
func PushIndexToRegistry(index v1.ImageIndex, imageName string) error {
// Parse the index name
ref, err := name.ParseReference(imageName)
@@ -34,6 +35,7 @@ func PushIndexToRegistry(index v1.ImageIndex, imageName string) error {
return remote.WriteIndex(ref, index, MultiKeychainOption())
}
// SaveIndexAsOCILayout saves an image as an OCI layout to the specified path.
func SaveImageAsOCILayout(image v1.Image, path string) error {
// Save the image to the local filesystem
err := os.MkdirAll(path, os.ModePerm)
@@ -48,6 +50,7 @@ func SaveImageAsOCILayout(image v1.Image, path string) error {
return l.AppendImage(image)
}
// SaveIndexAsOCILayout saves an index as an OCI layout to the specified path.
func SaveIndexAsOCILayout(image v1.ImageIndex, path string) error {
// Save the index to the local filesystem
err := os.MkdirAll(path, os.ModePerm)
@@ -62,6 +65,7 @@ func SaveIndexAsOCILayout(image v1.ImageIndex, path string) error {
return nil
}
// SaveIndex saves an index to the specified outputs.
func SaveIndex(outputs []*ImageSpec, index v1.ImageIndex, indexName string) error {
// split output by comma and write or push each one
for _, output := range outputs {
@@ -89,6 +93,7 @@ func SaveIndex(outputs []*ImageSpec, index v1.ImageIndex, indexName string) erro
return nil
}
// SaveImage saves an image to the specified output.
func SaveImage(output *ImageSpec, image v1.Image, imageName string) error {
if output.Type == OCI {
idx := v1.ImageIndex(empty.Index)
@@ -113,26 +118,23 @@ func SaveImage(output *ImageSpec, image v1.Image, imageName string) error {
return nil
}
func SaveReferrers(manifest *attestation.Manifest, outputs []*ImageSpec) error {
// SaveImagesNoTag saves a list of images by digest to the specified outputs.
func SaveImagesNoTag(images []v1.Image, outputs []*ImageSpec) error {
for _, output := range outputs {
// OCI layout output for referrers not supported
// OCI layout output not supported
if output.Type == OCI {
continue
}
images, err := manifest.BuildReferringArtifacts()
if err != nil {
return fmt.Errorf("failed to build image: %w", err)
}
for _, image := range images {
digest, err := image.Digest()
if err != nil {
return fmt.Errorf("failed to get attestation image digest: %w", err)
return fmt.Errorf("failed to get image digest: %w", err)
}
attOut, err := ReplaceDigestInSpec(output, digest)
spec, err := ReplaceDigestInSpec(output, digest)
if err != nil {
return fmt.Errorf("failed to create attestation image spec: %w", err)
return fmt.Errorf("failed to create image spec: %w", err)
}
err = PushImageToRegistry(image, attOut.Identifier)
err = PushImageToRegistry(image, spec.Identifier)
if err != nil {
return fmt.Errorf("failed to push image: %w", err)
}

View File

@@ -4,11 +4,9 @@ import (
"fmt"
"net/http/httptest"
"net/url"
"path/filepath"
"testing"
"github.com/docker/attest/internal/test"
"github.com/docker/attest/pkg/attest"
"github.com/docker/attest/pkg/attestation"
"github.com/docker/attest/pkg/oci"
"github.com/google/go-containerregistry/pkg/registry"
@@ -19,9 +17,8 @@ import (
)
func TestSavingIndex(t *testing.T) {
UnsignedTestImage := filepath.Join("..", "..", "test", "testdata", "unsigned-test-image")
outputLayout := test.CreateTempDir(t, "", "mirror-test")
attIdx, err := oci.IndexFromPath(UnsignedTestImage)
attIdx, err := oci.IndexFromPath(test.UnsignedTestImage)
require.NoError(t, err)
server := httptest.NewServer(registry.New())
@@ -80,9 +77,9 @@ func TestSavingReferrers(t *testing.T) {
MediaType: "application/vnd.oci.image.manifest.v1+json",
Digest: digest,
}
manifest, err := attest.NewAttestationManifest(subject)
manifest, err := attestation.NewManifest(subject)
require.NoError(t, err)
err = manifest.AddAttestation(ctx, signer, statement, opts)
err = manifest.Add(ctx, signer, statement, opts)
require.NoError(t, err)
server := httptest.NewServer(registry.New(registry.WithReferrersSupport(true)))
defer server.Close()
@@ -93,16 +90,18 @@ func TestSavingReferrers(t *testing.T) {
indexName := fmt.Sprintf("%s/repo:root", u.Host)
output, err := oci.ParseImageSpecs(indexName)
require.NoError(t, err)
err = oci.SaveReferrers(manifest, output)
artifacts, err := manifest.BuildReferringArtifacts()
require.NoError(t, err)
err = oci.SaveImagesNoTag(artifacts, output)
require.NoError(t, err)
reg := &oci.MockRegistryResolver{
reg := &attestation.MockRegistryResolver{
Subject: subject,
MockResolver: &oci.MockResolver{},
MockResolver: &attestation.MockResolver{},
ImageNameStr: indexName,
}
require.NoError(t, err)
refResolver, err := oci.NewReferrersAttestationResolver(reg)
refResolver, err := attestation.NewReferrersResolver(reg)
require.NoError(t, err)
attestations, err := refResolver.Attestations(ctx, attestation.VSAPredicateType)
require.NoError(t, err)

View File

@@ -4,18 +4,11 @@ import (
"context"
"fmt"
"github.com/docker/attest/pkg/attestation"
att "github.com/docker/attest/pkg/attestation"
"github.com/google/go-containerregistry/pkg/name"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/remote"
)
type RegistryResolver struct {
*RegistryImageDetailsResolver
*attestation.Manifest
}
type RegistryImageDetailsResolver struct {
*ImageSpec
descriptor *v1.Descriptor
@@ -27,12 +20,6 @@ func NewRegistryImageDetailsResolver(src *ImageSpec) (*RegistryImageDetailsResol
}, nil
}
func NewRegistryAttestationResolver(src *RegistryImageDetailsResolver) (*RegistryResolver, error) {
return &RegistryResolver{
RegistryImageDetailsResolver: src,
}, nil
}
func (r *RegistryImageDetailsResolver) ImageName(_ context.Context) (string, error) {
return r.Identifier, nil
}
@@ -72,70 +59,3 @@ func (r *RegistryImageDetailsResolver) ImageDescriptor(ctx context.Context) (*v1
}
return r.descriptor, nil
}
func (r *RegistryResolver) Attestations(ctx context.Context, predicateType string) ([]*att.Envelope, error) {
if r.Manifest == nil {
attest, err := FetchAttestationManifest(ctx, r.Identifier, r.ImageSpec.Platform)
if err != nil {
return nil, err
}
r.Manifest = attest
}
return ExtractEnvelopes(r.Manifest, predicateType)
}
func FetchAttestationManifest(ctx context.Context, image string, platform *v1.Platform) (*attestation.Manifest, error) {
// we want to get to the image index, so ignoring platform for now
options := WithOptions(ctx, nil)
ref, err := name.ParseReference(image)
if err != nil {
return nil, fmt.Errorf("failed to parse reference: %w", err)
}
index, err := remote.Index(ref, options...)
if err != nil {
return nil, fmt.Errorf("failed to get index: %w", err)
}
indexManifest, err := index.IndexManifest()
if err != nil {
return nil, fmt.Errorf("failed to get index manifest: %w", err)
}
subjectDescriptor, err := imageDescriptor(indexManifest, platform)
if err != nil {
return nil, fmt.Errorf("failed to obtain image for platform: %w", err)
}
digest := subjectDescriptor.Digest.String()
ref, err = name.ParseReference(fmt.Sprintf("%s@%s", ref.Context().Name(), digest))
if err != nil {
return nil, fmt.Errorf("failed to parse attestation reference: %w", err)
}
attestationDigest, err := attestationDigestForDigest(indexManifest, digest, "attestation-manifest")
if err != nil {
return nil, fmt.Errorf("failed to obtain attestation for image: %w", err)
}
ref, err = name.ParseReference(fmt.Sprintf("%s@%s", ref.Context().Name(), attestationDigest))
if err != nil {
return nil, fmt.Errorf("failed to parse attestation reference: %w", err)
}
remoteDescriptor, err := remote.Get(ref, options...)
if err != nil {
return nil, fmt.Errorf("failed to get attestation: %w", err)
}
attestationImage, err := remoteDescriptor.Image()
if err != nil {
return nil, fmt.Errorf("failed to get attestation image: %w", err)
}
layers, err := attestation.GetAttestationsFromImage(attestationImage)
if err != nil {
return nil, fmt.Errorf("failed to get attestations from image: %w", err)
}
attest := &attestation.Manifest{
OriginalLayers: layers,
OriginalDescriptor: &remoteDescriptor.Descriptor,
SubjectName: image,
SubjectDescriptor: subjectDescriptor,
}
return attest, nil
}

View File

@@ -3,15 +3,9 @@ package oci
import (
"context"
att "github.com/docker/attest/pkg/attestation"
v1 "github.com/google/go-containerregistry/pkg/v1"
)
type AttestationResolver interface {
ImageDetailsResolver
Attestations(ctx context.Context, mediaType string) ([]*att.Envelope, error)
}
type ImageDetailsResolver interface {
ImageName(ctx context.Context) (string, error)
ImagePlatform(ctx context.Context) (*v1.Platform, error)

View File

@@ -1,6 +1,8 @@
package oci
import (
"bytes"
"encoding/json"
"fmt"
"strings"
@@ -26,12 +28,6 @@ type (
}
)
type AttestationOptions struct {
NoReferrers bool
Attach bool
ReferrersRepo string
}
type ImageSpecOption func(*ImageSpec) error
type ImageSpec struct {
@@ -180,3 +176,42 @@ func WithoutTag(image string) (string, error) {
repo := ref.Context().Name()
return prefix + repo, nil
}
type EmptyConfigImage struct {
v1.Image
}
func (i *EmptyConfigImage) RawConfigFile() ([]byte, error) {
return []byte("{}"), nil
}
func (i *EmptyConfigImage) Manifest() (*v1.Manifest, error) {
mf, err := i.Image.Manifest()
if err != nil {
return nil, fmt.Errorf("failed to get manifest: %w", err)
}
mf.Config = v1.Descriptor{
MediaType: "application/vnd.oci.empty.v1+json",
Size: 2,
Digest: v1.Hash{Algorithm: "sha256", Hex: "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"},
Data: []byte("{}"),
}
return mf, nil
}
func (i *EmptyConfigImage) RawManifest() ([]byte, error) {
mf, err := i.Manifest()
if err != nil {
return nil, fmt.Errorf("failed to get manifest: %w", err)
}
return json.Marshal(mf)
}
func (i *EmptyConfigImage) Digest() (v1.Hash, error) {
mb, err := i.RawManifest()
if err != nil {
return v1.Hash{}, err
}
digest, _, err := v1.SHA256(bytes.NewReader(mb))
return digest, err
}

21
pkg/oci/types_test.go Normal file
View File

@@ -0,0 +1,21 @@
package oci
import (
"testing"
"github.com/docker/attest/internal/util"
"github.com/google/go-containerregistry/pkg/v1/empty"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestEmptyConfigImageDigest(t *testing.T) {
empty := empty.Image
img := EmptyConfigImage{Image: empty}
mf, err := img.RawManifest()
require.NoError(t, err)
hash := util.SHA256Hex(mf)
digest, err := img.Digest()
require.NoError(t, err)
assert.Equal(t, digest.Hex, hash)
}

2
pkg/policy/README.md Normal file
View File

@@ -0,0 +1,2 @@
## policy
This package is for attestation policy mapping and evaluation.

View File

@@ -4,7 +4,7 @@ import (
"context"
"fmt"
"github.com/docker/attest/pkg/oci"
"github.com/docker/attest/pkg/attestation"
)
type policyEvaluatorCtxKeyType struct{}
@@ -26,5 +26,5 @@ func GetPolicyEvaluator(ctx context.Context) (Evaluator, error) {
}
type Evaluator interface {
Evaluate(ctx context.Context, resolver oci.AttestationResolver, pctx *Policy, input *Input) (*Result, error)
Evaluate(ctx context.Context, resolver attestation.Resolver, pctx *Policy, input *Input) (*Result, error)
}

View File

@@ -3,14 +3,14 @@ package policy
import (
"context"
"github.com/docker/attest/pkg/oci"
"github.com/docker/attest/pkg/attestation"
)
type MockPolicyEvaluator struct {
EvaluateFunc func(ctx context.Context, resolver oci.AttestationResolver, pctx *Policy, input *Input) (*Result, error)
EvaluateFunc func(ctx context.Context, resolver attestation.Resolver, pctx *Policy, input *Input) (*Result, error)
}
func (pe *MockPolicyEvaluator) Evaluate(ctx context.Context, resolver oci.AttestationResolver, pctx *Policy, input *Input) (*Result, error) {
func (pe *MockPolicyEvaluator) Evaluate(ctx context.Context, resolver attestation.Resolver, pctx *Policy, input *Input) (*Result, error) {
if pe.EvaluateFunc != nil {
return pe.EvaluateFunc(ctx, resolver, pctx, input)
}
@@ -19,7 +19,7 @@ func (pe *MockPolicyEvaluator) Evaluate(ctx context.Context, resolver oci.Attest
func GetMockPolicy() Evaluator {
return &MockPolicyEvaluator{
EvaluateFunc: func(_ context.Context, _ oci.AttestationResolver, _ *Policy, _ *Input) (*Result, error) {
EvaluateFunc: func(_ context.Context, _ attestation.Resolver, _ *Policy, _ *Input) (*Result, error) {
return AllowedResult(), nil
},
}

View File

@@ -8,6 +8,8 @@ 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"
)
@@ -16,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
@@ -28,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
@@ -40,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
@@ -211,28 +245,28 @@ func normalizeImageName(imageName string) (string, error) {
func CreateImageDetailsResolver(imageSource *oci.ImageSpec) (oci.ImageDetailsResolver, error) {
switch imageSource.Type {
case oci.OCI:
return oci.NewOCILayoutAttestationResolver(imageSource)
return attestation.NewOCILayoutResolver(imageSource)
case oci.Docker:
return oci.NewRegistryImageDetailsResolver(imageSource)
}
return nil, fmt.Errorf("unsupported image source type: %s", imageSource.Type)
}
func CreateAttestationResolver(resolver oci.ImageDetailsResolver, mapping *config.PolicyMapping) (oci.AttestationResolver, error) {
func CreateAttestationResolver(resolver oci.ImageDetailsResolver, mapping *config.PolicyMapping) (attestation.Resolver, error) {
if mapping.Attestations != nil {
if mapping.Attestations.Style == config.AttestationStyleAttached {
switch resolver := resolver.(type) {
case *oci.RegistryImageDetailsResolver:
return oci.NewRegistryAttestationResolver(resolver)
case *oci.LayoutResolver:
return attestation.NewRegistryResolver(resolver)
case *attestation.LayoutResolver:
return resolver, nil
default:
return nil, fmt.Errorf("unsupported image details resolver type: %T", resolver)
}
}
if mapping.Attestations.Repo != "" {
return oci.NewReferrersAttestationResolver(resolver, oci.WithReferrersRepo(mapping.Attestations.Repo))
return attestation.NewReferrersResolver(resolver, attestation.WithReferrersRepo(mapping.Attestations.Repo))
}
}
return oci.NewReferrersAttestationResolver(resolver)
return attestation.NewReferrersResolver(resolver)
}

View File

@@ -32,33 +32,34 @@ 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")
re := policy.NewRegoEvaluator(true)
defaultResolver := oci.MockResolver{
defaultResolver := attestation.MockResolver{
Envs: []*attestation.Envelope{loadAttestation(t, ExampleAttestation)},
}
testCases := []struct {
repo string
expectSuccess bool
isCanonical bool
resolver oci.AttestationResolver
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")
@@ -117,10 +118,10 @@ func TestLoadingMappings(t *testing.T) {
}
func TestCreateAttestationResolver(t *testing.T) {
mockResolver := oci.MockResolver{
mockResolver := attestation.MockResolver{
Envs: []*attestation.Envelope{},
}
layoutResolver := &oci.LayoutResolver{}
layoutResolver := &attestation.LayoutResolver{}
registryResolver := &oci.RegistryImageDetailsResolver{}
nilRepoReferrers := &config.PolicyMapping{
@@ -166,11 +167,11 @@ func TestCreateAttestationResolver(t *testing.T) {
return
}
switch resolver.(type) {
case *oci.ReferrersResolver:
case *attestation.ReferrersResolver:
assert.Equal(t, tc.mapping.Attestations.Style, config.AttestationStyleReferrers)
case *oci.RegistryResolver:
case *attestation.RegistryResolver:
assert.Equal(t, tc.mapping.Attestations.Style, config.AttestationStyleAttached)
case *oci.LayoutResolver:
case *attestation.LayoutResolver:
assert.Equal(t, tc.mapping.Attestations.Style, config.AttestationStyleAttached)
}
})

View File

@@ -7,8 +7,7 @@ import (
"os"
"path/filepath"
att "github.com/docker/attest/pkg/attestation"
"github.com/docker/attest/pkg/oci"
"github.com/docker/attest/pkg/attestation"
intoto "github.com/in-toto/in-toto-golang/in_toto"
"github.com/open-policy-agent/opa/ast"
"github.com/open-policy-agent/opa/rego"
@@ -36,7 +35,7 @@ func NewRegoEvaluator(debug bool) Evaluator {
}
}
func (re *regoEvaluator) Evaluate(ctx context.Context, resolver oci.AttestationResolver, pctx *Policy, input *Input) (*Result, error) {
func (re *regoEvaluator) Evaluate(ctx context.Context, resolver attestation.Resolver, pctx *Policy, input *Input) (*Result, error) {
var regoOpts []func(*rego.Rego)
// Create a new in-memory store
@@ -170,7 +169,7 @@ func handleErrors2(f func(rCtx *rego.BuiltinContext, a, b *ast.Term) (*ast.Term,
}
}
func RegoFunctions(resolver oci.AttestationResolver) []*tester.Builtin {
func RegoFunctions(resolver attestation.Resolver) []*tester.Builtin {
return []*tester.Builtin{
{
Decl: verifyDecl,
@@ -197,7 +196,7 @@ func RegoFunctions(resolver oci.AttestationResolver) []*tester.Builtin {
}
}
func fetchInTotoAttestations(resolver oci.AttestationResolver) rego.Builtin1 {
func fetchInTotoAttestations(resolver attestation.Resolver) rego.Builtin1 {
return func(rCtx rego.BuiltinContext, predicateTypeTerm *ast.Term) (*ast.Term, error) {
predicateTypeStr, ok := predicateTypeTerm.Value.(ast.String)
if !ok {
@@ -228,8 +227,8 @@ func fetchInTotoAttestations(resolver oci.AttestationResolver) rego.Builtin1 {
}
func verifyInTotoEnvelope(rCtx *rego.BuiltinContext, envTerm, optsTerm *ast.Term) (*ast.Term, error) {
env := new(att.Envelope)
opts := new(att.VerifyOptions)
env := new(attestation.Envelope)
opts := new(attestation.VerifyOptions)
err := ast.As(envTerm.Value, env)
if err != nil {
return nil, fmt.Errorf("failed to cast envelope: %w", err)
@@ -239,7 +238,7 @@ func verifyInTotoEnvelope(rCtx *rego.BuiltinContext, envTerm, optsTerm *ast.Term
return nil, fmt.Errorf("failed to cast verifier options: %w", err)
}
payload, err := att.VerifyDSSE(rCtx.Context, env, opts)
payload, err := attestation.VerifyDSSE(rCtx.Context, env, opts)
if err != nil {
return nil, err
}

View File

@@ -0,0 +1 @@
policy: "this is not rego"

View File

@@ -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

View File

@@ -40,6 +40,8 @@ type Policy struct {
Query string
Mapping *config.PolicyMapping
ResolvedName string
URI string
Digest map[string]string
}
type Input struct {

View File

@@ -0,0 +1,2 @@
## signerverifier
This package implements methods to sign and verify attestation envelopes.

2
pkg/tlog/README.md Normal file
View File

@@ -0,0 +1,2 @@
## tlog
This package implements transparency logging.

2
pkg/tuf/README.md Normal file
View File

@@ -0,0 +1,2 @@
## tuf
This package implements TUF clients for http and oci data sources.

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -32,6 +32,9 @@ const (
tufTargetMediaType = "application/vnd.tuf.target"
testRole = "test-role"
tufMetadataRepo = "tuf-metadata"
targetsPath = "/tuf-targets"
metadataPath = "/tuf-metadata"
targetsRepo = "test" + targetsPath
)
func TestRegistryFetcher(t *testing.T) {
@@ -44,9 +47,9 @@ func TestRegistryFetcher(t *testing.T) {
}()
LoadRegistryTestData(t, regAddr, OCITUFTestDataPath)
metadataRepo := regAddr.Host + "/tuf-metadata"
metadataRepo := regAddr.Host + metadataPath
metadataImgTag := LatestTag
targetsRepo := regAddr.Host + "/tuf-targets"
targetsRepo := regAddr.Host + targetsPath
targetFile := "test.txt"
delegatedRole := testRole
dir := CreateTempDir(t, "", "tuf_temp")
@@ -122,7 +125,7 @@ func TestFindFileInManifest(t *testing.T) {
// make test image manifest
file := "test.json"
data := []byte("test")
hash := v1.Hash{Algorithm: "sha256", Hex: util.SHA256Hex(data)}
hash := v1.Hash{Hex: util.SHA256Hex(data)}
img := empty.Image
img = mutate.MediaType(img, types.OCIManifestSchema1)
img = mutate.ConfigMediaType(img, types.OCIConfigJSON)
@@ -150,7 +153,6 @@ func TestFindFileInManifest(t *testing.T) {
indexManifest, err := idx.RawManifest()
assert.NoError(t, err)
// cache image layer
targetsRepo := "test/tuf-targets"
d := &RegistryFetcher{
cache: NewImageCache(),
targetsRepo: targetsRepo,
@@ -183,9 +185,8 @@ func TestFindFileInManifest(t *testing.T) {
}
func TestParseImgRef(t *testing.T) {
metadataRepo := "test/tuf-metadata"
metadataRepo := "test" + metadataPath
metadataTag := LatestTag
targetsRepo := "test/tuf-targets"
delegatedRole := testRole
testCases := []struct {
name string

View File

@@ -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 {
// <hash>.<target-name>
targetRemotePath = fmt.Sprintf("%s.%s", digest, baseName)
} else {
// <dir-prefix>/<hash>.<target-name>
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 {

View File

@@ -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)
}
}

View File

@@ -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)
}

4
scripts/README.md Normal file
View File

@@ -0,0 +1,4 @@
## scripts
This directory contains project scripts.
`gen-testdata.sh` - used to generate static test data saved in `/test/testdata/`

2
test/README.md Normal file
View File

@@ -0,0 +1,2 @@
## test
This directory contains static `testdata` used to run go tests.