diff --git a/client/client_test.go b/client/client_test.go index f3812aa00dc9..ee55e6d53b5f 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -73,6 +73,7 @@ import ( "github.com/moby/buildkit/util/testutil/httpserver" "github.com/moby/buildkit/util/testutil/integration" "github.com/moby/buildkit/util/testutil/workers" + policyimage "github.com/moby/policy-helpers/image" digest "github.com/opencontainers/go-digest" ocispecs "github.com/opencontainers/image-spec/specs-go/v1" "github.com/pkg/errors" @@ -251,6 +252,8 @@ var allTests = []func(t *testing.T, sb integration.Sandbox){ testHTTPResolveMultiBuild, testGitResolveMutatedSource, testImageResolveAttestationChainRequiresNetwork, + testImageResolveAttestationChainLocal, + testImageResolveProvenanceAttestation, testSourcePolicySession, testSourcePolicySessionDenyMessages, testSourceMetaPolicySession, @@ -12404,6 +12407,216 @@ func testImageResolveAttestationChainRequiresNetwork(t *testing.T, sb integratio require.NoError(t, err) } +func testImageResolveProvenanceAttestation(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush, workers.FeatureProvenance) + requiresLinux(t) + + ctx := sb.Context() + c, err := New(ctx, sb.Address()) + require.NoError(t, err) + defer c.Close() + + target, platform := buildProvenanceImage(ctx, t, c, sb) + + _, err = c.Build(ctx, SolveOpt{}, "test", func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + md, err := c.ResolveSourceMetadata(ctx, &pb.SourceOp{ + Identifier: "docker-image://" + target, + }, sourceresolver.Opt{ + ImageOpt: &sourceresolver.ResolveImageOpt{ + NoConfig: true, + ResolveAttestations: []string{ + policyimage.SLSAProvenancePredicateType02, + policyimage.SLSAProvenancePredicateType1, + }, + Platform: &platform, + ResolveMode: pb.AttrImageResolveModeForcePull, + }, + }) + if err != nil { + return nil, err + } + require.NotNil(t, md.Image) + require.NotNil(t, md.Image.AttestationChain) + ac := md.Image.AttestationChain + require.NotEmpty(t, ac.AttestationManifest) + att := ac.Blobs[ac.AttestationManifest] + require.NotEmpty(t, att.Data) + + var manifest ocispecs.Manifest + require.NoError(t, json.Unmarshal(att.Data, &manifest)) + require.NotEmpty(t, manifest.Layers) + var ( + stmtBytes []byte + foundLayer ocispecs.Descriptor + ) + for _, layer := range manifest.Layers { + if !isSLSAPredicateType(layer.Annotations["in-toto.io/predicate-type"]) { + continue + } + blob, ok := ac.Blobs[layer.Digest] + if !ok { + continue + } + stmtBytes = blob.Data + foundLayer = layer + break + } + require.NotEmpty(t, stmtBytes) + require.Contains(t, []string{ + policyimage.SLSAProvenancePredicateType02, + policyimage.SLSAProvenancePredicateType1, + }, foundLayer.Annotations["in-toto.io/predicate-type"]) + + var stmt intoto.Statement + require.NoError(t, json.Unmarshal(stmtBytes, &stmt)) + require.Equal(t, "https://in-toto.io/Statement/v0.1", stmt.Type) + require.Contains(t, []string{ + policyimage.SLSAProvenancePredicateType02, + policyimage.SLSAProvenancePredicateType1, + }, stmt.PredicateType) + require.Equal(t, stmt.Subject[0].Digest["sha256"], ac.ImageManifest.Hex()) + return nil, nil + }, nil) + require.NoError(t, err) +} + +func testImageResolveAttestationChainLocal(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush, workers.FeatureProvenance) + requiresLinux(t) + + ctx := sb.Context() + c, err := New(ctx, sb.Address()) + require.NoError(t, err) + defer c.Close() + + target, platform := buildProvenanceImage(ctx, t, c, sb) + + _, err = c.Build(ctx, SolveOpt{}, "test", func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + md, err := c.ResolveSourceMetadata(ctx, &pb.SourceOp{ + Identifier: "docker-image://" + target, + }, sourceresolver.Opt{ + ImageOpt: &sourceresolver.ResolveImageOpt{ + NoConfig: true, + AttestationChain: true, + Platform: &platform, + ResolveMode: pb.AttrImageResolveModeForcePull, + }, + }) + if err != nil { + return nil, err + } + require.NotNil(t, md.Image) + require.NotNil(t, md.Image.AttestationChain) + ac := md.Image.AttestationChain + require.NotEmpty(t, ac.AttestationManifest) + att := ac.Blobs[ac.AttestationManifest] + require.NotEmpty(t, att.Data) + + var manifest ocispecs.Manifest + require.NoError(t, json.Unmarshal(att.Data, &manifest)) + require.NotEmpty(t, manifest.Layers) + found := false + for _, layer := range manifest.Layers { + if isSLSAPredicateType(layer.Annotations["in-toto.io/predicate-type"]) { + found = true + break + } + } + require.True(t, found) + return nil, nil + }, nil) + require.NoError(t, err) +} + +func buildProvenanceImage(ctx context.Context, t *testing.T, c *Client, sb integration.Sandbox) (string, ocispecs.Platform) { + t.Helper() + + registry, err := sb.NewRegistry() + if errors.Is(err, integration.ErrRequirements) { + t.Skip(err.Error()) + } + require.NoError(t, err) + + platform := platforms.Normalize(platforms.DefaultSpec()) + platformKey := platforms.Format(platform) + target := registry + "/buildkit/testprovenance:latest" + + frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + res := gateway.NewResult() + + st := llb.Scratch().File( + llb.Mkfile("/greeting", 0600, []byte("hello provenance")), + ) + def, err := st.Marshal(ctx) + if err != nil { + return nil, err + } + r, err := c.Solve(ctx, gateway.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, err + } + ref, err := r.SingleRef() + if err != nil { + return nil, err + } + _, err = ref.ToState() + if err != nil { + return nil, err + } + res.AddRef(platformKey, ref) + + img := ocispecs.Image{ + Platform: platform, + } + config, err := json.Marshal(img) + if err != nil { + return nil, errors.Wrapf(err, "failed to marshal image config") + } + res.AddMeta(fmt.Sprintf("%s/%s", exptypes.ExporterImageConfigKey, platformKey), config) + + expPlatforms := &exptypes.Platforms{ + Platforms: []exptypes.Platform{{ID: platformKey, Platform: platform}}, + } + dt, err := json.Marshal(expPlatforms) + if err != nil { + return nil, err + } + res.AddMeta(exptypes.ExporterPlatformsKey, dt) + + return res, nil + } + + _, err = c.Build(ctx, SolveOpt{ + FrontendAttrs: map[string]string{ + "attest:provenance": "mode=max", + }, + Exports: []ExportEntry{ + { + Type: ExporterImage, + Attrs: map[string]string{ + "name": target, + "push": "true", + }, + }, + }, + }, "", frontend, nil) + require.NoError(t, err) + + return target, platform +} + +// isSLSAPredicateType reports whether the predicate type represents SLSA provenance. +func isSLSAPredicateType(v string) bool { + switch v { + case policyimage.SLSAProvenancePredicateType02, policyimage.SLSAProvenancePredicateType1: + return true + default: + return false + } +} + func testHTTPPruneAfterCacheKey(t *testing.T, sb integration.Sandbox) { // this test depends on hitting race condition in internal functions. // If debugging and expecting failure you can add small sleep in beginning of source/http.Exec() to hit reliably diff --git a/client/llb/sourceresolver/types.go b/client/llb/sourceresolver/types.go index 82902c640ccc..8b2129400ae2 100644 --- a/client/llb/sourceresolver/types.go +++ b/client/llb/sourceresolver/types.go @@ -39,10 +39,11 @@ type MetaResponse struct { } type ResolveImageOpt struct { - Platform *ocispecs.Platform - ResolveMode string - NoConfig bool - AttestationChain bool + Platform *ocispecs.Platform + ResolveMode string + NoConfig bool + AttestationChain bool + ResolveAttestations []string } type ResolveImageResponse struct { diff --git a/frontend/gateway/gateway.go b/frontend/gateway/gateway.go index 8737e1f4b42f..7d7062f3c366 100644 --- a/frontend/gateway/gateway.go +++ b/frontend/gateway/gateway.go @@ -591,6 +591,7 @@ func (lbf *llbBridgeForwarder) ResolveSourceMeta(ctx context.Context, req *pb.Re if req.Image != nil { resolveopt.ImageOpt.NoConfig = req.Image.NoConfig resolveopt.ImageOpt.AttestationChain = req.Image.AttestationChain + resolveopt.ImageOpt.ResolveAttestations = slices.Clone(req.Image.ResolveAttestations) } resolveopt.OCILayoutOpt = &sourceresolver.ResolveOCILayoutOpt{ Platform: platform, diff --git a/frontend/gateway/grpcclient/client.go b/frontend/gateway/grpcclient/client.go index 790a0db4cb03..11d526cb09b2 100644 --- a/frontend/gateway/grpcclient/client.go +++ b/frontend/gateway/grpcclient/client.go @@ -8,6 +8,7 @@ import ( "maps" "net" "os" + "slices" "strings" "sync" "syscall" @@ -523,9 +524,15 @@ func (c *grpcClient) ResolveSourceMetadata(ctx context.Context, op *opspb.Source SourcePolicies: opt.SourcePolicies, } if opt.ImageOpt != nil { + attestationChain := opt.ImageOpt.AttestationChain + if len(opt.ImageOpt.ResolveAttestations) > 0 { + attestationChain = true + } + req.ResolveMode = opt.ImageOpt.ResolveMode req.Image = &pb.ResolveSourceImageRequest{ - NoConfig: opt.ImageOpt.NoConfig, - AttestationChain: opt.ImageOpt.AttestationChain, + NoConfig: opt.ImageOpt.NoConfig, + AttestationChain: attestationChain, + ResolveAttestations: slices.Clone(opt.ImageOpt.ResolveAttestations), } } diff --git a/frontend/gateway/pb/gateway.pb.go b/frontend/gateway/pb/gateway.pb.go index 626676cceaa5..db60b2fe148d 100644 --- a/frontend/gateway/pb/gateway.pb.go +++ b/frontend/gateway/pb/gateway.pb.go @@ -1069,11 +1069,12 @@ func (x *ResolveSourceMetaResponse) GetHTTP() *ResolveSourceHTTPResponse { } type ResolveSourceImageRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - NoConfig bool `protobuf:"varint,1,opt,name=NoConfig,proto3" json:"NoConfig,omitempty"` - AttestationChain bool `protobuf:"varint,2,opt,name=AttestationChain,proto3" json:"AttestationChain,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + NoConfig bool `protobuf:"varint,1,opt,name=NoConfig,proto3" json:"NoConfig,omitempty"` + AttestationChain bool `protobuf:"varint,2,opt,name=AttestationChain,proto3" json:"AttestationChain,omitempty"` + ResolveAttestations []string `protobuf:"bytes,3,rep,name=ResolveAttestations,proto3" json:"ResolveAttestations,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ResolveSourceImageRequest) Reset() { @@ -1120,6 +1121,13 @@ func (x *ResolveSourceImageRequest) GetAttestationChain() bool { return false } +func (x *ResolveSourceImageRequest) GetResolveAttestations() []string { + if x != nil { + return x.ResolveAttestations + } + return nil +} + type AttestationChain struct { state protoimpl.MessageState `protogen:"open.v1"` Root string `protobuf:"bytes,1,opt,name=Root,proto3" json:"Root,omitempty"` @@ -3318,10 +3326,11 @@ const file_github_com_moby_buildkit_frontend_gateway_pb_gateway_proto_rawDesc = "\x06Source\x18\x01 \x01(\v2\f.pb.SourceOpR\x06Source\x12K\n" + "\x05Image\x18\x02 \x01(\v25.moby.buildkit.v1.frontend.ResolveSourceImageResponseR\x05Image\x12E\n" + "\x03Git\x18\x03 \x01(\v23.moby.buildkit.v1.frontend.ResolveSourceGitResponseR\x03Git\x12H\n" + - "\x04HTTP\x18\x04 \x01(\v24.moby.buildkit.v1.frontend.ResolveSourceHTTPResponseR\x04HTTP\"c\n" + + "\x04HTTP\x18\x04 \x01(\v24.moby.buildkit.v1.frontend.ResolveSourceHTTPResponseR\x04HTTP\"\x95\x01\n" + "\x19ResolveSourceImageRequest\x12\x1a\n" + "\bNoConfig\x18\x01 \x01(\bR\bNoConfig\x12*\n" + - "\x10AttestationChain\x18\x02 \x01(\bR\x10AttestationChain\"\xd7\x02\n" + + "\x10AttestationChain\x18\x02 \x01(\bR\x10AttestationChain\x120\n" + + "\x13ResolveAttestations\x18\x03 \x03(\tR\x13ResolveAttestations\"\xd7\x02\n" + "\x10AttestationChain\x12\x12\n" + "\x04Root\x18\x01 \x01(\tR\x04Root\x12$\n" + "\rImageManifest\x18\x02 \x01(\tR\rImageManifest\x120\n" + diff --git a/frontend/gateway/pb/gateway.proto b/frontend/gateway/pb/gateway.proto index 67851771f665..2f2b3ea3e851 100644 --- a/frontend/gateway/pb/gateway.proto +++ b/frontend/gateway/pb/gateway.proto @@ -155,6 +155,7 @@ message ResolveSourceMetaResponse { message ResolveSourceImageRequest { bool NoConfig = 1; bool AttestationChain = 2; + repeated string ResolveAttestations = 3; } message AttestationChain { diff --git a/frontend/gateway/pb/gateway_vtproto.pb.go b/frontend/gateway/pb/gateway_vtproto.pb.go index 8cb7d4f8ba00..647c97eca4ef 100644 --- a/frontend/gateway/pb/gateway_vtproto.pb.go +++ b/frontend/gateway/pb/gateway_vtproto.pb.go @@ -432,6 +432,11 @@ func (m *ResolveSourceImageRequest) CloneVT() *ResolveSourceImageRequest { r := new(ResolveSourceImageRequest) r.NoConfig = m.NoConfig r.AttestationChain = m.AttestationChain + if rhs := m.ResolveAttestations; rhs != nil { + tmpContainer := make([]string, len(rhs)) + copy(tmpContainer, rhs) + r.ResolveAttestations = tmpContainer + } if len(m.unknownFields) > 0 { r.unknownFields = make([]byte, len(m.unknownFields)) copy(r.unknownFields, m.unknownFields) @@ -1932,6 +1937,15 @@ func (this *ResolveSourceImageRequest) EqualVT(that *ResolveSourceImageRequest) if this.AttestationChain != that.AttestationChain { return false } + if len(this.ResolveAttestations) != len(that.ResolveAttestations) { + return false + } + for i, vx := range this.ResolveAttestations { + vy := that.ResolveAttestations[i] + if vx != vy { + return false + } + } return string(this.unknownFields) == string(that.unknownFields) } @@ -4236,6 +4250,15 @@ func (m *ResolveSourceImageRequest) MarshalToSizedBufferVT(dAtA []byte) (int, er i -= len(m.unknownFields) copy(dAtA[i:], m.unknownFields) } + if len(m.ResolveAttestations) > 0 { + for iNdEx := len(m.ResolveAttestations) - 1; iNdEx >= 0; iNdEx-- { + i -= len(m.ResolveAttestations[iNdEx]) + copy(dAtA[i:], m.ResolveAttestations[iNdEx]) + i = protohelpers.EncodeVarint(dAtA, i, uint64(len(m.ResolveAttestations[iNdEx]))) + i-- + dAtA[i] = 0x1a + } + } if m.AttestationChain { i-- if m.AttestationChain { @@ -6835,6 +6858,12 @@ func (m *ResolveSourceImageRequest) SizeVT() (n int) { if m.AttestationChain { n += 2 } + if len(m.ResolveAttestations) > 0 { + for _, s := range m.ResolveAttestations { + l = len(s) + n += 1 + l + protohelpers.SizeOfVarint(uint64(l)) + } + } n += len(m.unknownFields) return n } @@ -10607,6 +10636,38 @@ func (m *ResolveSourceImageRequest) UnmarshalVT(dAtA []byte) error { } } m.AttestationChain = bool(v != 0) + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field ResolveAttestations", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return protohelpers.ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return protohelpers.ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return protohelpers.ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.ResolveAttestations = append(m.ResolveAttestations, string(dAtA[iNdEx:postIndex])) + iNdEx = postIndex default: iNdEx = preIndex skippy, err := protohelpers.Skip(dAtA[iNdEx:]) diff --git a/source/containerimage/source.go b/source/containerimage/source.go index 46749f7b2a45..b752dc75b9a3 100644 --- a/source/containerimage/source.go +++ b/source/containerimage/source.go @@ -2,6 +2,7 @@ package containerimage import ( "context" + "encoding/json" "slices" "strconv" "time" @@ -179,6 +180,10 @@ func (is *Source) ResolveImageMetadata(ctx context.Context, id *ImageIdentifier, rslvr := resolver.DefaultPool.GetResolver(is.RegistryHosts, ref, resolver.ScopeType{}, sm, g).WithImageStore(is.ImageStore, rm) key += rm.String() + if len(opt.ResolveAttestations) > 0 { + opt.AttestationChain = true + } + ret := &sourceresolver.ResolveImageResponse{} if !opt.NoConfig { res, err := is.gImageRes.Do(ctx, key, func(ctx context.Context) (*resolveImageResult, error) { @@ -260,6 +265,11 @@ func (is *Source) ResolveImageMetadata(ctx context.Context, id *ImageIdentifier, Data: dt, } } + if len(opt.ResolveAttestations) > 0 && ac.AttestationManifest != "" { + if err := addAttestationBlobs(ctx, prov, ac, opt.ResolveAttestations); err != nil { + return nil, err + } + } if err := prov.SetGCLabels(ctx, desc); err != nil { return nil, errors.WithStack(err) } @@ -322,6 +332,49 @@ type resolveImageResult struct { dt []byte } +func addAttestationBlobs(ctx context.Context, prov policyimage.ReferrersProvider, ac *sourceresolver.AttestationChain, predicateTypes []string) error { + if ac == nil || ac.AttestationManifest == "" || ac.Blobs == nil || len(predicateTypes) == 0 { + return nil + } + att, ok := ac.Blobs[ac.AttestationManifest] + if !ok || len(att.Data) == 0 { + return nil + } + need := map[string]struct{}{} + for _, p := range predicateTypes { + if p == "" { + continue + } + need[p] = struct{}{} + } + if len(need) == 0 { + return nil + } + var manifest ocispecs.Manifest + if err := json.Unmarshal(att.Data, &manifest); err != nil { + return errors.Wrapf(err, "unmarshaling attestation manifest %s", ac.AttestationManifest) + } + + for _, layer := range manifest.Layers { + predicateType := layer.Annotations["in-toto.io/predicate-type"] + if _, ok := need[predicateType]; !ok { + continue + } + if _, ok := ac.Blobs[layer.Digest]; ok { + continue + } + dt, err := policyimage.ReadBlob(ctx, prov, layer) + if err != nil { + return errors.WithStack(err) + } + ac.Blobs[layer.Digest] = sourceresolver.Blob{ + Descriptor: layer, + Data: dt, + } + } + return nil +} + func (is *Source) registryIdentifier(ref string, attrs map[string]string, platform *pb.Platform) (source.Identifier, error) { id, err := NewImageIdentifier(ref) if err != nil {