From 1bbf73efa9c52e22b6b445c1bd5f04cb507fa06b Mon Sep 17 00:00:00 2001 From: Tonis Tiigi Date: Wed, 30 Aug 2023 17:38:00 -0700 Subject: [PATCH] llb: avoid duplicate instances of sourcemaps in provenance If build contains multiple subbuilds all of their sources are tracked in provenance attestations. When some subbuilds are coming from same source file (eg. same Dockerfile but different targets) currently the same file would appear in multiple times. This detects such duplicates and makes sure definitions from multiple subbuilds can map to same file. Signed-off-by: Tonis Tiigi --- client/llb/sourcemap.go | 42 ++++- .../dockerfile/dockerfile_provenance_test.go | 155 ++++++++++++++++++ frontend/dockerfile/dockerfile_test.go | 1 + 3 files changed, 196 insertions(+), 2 deletions(-) diff --git a/client/llb/sourcemap.go b/client/llb/sourcemap.go index 721db3cebe9d..4e3be2b49936 100644 --- a/client/llb/sourcemap.go +++ b/client/llb/sourcemap.go @@ -1,6 +1,7 @@ package llb import ( + "bytes" "context" "github.com/moby/buildkit/solver/pb" @@ -47,6 +48,33 @@ func (s *SourceMap) Location(r []*pb.Range) ConstraintsOpt { }) } +func equalSourceMap(sm1, sm2 *SourceMap) (out bool) { + if sm1 == nil || sm2 == nil { + return false + } + if sm1.Filename != sm2.Filename { + return false + } + if sm1.Language != sm2.Language { + return false + } + if len(sm1.Data) != len(sm2.Data) { + return false + } + if !bytes.Equal(sm1.Data, sm2.Data) { + return false + } + if sm1.Definition != nil && sm2.Definition != nil { + if len(sm1.Definition.Def) != len(sm2.Definition.Def) && len(sm1.Definition.Def) != 0 { + return false + } + if !bytes.Equal(sm1.Definition.Def[len(sm1.Definition.Def)-1], sm2.Definition.Def[len(sm2.Definition.Def)-1]) { + return false + } + } + return true +} + type SourceLocation struct { SourceMap *SourceMap Ranges []*pb.Range @@ -69,8 +97,18 @@ func (smc *sourceMapCollector) Add(dgst digest.Digest, ls []*SourceLocation) { for _, l := range ls { idx, ok := smc.index[l.SourceMap] if !ok { - idx = len(smc.maps) - smc.maps = append(smc.maps, l.SourceMap) + idx = -1 + // slow equality check + for i, m := range smc.maps { + if equalSourceMap(m, l.SourceMap) { + idx = i + break + } + } + if idx == -1 { + idx = len(smc.maps) + smc.maps = append(smc.maps, l.SourceMap) + } } smc.index[l.SourceMap] = idx } diff --git a/frontend/dockerfile/dockerfile_provenance_test.go b/frontend/dockerfile/dockerfile_provenance_test.go index 333b4a2a76f2..fd4de154827b 100644 --- a/frontend/dockerfile/dockerfile_provenance_test.go +++ b/frontend/dockerfile/dockerfile_provenance_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" "net/http/httptest" "net/url" @@ -16,16 +17,20 @@ import ( "github.com/containerd/containerd/content" "github.com/containerd/containerd/content/local" + "github.com/containerd/containerd/content/proxy" "github.com/containerd/containerd/platforms" "github.com/containerd/continuity/fs/fstest" intoto "github.com/in-toto/in-toto-golang/in_toto" provenanceCommon "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/common" + controlapi "github.com/moby/buildkit/api/services/control" "github.com/moby/buildkit/client" "github.com/moby/buildkit/client/llb" "github.com/moby/buildkit/exporter/containerimage/exptypes" "github.com/moby/buildkit/frontend/dockerui" gateway "github.com/moby/buildkit/frontend/gateway/client" + "github.com/moby/buildkit/identity" "github.com/moby/buildkit/solver/llbsolver/provenance" + "github.com/moby/buildkit/solver/pb" "github.com/moby/buildkit/util/contentutil" "github.com/moby/buildkit/util/testutil" "github.com/moby/buildkit/util/testutil/integration" @@ -1113,3 +1118,153 @@ func testDockerIgnoreMissingProvenance(t *testing.T, sb integration.Sandbox) { }, "", frontend, nil) require.NoError(t, err) } + +func testFrontendDeduplicateSources(t *testing.T, sb integration.Sandbox) { + ctx := sb.Context() + + c, err := client.New(ctx, sb.Address()) + require.NoError(t, err) + defer c.Close() + + dockerfile := []byte(` +FROM scratch as base +COPY foo foo2 + +FROM linked +COPY bar bar2 +`) + + dir := integration.Tmpdir( + t, + fstest.CreateFile("Dockerfile", dockerfile, 0600), + fstest.CreateFile("foo", []byte("data"), 0600), + fstest.CreateFile("bar", []byte("data2"), 0600), + ) + + f := getFrontend(t, sb) + + b := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + res, err := f.SolveGateway(ctx, c, gateway.SolveRequest{ + FrontendOpt: map[string]string{ + "target": "base", + }, + }) + if err != nil { + return nil, err + } + ref, err := res.SingleRef() + if err != nil { + return nil, err + } + st, err := ref.ToState() + if err != nil { + return nil, err + } + + def, err := st.Marshal(ctx) + if err != nil { + return nil, err + } + + dt, ok := res.Metadata["containerimage.config"] + if !ok { + return nil, errors.Errorf("no containerimage.config in metadata") + } + + dt, err = json.Marshal(map[string][]byte{ + "containerimage.config": dt, + }) + if err != nil { + return nil, err + } + + res, err = f.SolveGateway(ctx, c, gateway.SolveRequest{ + FrontendOpt: map[string]string{ + "context:linked": "input:baseinput", + "input-metadata:linked": string(dt), + }, + FrontendInputs: map[string]*pb.Definition{ + "baseinput": def.ToPB(), + }, + }) + if err != nil { + return nil, err + } + return res, nil + } + + product := "buildkit_test" + + destDir := t.TempDir() + + ref := identity.NewID() + + _, err = c.Build(ctx, client.SolveOpt{ + LocalDirs: map[string]string{ + dockerui.DefaultLocalNameDockerfile: dir, + dockerui.DefaultLocalNameContext: dir, + }, + Exports: []client.ExportEntry{ + { + Type: client.ExporterLocal, + OutputDir: destDir, + }, + }, + Ref: ref, + }, product, b, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(filepath.Join(destDir, "foo2")) + require.NoError(t, err) + require.Equal(t, "data", string(dt)) + + dt, err = os.ReadFile(filepath.Join(destDir, "bar2")) + require.NoError(t, err) + require.Equal(t, "data2", string(dt)) + + history, err := c.ControlClient().ListenBuildHistory(ctx, &controlapi.BuildHistoryRequest{ + Ref: ref, + EarlyExit: true, + }) + require.NoError(t, err) + + store := proxy.NewContentStore(c.ContentClient()) + + var provDt []byte + for { + ev, err := history.Recv() + if err != nil { + require.Equal(t, io.EOF, err) + break + } + require.Equal(t, ref, ev.Record.Ref) + + for _, prov := range ev.Record.Result.Attestations { + if len(prov.Annotations) == 0 || prov.Annotations["in-toto.io/predicate-type"] != "https://slsa.dev/provenance/v0.2" { + t.Logf("skipping non-slsa provenance: %s", prov.MediaType) + continue + } + + provDt, err = content.ReadBlob(ctx, store, ocispecs.Descriptor{ + MediaType: prov.MediaType, + Digest: prov.Digest, + Size: prov.Size_, + }) + require.NoError(t, err) + } + } + + require.NotEqual(t, len(provDt), 0) + + var pred provenance.ProvenancePredicate + require.NoError(t, json.Unmarshal(provDt, &pred)) + + sources := pred.Metadata.BuildKitMetadata.Source.Infos + + require.Equal(t, 1, len(sources)) + require.Equal(t, "Dockerfile", sources[0].Filename) + require.Equal(t, "Dockerfile", sources[0].Language) + + require.Equal(t, dockerfile, sources[0].Data) + require.NotEqual(t, 0, len(sources[0].Definition)) +} diff --git a/frontend/dockerfile/dockerfile_test.go b/frontend/dockerfile/dockerfile_test.go index 6ae527d01d7a..8abe4f321004 100644 --- a/frontend/dockerfile/dockerfile_test.go +++ b/frontend/dockerfile/dockerfile_test.go @@ -170,6 +170,7 @@ var allTests = integration.TestFuncs( testMultiPlatformWarnings, testNilContextInSolveGateway, testCopyUnicodePath, + testFrontendDeduplicateSources, ) // Tests that depend on the `security.*` entitlements