diff --git a/client/build.go b/client/build.go index 106d36329108..f40d83cc27bf 100644 --- a/client/build.go +++ b/client/build.go @@ -4,6 +4,7 @@ import ( "context" "github.com/moby/buildkit/client/buildid" + "github.com/moby/buildkit/frontend/attestations" gateway "github.com/moby/buildkit/frontend/gateway/client" "github.com/moby/buildkit/frontend/gateway/grpcclient" gatewayapi "github.com/moby/buildkit/frontend/gateway/pb" @@ -20,17 +21,15 @@ func (c *Client) Build(ctx context.Context, opt SolveOpt, product string, buildF } }() - if opt.Frontend != "" { - return nil, errors.New("invalid SolveOpt, Build interface cannot use Frontend") - } + feOpts := opt.FrontendAttrs + + opt.Frontend = "" + opt.FrontendAttrs = attestations.Filter(opt.FrontendAttrs) if product == "" { product = apicaps.ExportedProduct } - feOpts := opt.FrontendAttrs - opt.FrontendAttrs = nil - workers, err := c.ListWorkers(ctx) if err != nil { return nil, errors.Wrap(err, "listing workers for Build") diff --git a/client/client_test.go b/client/client_test.go index 39fae644f2d0..da100d7c2c12 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -181,6 +181,9 @@ func TestIntegration(t *testing.T) { testSourceDateEpochReset, testSourceDateEpochLocalExporter, testSourceDateEpochTarExporter, + testAttestationBundle, + testSBOMScan, + testSBOMScanSingleRef, ) tests = append(tests, diffOpTestCases()...) integration.Run(t, tests, mirrors) @@ -7113,6 +7116,581 @@ func testAttestationDefaultSubject(t *testing.T, sb integration.Sandbox) { } } +func testAttestationBundle(t *testing.T, sb integration.Sandbox) { + requiresLinux(t) + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + + registry, err := sb.NewRegistry() + if errors.Is(err, integration.ErrRequirements) { + t.Skip(err.Error()) + } + + ps := []ocispecs.Platform{ + platforms.MustParse("linux/amd64"), + } + + frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + res := gateway.NewResult() + expPlatforms := &exptypes.Platforms{} + + for _, p := range ps { + pk := platforms.Format(p) + expPlatforms.Platforms = append(expPlatforms.Platforms, exptypes.Platform{ID: pk, Platform: p}) + + // build image + st := llb.Scratch().File( + llb.Mkfile("/greeting", 0600, []byte(fmt.Sprintf("hello %s!", pk))), + ) + 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(pk, ref) + + stmt := intoto.Statement{ + StatementHeader: intoto.StatementHeader{ + Type: intoto.StatementInTotoV01, + PredicateType: "https://example.com/attestations/v1.0", + }, + Predicate: map[string]interface{}{ + "foo": "1", + }, + } + buff := bytes.NewBuffer(nil) + enc := json.NewEncoder(buff) + require.NoError(t, enc.Encode(stmt)) + + // build attestations + st = llb.Scratch() + st = st.File( + llb.Mkdir("/bundle", 0700), + ) + st = st.File( + llb.Mkfile("/bundle/attestation.json", 0600, buff.Bytes()), + ) + 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 + } + refAttest, err := r.SingleRef() + if err != nil { + return nil, err + } + _, err = ref.ToState() + if err != nil { + return nil, err + } + res.AddAttestation(pk, result.Attestation{ + Kind: gatewaypb.AttestationKindBundle, + Path: "/bundle", + }, refAttest) + } + + dt, err := json.Marshal(expPlatforms) + if err != nil { + return nil, err + } + res.AddMeta(exptypes.ExporterPlatformsKey, dt) + + return res, nil + } + + target := registry + "/buildkit/testattestationsbundle:latest" + _, err = c.Build(sb.Context(), SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterImage, + Attrs: map[string]string{ + "name": target, + "push": "true", + }, + }, + }, + }, "", frontend, nil) + require.NoError(t, err) + + desc, provider, err := contentutil.ProviderFromRef(target) + require.NoError(t, err) + + imgs, err := testutil.ReadImages(sb.Context(), provider, desc) + require.NoError(t, err) + require.Equal(t, len(ps)*2, len(imgs.Images)) + + var bases []*testutil.ImageInfo + for _, p := range ps { + pk := platforms.Format(p) + bases = append(bases, imgs.Find(pk)) + } + + atts := imgs.Filter("unknown/unknown") + require.Equal(t, len(ps)*1, len(atts.Images)) + for i, att := range atts.Images { + require.Equal(t, 1, len(att.LayersRaw)) + var attest intoto.Statement + require.NoError(t, json.Unmarshal(att.LayersRaw[0], &attest)) + + require.Equal(t, "https://example.com/attestations/v1.0", attest.PredicateType) + require.Equal(t, map[string]interface{}{"foo": "1"}, attest.Predicate) + name, _ := purl.RefToPURL(target, &ps[i]) + subjects := []intoto.Subject{{ + Name: name, + Digest: map[string]string{ + "sha256": bases[i].Desc.Digest.Encoded(), + }, + }} + require.Equal(t, subjects, attest.Subject) + } +} + +func testSBOMScan(t *testing.T, sb integration.Sandbox) { + requiresLinux(t) + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + + registry, err := sb.NewRegistry() + if errors.Is(err, integration.ErrRequirements) { + t.Skip(err.Error()) + } + + p := platforms.MustParse("linux/amd64") + pk := platforms.Format(p) + + scannerFrontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + res := gateway.NewResult() + + st := llb.Image("busybox") + def, err := st.Marshal(sb.Context()) + require.NoError(t, 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(pk, ref) + + expPlatforms := &exptypes.Platforms{ + Platforms: []exptypes.Platform{{ID: pk, Platform: p}}, + } + dt, err := json.Marshal(expPlatforms) + if err != nil { + return nil, err + } + res.AddMeta(exptypes.ExporterPlatformsKey, dt) + + var img ocispecs.Image + cmd := ` +cat < $BUILDKIT_SCAN_DESTINATION/spdx.json +{ + "_type": "https://in-toto.io/Statement/v0.1", + "predicateType": "https://spdx.dev/Document", + "predicate": {"success": false} +} +EOF +` + img.Config.Cmd = []string{"/bin/sh", "-c", cmd} + 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, pk), config) + + return res, nil + } + + scannerTarget := registry + "/buildkit/testsbomscanner:latest" + _, err = c.Build(sb.Context(), SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterImage, + Attrs: map[string]string{ + "name": scannerTarget, + "push": "true", + }, + }, + }, + }, "", scannerFrontend, nil) + require.NoError(t, err) + + makeTargetFrontend := func(attest bool) func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + return func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + res := gateway.NewResult() + + // build image + st := llb.Scratch().File( + llb.Mkfile("/greeting", 0600, []byte("hello world!")), + ) + 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(pk, ref) + + expPlatforms := &exptypes.Platforms{ + Platforms: []exptypes.Platform{{ID: pk, Platform: p}}, + } + dt, err := json.Marshal(expPlatforms) + if err != nil { + return nil, err + } + res.AddMeta(exptypes.ExporterPlatformsKey, dt) + + // build attestations + if attest { + st = llb.Scratch(). + File(llb.Mkfile("/result.spdx", 0600, []byte(`{"success": true}`))) + 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 + } + refAttest, err := r.SingleRef() + if err != nil { + return nil, err + } + _, err = ref.ToState() + if err != nil { + return nil, err + } + + res.AddAttestation(pk, result.Attestation{ + Kind: gatewaypb.AttestationKindInToto, + Path: "/result.spdx", + InToto: result.InTotoAttestation{ + PredicateType: intoto.PredicateSPDX, + }, + }, refAttest) + } + + return res, nil + } + } + + // test the default fallback scanner + target := registry + "/buildkit/testsbom:latest" + _, err = c.Build(sb.Context(), SolveOpt{ + FrontendAttrs: map[string]string{ + "attest:sbom": "", + }, + Exports: []ExportEntry{ + { + Type: ExporterImage, + Attrs: map[string]string{ + "name": target, + "push": "true", + }, + }, + }, + }, "", makeTargetFrontend(false), nil) + require.NoError(t, err) + + desc, provider, err := contentutil.ProviderFromRef(target) + require.NoError(t, err) + + imgs, err := testutil.ReadImages(sb.Context(), provider, desc) + require.NoError(t, err) + require.Equal(t, 2, len(imgs.Images)) + + // test the frontend builtin scanner + target = registry + "/buildkit/testsbom2:latest" + _, err = c.Build(sb.Context(), SolveOpt{ + FrontendAttrs: map[string]string{ + "attest:sbom": "", + }, + Exports: []ExportEntry{ + { + Type: ExporterImage, + Attrs: map[string]string{ + "name": target, + "push": "true", + }, + }, + }, + }, "", makeTargetFrontend(true), nil) + require.NoError(t, err) + + desc, provider, err = contentutil.ProviderFromRef(target) + require.NoError(t, err) + + imgs, err = testutil.ReadImages(sb.Context(), provider, desc) + require.NoError(t, err) + require.Equal(t, 2, len(imgs.Images)) + + att := imgs.Find("unknown/unknown") + attest := intoto.Statement{} + require.NoError(t, json.Unmarshal(att.LayersRaw[0], &attest)) + require.Equal(t, "https://in-toto.io/Statement/v0.1", attest.Type) + require.Equal(t, intoto.PredicateSPDX, attest.PredicateType) + require.Equal(t, map[string]interface{}{"success": true}, attest.Predicate) + + // test the specified fallback scanner + target = registry + "/buildkit/testsbom3:latest" + _, err = c.Build(sb.Context(), SolveOpt{ + FrontendAttrs: map[string]string{ + "attest:sbom": "generator=" + scannerTarget, + }, + Exports: []ExportEntry{ + { + Type: ExporterImage, + Attrs: map[string]string{ + "name": target, + "push": "true", + }, + }, + }, + }, "", makeTargetFrontend(false), nil) + require.NoError(t, err) + + desc, provider, err = contentutil.ProviderFromRef(target) + require.NoError(t, err) + + imgs, err = testutil.ReadImages(sb.Context(), provider, desc) + require.NoError(t, err) + require.Equal(t, 2, len(imgs.Images)) + + att = imgs.Find("unknown/unknown") + attest = intoto.Statement{} + require.NoError(t, json.Unmarshal(att.LayersRaw[0], &attest)) + require.Equal(t, "https://in-toto.io/Statement/v0.1", attest.Type) + require.Equal(t, intoto.PredicateSPDX, attest.PredicateType) + require.Equal(t, map[string]interface{}{"success": false}, attest.Predicate) + + // test the builtin frontend scanner and the specified fallback scanner together + target = registry + "/buildkit/testsbom3:latest" + _, err = c.Build(sb.Context(), SolveOpt{ + FrontendAttrs: map[string]string{ + "attest:sbom": "generator=" + scannerTarget, + }, + Exports: []ExportEntry{ + { + Type: ExporterImage, + Attrs: map[string]string{ + "name": target, + "push": "true", + }, + }, + }, + }, "", makeTargetFrontend(true), nil) + require.NoError(t, err) + + desc, provider, err = contentutil.ProviderFromRef(target) + require.NoError(t, err) + + imgs, err = testutil.ReadImages(sb.Context(), provider, desc) + require.NoError(t, err) + require.Equal(t, 2, len(imgs.Images)) + + att = imgs.Find("unknown/unknown") + attest = intoto.Statement{} + require.NoError(t, json.Unmarshal(att.LayersRaw[0], &attest)) + require.Equal(t, "https://in-toto.io/Statement/v0.1", attest.Type) + require.Equal(t, intoto.PredicateSPDX, attest.PredicateType) + require.Equal(t, map[string]interface{}{"success": true}, attest.Predicate) +} + +func testSBOMScanSingleRef(t *testing.T, sb integration.Sandbox) { + requiresLinux(t) + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + + registry, err := sb.NewRegistry() + if errors.Is(err, integration.ErrRequirements) { + t.Skip(err.Error()) + } + + p := platforms.DefaultSpec() + pk := platforms.Format(p) + + scannerFrontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + res := gateway.NewResult() + + st := llb.Image("busybox") + def, err := st.Marshal(sb.Context()) + require.NoError(t, 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(pk, ref) + + expPlatforms := &exptypes.Platforms{ + Platforms: []exptypes.Platform{{ID: pk, Platform: p}}, + } + dt, err := json.Marshal(expPlatforms) + if err != nil { + return nil, err + } + res.AddMeta(exptypes.ExporterPlatformsKey, dt) + + var img ocispecs.Image + cmd := ` +cat < $BUILDKIT_SCAN_DESTINATION/spdx.json +{ + "_type": "https://in-toto.io/Statement/v0.1", + "predicateType": "https://spdx.dev/Document", + "predicate": {"success": false} +} +EOF +` + img.Config.Cmd = []string{"/bin/sh", "-c", cmd} + 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, pk), config) + + return res, nil + } + + scannerTarget := registry + "/buildkit/testsbomscanner:latest" + _, err = c.Build(sb.Context(), SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterImage, + Attrs: map[string]string{ + "name": scannerTarget, + "push": "true", + }, + }, + }, + }, "", scannerFrontend, nil) + require.NoError(t, err) + + targetFrontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + res := gateway.NewResult() + + // build image + st := llb.Scratch().File( + llb.Mkfile("/greeting", 0600, []byte("hello world!")), + ) + 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.SetRef(ref) + + var img ocispecs.Image + img.Config.Cmd = []string{"/bin/sh", "-c", "cat /greeting"} + config, err := json.Marshal(img) + if err != nil { + return nil, errors.Wrapf(err, "failed to marshal image config") + } + res.AddMeta(exptypes.ExporterImageConfigKey, config) + + return res, nil + } + + target := registry + "/buildkit/testsbomsingle:latest" + _, err = c.Build(sb.Context(), SolveOpt{ + FrontendAttrs: map[string]string{ + "attest:sbom": "generator=" + scannerTarget, + }, + Exports: []ExportEntry{ + { + Type: ExporterImage, + Attrs: map[string]string{ + "name": target, + "push": "true", + }, + }, + }, + }, "", targetFrontend, nil) + require.NoError(t, err) + + desc, provider, err := contentutil.ProviderFromRef(target) + require.NoError(t, err) + + imgs, err := testutil.ReadImages(sb.Context(), provider, desc) + require.NoError(t, err) + require.Equal(t, 2, len(imgs.Images)) + + img := imgs.Find(pk) + require.NotNil(t, img) + require.Equal(t, []string{"/bin/sh", "-c", "cat /greeting"}, img.Img.Config.Cmd) + + att := imgs.Find("unknown/unknown") + require.NotNil(t, att) + attest := intoto.Statement{} + require.NoError(t, json.Unmarshal(att.LayersRaw[0], &attest)) + require.Equal(t, "https://in-toto.io/Statement/v0.1", attest.Type) + require.Equal(t, intoto.PredicateSPDX, attest.PredicateType) + require.Equal(t, map[string]interface{}{"success": false}, attest.Predicate) +} + func makeSSHAgentSock(t *testing.T, agent agent.Agent) (p string, err error) { tmpDir, err := integration.Tmpdir(t) if err != nil { diff --git a/cmd/buildctl/build.go b/cmd/buildctl/build.go index db1a5684ad12..bd78f8ff7b3d 100644 --- a/cmd/buildctl/build.go +++ b/cmd/buildctl/build.go @@ -284,8 +284,6 @@ func buildAction(clicontext *cli.Context) error { if def != nil { sreq.Definition = def.ToPB() } - solveOpt.Frontend = "" - solveOpt.FrontendAttrs = nil resp, err := c.Build(ctx, solveOpt, "buildctl", func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { _, isSubRequest := sreq.FrontendOpt["requestid"] diff --git a/control/control.go b/control/control.go index 8ae6de8d039c..7ab14dfb4d5c 100644 --- a/control/control.go +++ b/control/control.go @@ -6,6 +6,7 @@ import ( "sync/atomic" "time" + "github.com/docker/distribution/reference" controlapi "github.com/moby/buildkit/api/services/control" apitypes "github.com/moby/buildkit/api/types" "github.com/moby/buildkit/cache/remotecache" @@ -14,10 +15,12 @@ import ( "github.com/moby/buildkit/exporter" "github.com/moby/buildkit/exporter/util/epoch" "github.com/moby/buildkit/frontend" + "github.com/moby/buildkit/frontend/attestations" "github.com/moby/buildkit/session" "github.com/moby/buildkit/session/grpchijack" "github.com/moby/buildkit/solver" "github.com/moby/buildkit/solver/llbsolver" + "github.com/moby/buildkit/solver/llbsolver/proc" "github.com/moby/buildkit/solver/pb" "github.com/moby/buildkit/util/bklog" "github.com/moby/buildkit/util/imageutil" @@ -323,6 +326,25 @@ func (c *Controller) Solve(ctx context.Context, req *controlapi.SolveRequest) (* }) } + attests, err := attestations.Parse(req.FrontendAttrs) + if err != nil { + return nil, err + } + + var procs []llbsolver.Processor + if attrs, ok := attests["sbom"]; ok { + src := attrs["generator"] + if src == "" { + return nil, errors.Errorf("sbom generator cannot be empty") + } + ref, err := reference.ParseNormalizedNamed(src) + if err != nil { + return nil, errors.Wrapf(err, "failed to parse sbom generator %s", src) + } + ref = reference.TagNameOnly(ref) + procs = append(procs, proc.ForceRefsProcessor, proc.SBOMProcessor(ref.String())) + } + resp, err := c.solver.Solve(ctx, req.Ref, req.Session, frontend.SolveRequest{ Frontend: req.Frontend, Definition: req.Definition, @@ -333,7 +355,7 @@ func (c *Controller) Solve(ctx context.Context, req *controlapi.SolveRequest) (* Exporter: expi, CacheExporter: cacheExporter, CacheExportMode: cacheExportMode, - }, req.Entitlements) + }, req.Entitlements, procs) if err != nil { return nil, err } diff --git a/exporter/containerimage/exptypes/types.go b/exporter/containerimage/exptypes/types.go index 08b2daf2f1b0..f22344c86a7e 100644 --- a/exporter/containerimage/exptypes/types.go +++ b/exporter/containerimage/exptypes/types.go @@ -16,6 +16,14 @@ const ( ExporterEpochKey = "source.date.epoch" ) +// KnownRefMetadataKeys are the subset of exporter keys that can be suffixed by +// a platform to become platform specific +var KnownRefMetadataKeys = []string{ + ExporterImageConfigKey, + ExporterInlineCache, + ExporterBuildInfo, +} + type Platforms struct { Platforms []Platform } diff --git a/exporter/containerimage/writer.go b/exporter/containerimage/writer.go index 107f66e01aac..66aecacde580 100644 --- a/exporter/containerimage/writer.go +++ b/exporter/containerimage/writer.go @@ -6,7 +6,6 @@ import ( "encoding/json" "fmt" "os" - "path" "strings" "time" @@ -14,6 +13,7 @@ import ( "github.com/containerd/containerd/diff" "github.com/containerd/containerd/images" "github.com/containerd/containerd/platforms" + "github.com/containerd/continuity/fs" intoto "github.com/in-toto/in-toto-golang/in_toto" "github.com/moby/buildkit/cache" cacheconfig "github.com/moby/buildkit/cache/config" @@ -266,7 +266,19 @@ func (ic *ImageWriter) exportLayers(ctx context.Context, refCfg cacheconfig.RefC func (ic *ImageWriter) extractAttestations(ctx context.Context, opts *ImageCommitOpts, s session.Group, desc *ocispecs.Descriptor, refs map[string]cache.ImmutableRef, attestations []result.Attestation) ([]intoto.Statement, error) { eg, ctx := errgroup.WithContext(ctx) - statements := make([]intoto.Statement, len(attestations)) + statements := make([][]intoto.Statement, len(attestations)) + + var purls []string + for _, name := range strings.Split(opts.ImageName, ",") { + if name == "" { + continue + } + p, err := purl.RefToPURL(name, desc.Platform) + if err != nil { + return nil, err + } + purls = append(purls, p) + } if len(attestations) > 0 && refs == nil { return nil, errors.Errorf("no refs map provided to lookup attestation keys") @@ -275,58 +287,54 @@ func (ic *ImageWriter) extractAttestations(ctx context.Context, opts *ImageCommi for i, att := range attestations { i, att := i, att eg.Go(func() error { + ref, ok := refs[att.Ref] + if !ok { + return errors.Errorf("key %s not found in refs map", att.Ref) + } + mount, err := ref.Mount(ctx, true, s) + if err != nil { + return err + } + lm := snapshot.LocalMounter(mount) + src, err := lm.Mount() + if err != nil { + return err + } + defer lm.Unmount() + switch att.Kind { case gatewaypb.AttestationKindInToto: - ref, ok := refs[att.Ref] - if !ok { - return errors.Errorf("key %s not found in refs map", att.Ref) - } - - mount, err := ref.Mount(ctx, true, s) + p, err := fs.RootPath(src, att.Path) if err != nil { return err } - - lm := snapshot.LocalMounter(mount) - src, err := lm.Mount() + data, err := os.ReadFile(p) if err != nil { - return err + return errors.Wrap(err, "cannot read in-toto attestation") } - defer lm.Unmount() - predicate, err := os.ReadFile(path.Join(src, att.Path)) - if err != nil { - return err - } - if len(predicate) == 0 { - predicate = nil + if len(data) == 0 { + data = nil } + var subjects []intoto.Subject if len(att.InToto.Subjects) == 0 { att.InToto.Subjects = []result.InTotoSubject{{ Kind: gatewaypb.InTotoSubjectKindSelf, }} } - - subjects := make([]intoto.Subject, 0, len(att.InToto.Subjects)) for _, subject := range att.InToto.Subjects { name := "_" if subject.Name != "" { name = subject.Name } + switch subject.Kind { case gatewaypb.InTotoSubjectKindSelf: - var names []string - if opts.ImageName != "" { - for _, name := range strings.Split(opts.ImageName, ",") { - name, err := purl.RefToPURL(name, desc.Platform) - if err != nil { - return err - } - names = append(names, name) - } - } else { - names = []string{name} + names := []string{} + if name != "_" { + names = append(names, name) } + names = append(names, purls...) for _, name := range names { subjects = append(subjects, intoto.Subject{ Name: name, @@ -338,28 +346,70 @@ func (ic *ImageWriter) extractAttestations(ctx context.Context, opts *ImageCommi Name: name, Digest: result.DigestMap(subject.Digest...), }) - default: - return errors.Errorf("unknown attestation subject kind %q", subject.Kind) + return errors.Errorf("unknown attestation subject type %T", subject) } } - statements[i] = intoto.Statement{ + + stmt := intoto.Statement{ StatementHeader: intoto.StatementHeader{ Type: intoto.StatementInTotoV01, PredicateType: att.InToto.PredicateType, Subject: subjects, }, - Predicate: json.RawMessage(predicate), + Predicate: json.RawMessage(data), + } + statements[i] = append(statements[i], stmt) + case gatewaypb.AttestationKindBundle: + dir, err := fs.RootPath(src, att.Path) + if err != nil { + return err + } + entries, err := os.ReadDir(dir) + if err != nil { + return err + } + + for _, entry := range entries { + p, err := fs.RootPath(dir, entry.Name()) + if err != nil { + return err + } + f, err := os.Open(p) + if err != nil { + return err + } + dec := json.NewDecoder(f) + var stmt intoto.Statement + if err := dec.Decode(&stmt); err != nil { + return errors.Wrap(err, "cannot decode in-toto statement") + } + if att.InToto.PredicateType != "" && stmt.PredicateType != att.InToto.PredicateType { + return errors.Errorf("bundle entry %s does not match required predicate type %s", stmt.PredicateType, att.InToto.PredicateType) + } + if stmt.Subject == nil { + for _, name := range purls { + stmt.Subject = append(stmt.Subject, intoto.Subject{ + Name: name, + Digest: result.DigestMap(desc.Digest), + }) + } + } + statements[i] = append(statements[i], stmt) } } return nil }) } - if err := eg.Wait(); err != nil { return nil, err } - return statements, nil + + var allStatements []intoto.Statement + for _, statements := range statements { + allStatements = append(allStatements, statements...) + } + return allStatements, nil } func (ic *ImageWriter) commitDistributionManifest(ctx context.Context, opts *ImageCommitOpts, ref cache.ImmutableRef, config []byte, remote *solver.Remote, annotations *Annotations, inlineCache []byte, buildInfo []byte, epoch *time.Time) (*ocispecs.Descriptor, *ocispecs.Descriptor, error) { @@ -485,7 +535,7 @@ func (ic *ImageWriter) commitAttestationsManifest(ctx context.Context, opts *Ima data, err := json.Marshal(statement) if err != nil { - return nil, err + return nil, errors.Wrap(err, "failed to marshal attestation") } digest := digest.FromBytes(data) desc := ocispecs.Descriptor{ diff --git a/frontend/attest/sbom.go b/frontend/attest/sbom.go new file mode 100644 index 000000000000..6c70a3d654a9 --- /dev/null +++ b/frontend/attest/sbom.go @@ -0,0 +1,84 @@ +package attest + +import ( + "context" + "encoding/json" + "fmt" + "path" + + intoto "github.com/in-toto/in-toto-golang/in_toto" + "github.com/moby/buildkit/client/llb" + gatewaypb "github.com/moby/buildkit/frontend/gateway/pb" + "github.com/moby/buildkit/solver/result" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/pkg/errors" +) + +// Scanner is a function type for scanning the contents of a state and +// returning a new attestation and state representing the scan results. +// +// A scanner is designed a scan a single state, however, additional states can +// also be attached, for attaching additional information, such as scans of +// build-contexts or multi-stage builds. Handling these separately allows the +// scanner to optionally ignore these or to mark them as such in the +// attestation. +type Scanner func(ctx context.Context, name string, ref llb.State, extras map[string]llb.State) (result.Attestation, llb.State, error) + +func CreateSBOMScanner(ctx context.Context, resolver llb.ImageMetaResolver, scanner string) (Scanner, error) { + if scanner == "" { + return nil, nil + } + + _, dt, err := resolver.ResolveImageConfig(ctx, scanner, llb.ResolveImageConfigOpt{}) + if err != nil { + return nil, err + } + + var cfg ocispecs.Image + if err := json.Unmarshal(dt, &cfg); err != nil { + return nil, err + } + if len(cfg.Config.Cmd) == 0 { + return nil, errors.Errorf("scanner %s does not have cmd", scanner) + } + + return func(ctx context.Context, name string, ref llb.State, extras map[string]llb.State) (result.Attestation, llb.State, error) { + srcDir := "/run/src/" + outDir := "/run/out/" + + args := []string{} + args = append(args, cfg.Config.Entrypoint...) + args = append(args, cfg.Config.Cmd...) + runscan := llb.Image(scanner).Run( + llb.Dir(cfg.Config.WorkingDir), + llb.AddEnv("BUILDKIT_SCAN_SOURCE", path.Join(srcDir, "core")), + llb.AddEnv("BUILDKIT_SCAN_SOURCE_EXTRAS", path.Join(srcDir, "extras/")), + llb.AddEnv("BUILDKIT_SCAN_DESTINATION", outDir), + llb.Args(args), + llb.WithCustomName(fmt.Sprintf("[%s] generating sbom using %s", name, scanner))) + + runscan.AddMount(path.Join(srcDir, "core"), ref, llb.Readonly) + for k, extra := range extras { + runscan.AddMount(path.Join(srcDir, "extras", k), extra, llb.Readonly) + } + + stsbom := runscan.AddMount(outDir, llb.Scratch()) + return result.Attestation{ + Kind: gatewaypb.AttestationKindBundle, + InToto: result.InTotoAttestation{ + PredicateType: intoto.PredicateSPDX, + }, + }, stsbom, nil + }, nil +} + +func HasSBOM[T any](res *result.Result[T]) bool { + for _, as := range res.Attestations { + for _, a := range as { + if a.InToto.PredicateType == intoto.PredicateSPDX { + return true + } + } + } + return false +} diff --git a/frontend/attestations/parse.go b/frontend/attestations/parse.go new file mode 100644 index 000000000000..7d697a0f4e1d --- /dev/null +++ b/frontend/attestations/parse.go @@ -0,0 +1,72 @@ +package attestations + +import ( + "encoding/csv" + "strings" + + "github.com/pkg/errors" +) + +const ( + KeyTypeSbom = "sbom" + KeyTypeProvenance = "provenance" +) + +const ( + // TODO: update this before next buildkit release + defaultSBOMGenerator = "jedevc/buildkit-syft-scanner:master@sha256:de630f621eb0ab1bb1245cea76d01c5bddfe78af4f5b9adecde424cb7ec5605e" +) + +func Filter(v map[string]string) map[string]string { + attests := make(map[string]string) + for k, v := range v { + if strings.HasPrefix(k, "attest:") { + attests[k] = v + continue + } + if strings.HasPrefix(k, "build-arg:BUILDKIT_ATTEST_") { + attests[k] = v + continue + } + } + return attests +} + +func Parse(v map[string]string) (map[string]map[string]string, error) { + attests := make(map[string]string) + for k, v := range v { + if strings.HasPrefix(k, "attest:") { + attests[strings.ToLower(strings.TrimPrefix(k, "attest:"))] = v + continue + } + if strings.HasPrefix(k, "build-arg:BUILDKIT_ATTEST_") { + attests[strings.ToLower(strings.TrimPrefix(k, "build-arg:BUILDKIT_ATTEST_"))] = v + continue + } + } + + out := make(map[string]map[string]string) + for k, v := range attests { + attrs := make(map[string]string) + out[k] = attrs + if k == KeyTypeSbom { + attrs["generator"] = defaultSBOMGenerator + } + if v == "" { + continue + } + csvReader := csv.NewReader(strings.NewReader(v)) + fields, err := csvReader.Read() + if err != nil { + return nil, errors.Wrapf(err, "failed to parse %s", k) + } + for _, field := range fields { + parts := strings.SplitN(field, "=", 2) + if len(parts) != 2 { + parts = append(parts, "") + } + attrs[parts[0]] = parts[1] + } + } + return out, nil +} diff --git a/frontend/dockerfile/builder/build.go b/frontend/dockerfile/builder/build.go index 9f381aa3b47b..5494351af511 100644 --- a/frontend/dockerfile/builder/build.go +++ b/frontend/dockerfile/builder/build.go @@ -20,6 +20,9 @@ import ( controlapi "github.com/moby/buildkit/api/services/control" "github.com/moby/buildkit/client/llb" "github.com/moby/buildkit/exporter/containerimage/exptypes" + "github.com/moby/buildkit/frontend" + "github.com/moby/buildkit/frontend/attest" + "github.com/moby/buildkit/frontend/attestations" "github.com/moby/buildkit/frontend/dockerfile/dockerfile2llb" "github.com/moby/buildkit/frontend/dockerfile/dockerignore" "github.com/moby/buildkit/frontend/dockerfile/parser" @@ -485,7 +488,30 @@ func Build(ctx context.Context, c client.Client) (_ *client.Result, err error) { } } - eg, ctx = errgroup.WithContext(ctx) + var scanner attest.Scanner + attests, err := attestations.Parse(opts) + if err != nil { + return nil, err + } + if attrs, ok := attests[attestations.KeyTypeSbom]; ok { + src, ok := attrs["generator"] + if !ok { + return nil, errors.Errorf("sbom scanner cannot be empty") + } + ref, err := reference.ParseNormalizedNamed(src) + if err != nil { + return nil, errors.Wrapf(err, "failed to parse sbom scanner %s", src) + } + ref = reference.TagNameOnly(ref) + exportMap = true + + scanner, err = attest.CreateSBOMScanner(ctx, c, ref.String()) + if err != nil { + return nil, err + } + } + + eg, ctx2 = errgroup.WithContext(ctx) for i, tp := range targetPlatforms { func(i int, tp *ocispecs.Platform) { @@ -496,13 +522,13 @@ func Build(ctx context.Context, c client.Client) (_ *client.Result, err error) { opt.Warn = nil } opt.ContextByName = contextByNameFunc(c, c.BuildOpts().SessionID) - st, img, bi, err := dockerfile2llb.Dockerfile2LLB(ctx, dtDockerfile, opt) + st, img, bi, err := dockerfile2llb.Dockerfile2LLB(ctx2, dtDockerfile, opt) if err != nil { return err } - def, err := st.Marshal(ctx) + def, err := st.Marshal(ctx2) if err != nil { return errors.Wrapf(err, "failed to marshal LLB definition") } @@ -538,7 +564,7 @@ func Build(ctx context.Context, c client.Client) (_ *client.Result, err error) { } } - r, err := c.Solve(ctx, client.SolveRequest{ + r, err := c.Solve(ctx2, client.SolveRequest{ Definition: def.ToPB(), CacheImports: cacheImports, }) @@ -590,6 +616,37 @@ func Build(ctx context.Context, c client.Client) (_ *client.Result, err error) { return nil, err } + if scanner != nil { + for _, p := range expPlatforms.Platforms { + ref, ok := res.Refs[p.ID] + if !ok { + return nil, errors.Errorf("could not find ref %s", p.ID) + } + st, err := ref.ToState() + if err != nil { + return nil, err + } + + att, st, err := scanner(ctx, p.ID, st, nil) + if err != nil { + return nil, err + } + + def, err := st.Marshal(ctx) + if err != nil { + return nil, err + } + r, err := c.Solve(ctx, frontend.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, err + } + + res.AddAttestation(p.ID, att, r.Ref) + } + } + dt, err := json.Marshal(expPlatforms) if err != nil { return nil, err diff --git a/frontend/dockerfile/dockerfile_test.go b/frontend/dockerfile/dockerfile_test.go index 3747d11b7525..0e11aabb1164 100644 --- a/frontend/dockerfile/dockerfile_test.go +++ b/frontend/dockerfile/dockerfile_test.go @@ -27,6 +27,7 @@ import ( "github.com/containerd/containerd/platforms" "github.com/containerd/containerd/snapshots" "github.com/containerd/continuity/fs/fstest" + intoto "github.com/in-toto/in-toto-golang/in_toto" "github.com/moby/buildkit/client" "github.com/moby/buildkit/client/llb" "github.com/moby/buildkit/frontend/dockerfile/builder" @@ -146,6 +147,7 @@ var allTests = integration.TestFuncs( testCopyFollowAllSymlinks, testDockerfileAddChownExpand, testSourceDateEpochWithoutExporter, + testSBOMScannerImage, ) // Tests that depend on the `security.*` entitlements @@ -6046,6 +6048,110 @@ COPY Dockerfile . } } +func testSBOMScannerImage(t *testing.T, sb integration.Sandbox) { + ctx := sb.Context() + + c, err := client.New(ctx, sb.Address()) + require.NoError(t, err) + defer c.Close() + + registry, err := sb.NewRegistry() + if errors.Is(err, integration.ErrRequirements) { + t.Skip(err.Error()) + } + require.NoError(t, err) + + f := getFrontend(t, sb) + + dockerfile := []byte(` +FROM busybox:latest +COPY <<-"EOF" /scan.sh + set -e + cat < $BUILDKIT_SCAN_DESTINATION/spdx.json + { + "_type": "https://in-toto.io/Statement/v0.1", + "predicateType": "https://spdx.dev/Document", + "predicate": {"success": true} + } + BUNDLE +EOF +CMD sh /scan.sh +`) + scannerDir, err := integration.Tmpdir( + t, + fstest.CreateFile("Dockerfile", dockerfile, 0600), + ) + require.NoError(t, err) + + scannerTarget := registry + "/buildkit/testsbomscanner:latest" + _, err = f.Solve(sb.Context(), c, client.SolveOpt{ + LocalDirs: map[string]string{ + builder.DefaultLocalNameDockerfile: scannerDir, + builder.DefaultLocalNameContext: scannerDir, + }, + Exports: []client.ExportEntry{ + { + Type: client.ExporterImage, + Attrs: map[string]string{ + "name": scannerTarget, + "push": "true", + }, + }, + }, + }, nil) + require.NoError(t, err) + + dockerfile = []byte(` +FROM scratch +COPY < 0 { + return result, nil + } + if result.Ref == nil { + return nil, errors.New("no refs to operate on") + } + + // try to determine platform from image config, fallback to current platform + p := platforms.DefaultSpec() + if imgConfig, ok := result.Metadata[exptypes.ExporterImageConfigKey]; ok { + var img ocispecs.Image + err := json.Unmarshal(imgConfig, &img) + if err != nil { + return nil, err + } + + if img.OS != "" && img.Architecture != "" { + p = ocispecs.Platform{ + Architecture: img.Architecture, + OS: img.OS, + OSVersion: img.OSVersion, + OSFeatures: img.OSFeatures, + Variant: img.Variant, + } + } + } + p = platforms.Normalize(p) + pk := platforms.Format(p) + + result.Refs = map[string]solver.ResultProxy{ + pk: result.Ref, + } + result.Ref = nil + + if result.Metadata != nil { + for _, key := range exptypes.KnownRefMetadataKeys { + if value, ok := result.Metadata[key]; ok { + result.Metadata[fmt.Sprintf("%s/%s", key, pk)] = value + delete(result.Metadata, key) + } + } + } + + expPlatforms := exptypes.Platforms{ + Platforms: []exptypes.Platform{{ID: pk, Platform: p}}, + } + dt, err := json.Marshal(expPlatforms) + if err != nil { + return nil, err + } + result.AddMeta(exptypes.ExporterPlatformsKey, dt) + + return result, nil +} diff --git a/solver/llbsolver/proc/sbom.go b/solver/llbsolver/proc/sbom.go new file mode 100644 index 000000000000..3b66f7693a24 --- /dev/null +++ b/solver/llbsolver/proc/sbom.go @@ -0,0 +1,74 @@ +package proc + +import ( + "context" + "encoding/json" + + "github.com/moby/buildkit/client/llb" + "github.com/moby/buildkit/exporter/containerimage/exptypes" + "github.com/moby/buildkit/frontend" + "github.com/moby/buildkit/frontend/attest" + "github.com/moby/buildkit/solver" + "github.com/moby/buildkit/solver/llbsolver" + "github.com/pkg/errors" +) + +func SBOMProcessor(scannerRef string) llbsolver.Processor { + return func(ctx context.Context, res *frontend.Result, s *llbsolver.Solver, j *solver.Job) (*frontend.Result, error) { + // skip sbom generation if we already have an sbom + if attest.HasSBOM(res) { + return res, nil + } + + platformsBytes, ok := res.Metadata[exptypes.ExporterPlatformsKey] + if !ok { + return nil, errors.Errorf("unable to collect multiple refs, missing platforms mapping") + } + + var ps exptypes.Platforms + if len(platformsBytes) > 0 { + if err := json.Unmarshal(platformsBytes, &ps); err != nil { + return nil, errors.Wrapf(err, "failed to parse platforms passed to sbom processor") + } + } + + scanner, err := attest.CreateSBOMScanner(ctx, s.Bridge(j), scannerRef) + if err != nil { + return nil, err + } + if scanner == nil { + return res, nil + } + + for _, p := range ps.Platforms { + ref, ok := res.Refs[p.ID] + if !ok { + return nil, errors.Errorf("could not find ref %s", p.ID) + } + defop, err := llb.NewDefinitionOp(ref.Definition()) + if err != nil { + return nil, err + } + st := llb.NewState(defop) + + att, st, err := scanner(ctx, p.ID, st, nil) + if err != nil { + return nil, err + } + + def, err := st.Marshal(ctx) + if err != nil { + return nil, err + } + + r, err := s.Bridge(j).Solve(ctx, frontend.SolveRequest{ + Definition: def.ToPB(), + }, j.SessionID) + if err != nil { + return nil, err + } + res.AddAttestation(p.ID, att, r.Ref) + } + return res, nil + } +} diff --git a/solver/llbsolver/solver.go b/solver/llbsolver/solver.go index 5a87fc88e0e4..ed833afc15ff 100644 --- a/solver/llbsolver/solver.go +++ b/solver/llbsolver/solver.go @@ -64,6 +64,10 @@ type Solver struct { entitlements []string } +// Processor defines a processing function to be applied after solving, but +// before exporting +type Processor func(ctx context.Context, result *frontend.Result, s *Solver, j *solver.Job) (*frontend.Result, error) + func New(opt Opt) (*Solver, error) { s := &Solver{ workerController: opt.WorkerController, @@ -105,7 +109,7 @@ func (s *Solver) Bridge(b solver.Builder) frontend.FrontendLLBBridge { } } -func (s *Solver) Solve(ctx context.Context, id string, sessionID string, req frontend.SolveRequest, exp ExporterRequest, ent []entitlements.Entitlement) (*client.SolveResponse, error) { +func (s *Solver) Solve(ctx context.Context, id string, sessionID string, req frontend.SolveRequest, exp ExporterRequest, ent []entitlements.Entitlement, post []Processor) (*client.SolveResponse, error) { j, err := s.solver.NewJob(id) if err != nil { return nil, err @@ -147,6 +151,14 @@ func (s *Solver) Solve(ctx context.Context, id string, sessionID string, req fro } } + for _, post := range post { + res2, err := post(ctx, res, s, j) + if err != nil { + return nil, err + } + res = res2 + } + if res == nil { res = &frontend.Result{} } diff --git a/solver/result/result.go b/solver/result/result.go index 8401ffbc5914..a857128b56d9 100644 --- a/solver/result/result.go +++ b/solver/result/result.go @@ -41,6 +41,9 @@ func (r *Result[T]) AddRef(k string, ref T) { func (r *Result[T]) AddAttestation(k string, v Attestation, ref T) { r.mu.Lock() + if r.Refs == nil { + r.Refs = map[string]T{} + } if r.Attestations == nil { r.Attestations = map[string][]Attestation{} }