From f0af1c83244775014029a3155319e9c2c7a5c5c8 Mon Sep 17 00:00:00 2001 From: Tonis Tiigi Date: Wed, 30 Sep 2026 08:22:35 +0000 Subject: [PATCH 1/4] build: support custom frontends and callback-only source policies Allow build.Options to select the frontend and pass frontend attributes directly instead of always solving with dockerfile.v0. Allow a PolicyConfig to carry a programmatic policy callback without a policy file. Callback-only entries run after file-based policies, require BuildKit's session source policy capability, and are not serialized. Both are needed by buildx replay, which replays the recorded frontend and pins sources with a policy callback. Signed-off-by: Tonis Tiigi --- build/build.go | 2 ++ build/opt.go | 49 ++++++++++++++++++++++++++++++---- util/buildflags/policy.go | 8 ++++++ util/buildflags/policy_test.go | 17 ++++++++++++ 4 files changed, 71 insertions(+), 5 deletions(-) diff --git a/build/build.go b/build/build.go index f1b90ea0230a..a8335e30c4d9 100644 --- a/build/build.go +++ b/build/build.go @@ -67,6 +67,8 @@ type Options struct { Inputs Inputs Ref string + Frontend string + FrontendAttrs map[string]string Allow []string Attests map[string]*string BuildArgs map[string]string diff --git a/build/opt.go b/build/opt.go index cf9c1098be09..375fe3941aa1 100644 --- a/build/opt.go +++ b/build/opt.go @@ -303,16 +303,23 @@ func toSolveOpt(ctx context.Context, np *noderesolver.ResolvedNode, multiDriver cacheFrom = append(cacheFrom, e) } + frontend := opt.Frontend + if frontend == "" { + frontend = "dockerfile.v0" + } so := client.SolveOpt{ Ref: opt.Ref, - Frontend: "dockerfile.v0", - FrontendAttrs: map[string]string{}, + Frontend: frontend, + FrontendAttrs: maps.Clone(opt.FrontendAttrs), LocalMounts: map[string]fsutil.FS{}, CacheExports: cacheTo, CacheImports: cacheFrom, AllowedEntitlements: opt.Allow, SourcePolicy: opt.SourcePolicy, } + if so.FrontendAttrs == nil { + so.FrontendAttrs = map[string]string{} + } if opt.CgroupParent != "" { so.FrontendAttrs["cgroup-parent"] = opt.CgroupParent @@ -668,10 +675,31 @@ func proxyArgKeyExists(buildArgs map[string]string, key string) bool { } func configureSourcePolicy(ctx context.Context, np *noderesolver.ResolvedNode, opt *Options, cfg *confutil.Config, bopts gateway.BuildOpts, so *client.SolveOpt, pw progress.Writer) (_ []func(error), err error) { + var callbackOnly []policysession.PolicyCallback + var fileConfigs []buildflags.PolicyConfig + for _, p := range opt.Policy { + if p.Callback != nil && len(p.Files) == 0 { + callbackOnly = append(callbackOnly, p.Callback) + continue + } + fileConfigs = append(fileConfigs, p) + } + + // Any callback-only entry requires the session policy capability, the + // same way a Strict declarative policy does. + if len(callbackOnly) > 0 { + if bopts.LLBCaps.Supports(pb.CapSourcePolicySession) != nil { + return nil, errors.New("session source policy is not supported by the current BuildKit daemon, please upgrade to version v0.27+") + } + } if opt.Inputs.policy == nil { - if len(opt.Policy) > 0 { + if len(fileConfigs) > 0 { return nil, errors.New("policy file specified but no policy FS in build context") } + if len(callbackOnly) > 0 { + so.SourcePolicyProvider = policysession.NewPolicyProvider(policy.MultiPolicyCallback(callbackOnly...)) + return nil, nil + } so.SourcePolicyProvider = nil return nil, nil } @@ -687,7 +715,7 @@ func configureSourcePolicy(ctx context.Context, np *noderesolver.ResolvedNode, o env.Target = opt.Target env.Labels = opt.Labels - popts, err := withPolicyConfig(*opt.Inputs.policy, opt.Policy) + popts, err := withPolicyConfig(*opt.Inputs.policy, fileConfigs) if err != nil { return nil, err } @@ -697,7 +725,7 @@ func configureSourcePolicy(ctx context.Context, np *noderesolver.ResolvedNode, o // (docker/dockerfile, docker/dockerfile-upstream) that may be implicitly // loaded during a build, and passes through any other source so user // policies retain full control. - if policy.DefaultPolicyEnabled() && !policyExplicitlyDisabled(opt.Policy) { + if policy.DefaultPolicyEnabled() && !policyExplicitlyDisabled(fileConfigs) { builtin := policyOpt{ Files: []policyFileSpec{{ Filename: policy.DefaultPolicyFilename, @@ -709,6 +737,10 @@ func configureSourcePolicy(ctx context.Context, np *noderesolver.ResolvedNode, o } if len(popts) == 0 { + if len(callbackOnly) > 0 { + so.SourcePolicyProvider = policysession.NewPolicyProvider(policy.MultiPolicyCallback(callbackOnly...)) + return nil, nil + } so.SourcePolicyProvider = nil return nil, nil } @@ -738,6 +770,10 @@ func configureSourcePolicy(ctx context.Context, np *noderesolver.ResolvedNode, o return nil, err } if len(loadedOpts) == 0 { + if len(callbackOnly) > 0 { + so.SourcePolicyProvider = policysession.NewPolicyProvider(policy.MultiPolicyCallback(callbackOnly...)) + return defers, nil + } so.SourcePolicyProvider = nil return defers, nil } @@ -802,6 +838,9 @@ func configureSourcePolicy(ctx context.Context, np *noderesolver.ResolvedNode, o policyLogger.Log("policy enabled network proxy") } } + // Callback-only policy entries compose as the last (most-strict) + // entries, allowing file-based policies to still run first. + cbs = append(cbs, callbackOnly...) so.SourcePolicyProvider = policysession.NewPolicyProvider(policy.MultiPolicyCallback(cbs...)) return defers, nil } diff --git a/util/buildflags/policy.go b/util/buildflags/policy.go index 0d1abb694b53..7197fee0a991 100644 --- a/util/buildflags/policy.go +++ b/util/buildflags/policy.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/docker/buildx/policy" + "github.com/moby/buildkit/sourcepolicy/policysession" "github.com/pkg/errors" "github.com/sirupsen/logrus" "github.com/tonistiigi/go-csvvalue" @@ -16,6 +17,13 @@ type PolicyConfig struct { Disabled bool Strict *bool LogLevel *logrus.Level + + // Callback, when non-nil, is a programmatic policy evaluator. Set in + // code (not from CLI flags) for cases like `buildx replay` that + // enforce pinning without loading a policy file. Configs with + // Callback set are composed into the policy callback chain alongside + // file-based policies. + Callback policysession.PolicyCallback `json:"-"` } func ParsePolicyConfigs(in []string) ([]PolicyConfig, error) { diff --git a/util/buildflags/policy_test.go b/util/buildflags/policy_test.go index 9359c7690dab..fbc7b27c7f53 100644 --- a/util/buildflags/policy_test.go +++ b/util/buildflags/policy_test.go @@ -1,16 +1,33 @@ package buildflags import ( + "context" + "encoding/json" "os" "path/filepath" "testing" "github.com/docker/buildx/policy" + gwpb "github.com/moby/buildkit/frontend/gateway/pb" + "github.com/moby/buildkit/sourcepolicy/policysession" "github.com/sirupsen/logrus" "github.com/stretchr/testify/require" "github.com/zclconf/go-cty/cty" ) +func TestPolicyConfigMarshalJSON(t *testing.T) { + for _, cfg := range []PolicyConfig{ + {}, + {Callback: policysession.PolicyCallback(func(_ context.Context, _ *policysession.CheckPolicyRequest) (*policysession.DecisionResponse, *gwpb.ResolveSourceMetaRequest, error) { + return nil, nil, nil + })}, + } { + dt, err := json.Marshal(cfg) + require.NoError(t, err) + require.NotContains(t, string(dt), "Callback") + } +} + func TestPolicyConfigs_FromCtyValue(t *testing.T) { policyDir := t.TempDir() policyPath := filepath.Join(policyDir, "policy.rego") From 40f3502b1a73daa62d2fd9284222c70b14915a27 Mon Sep 17 00:00:00 2001 From: Tonis Tiigi Date: Wed, 30 Sep 2026 08:22:40 +0000 Subject: [PATCH 2/4] policy, imagetools: export provenance helpers Export ParseSLSAMaterial and AddPinToImage from the policy package and add imagetools.ReadProvenancePredicate to read the provenance predicate of an attestation manifest. Fetching referrers from an OCI layout that has none now returns an empty result instead of ErrNotFound, matching the registry behavior. Callers that treated ErrNotFound as "no referrers" behave the same. Signed-off-by: Tonis Tiigi --- policy/funcs.go | 2 +- policy/input.go | 2 +- policy/materials.go | 2 +- policy/provenance.go | 4 +- policy/resolve.go | 2 +- policy/validate.go | 2 +- util/imagetools/inspect_test.go | 24 +++++++++++ util/imagetools/loader.go | 58 ++++++++++++++++++++++++++ util/imagetools/ocilayout_referrers.go | 6 ++- 9 files changed, 93 insertions(+), 9 deletions(-) diff --git a/policy/funcs.go b/policy/funcs.go index 3593504661ee..fc673b7af983 100644 --- a/policy/funcs.go +++ b/policy/funcs.go @@ -665,7 +665,7 @@ func (p *Policy) builtinLoadJSONImpl(bctx rego.BuiltinContext, a *ast.Term) (*as return ast.NewTerm(astVal), nil } -func addPinToImage(src *pb.SourceOp, dgst digest.Digest) (*pb.SourceOp, error) { +func AddPinToImage(src *pb.SourceOp, dgst digest.Digest) (*pb.SourceOp, error) { id, ok := strings.CutPrefix(src.Identifier, "docker-image://") if !ok { return nil, errors.Errorf("cannot pin non-image source: %q", src.Identifier) diff --git a/policy/input.go b/policy/input.go index bc53ed58b58a..25a9ed8d5a6e 100644 --- a/policy/input.go +++ b/policy/input.go @@ -46,7 +46,7 @@ func sourceToInputRecursive(ctx context.Context, verifier PolicyVerifierProvider materials := make([]Input, 0, len(inp.Image.Provenance.materialsRaw)) for _, m := range inp.Image.Provenance.materialsRaw { - matSrc, matPlatform, err := parseSLSAMaterial(m) + matSrc, matPlatform, err := ParseSLSAMaterial(m) if err != nil { materials = append(materials, Input{}) continue diff --git a/policy/materials.go b/policy/materials.go index b705efa1db92..084110ff6894 100644 --- a/policy/materials.go +++ b/policy/materials.go @@ -34,7 +34,7 @@ func isMaterialKey(key string) (idx int, rest string, ok bool) { return n, rest, true } -func parseSLSAMaterial(m slsa1.ResourceDescriptor) (*pb.SourceOp, *ocispecs.Platform, error) { +func ParseSLSAMaterial(m slsa1.ResourceDescriptor) (*pb.SourceOp, *ocispecs.Platform, error) { uri := m.URI dgst := m.Digest if strings.HasPrefix(uri, "pkg:docker/") { diff --git a/policy/provenance.go b/policy/provenance.go index b16221b66899..19c96b747b70 100644 --- a/policy/provenance.go +++ b/policy/provenance.go @@ -165,7 +165,7 @@ func rawMaterialsFromSLSA1(materials []slsa1.ResourceDescriptor, logf func(logru URI: m.URI, Digest: maps.Clone(m.Digest), } - if _, _, err := parseSLSAMaterial(rd); err != nil { + if _, _, err := ParseSLSAMaterial(rd); err != nil { if logf != nil { logf(logrus.WarnLevel, fmt.Sprintf("skipping unsupported provenance material %q: %v", m.URI, err)) } @@ -186,7 +186,7 @@ func rawMaterialsFromSLSA02(materials []slsa02.ProvenanceMaterial, logf func(log URI: m.URI, Digest: maps.Clone(m.Digest), } - if _, _, err := parseSLSAMaterial(rd); err != nil { + if _, _, err := ParseSLSAMaterial(rd); err != nil { if logf != nil { logf(logrus.WarnLevel, fmt.Sprintf("skipping unsupported provenance material %q: %v", m.URI, err)) } diff --git a/policy/resolve.go b/policy/resolve.go index 84aa8c102714..d805fc0e0827 100644 --- a/policy/resolve.go +++ b/policy/resolve.go @@ -62,7 +62,7 @@ func resolveNodeUnknowns(ctx context.Context, node *Input, source *pb.SourceOp, continue } raw := node.Image.Provenance.materialsRaw[idx] - childSource, childNodePlatform, err := parseSLSAMaterial(raw) + childSource, childNodePlatform, err := ParseSLSAMaterial(raw) if err != nil { continue } diff --git a/policy/validate.go b/policy/validate.go index 5bd41be4a7a0..6aed8ec07368 100644 --- a/policy/validate.go +++ b/policy/validate.go @@ -339,7 +339,7 @@ func (p *Policy) CheckPolicy(ctx context.Context, req *policysession.CheckPolicy return nil, nil, errors.Errorf("multiple image pins set to %s: %v", sourceName(req), st.ImagePins) } if len(st.ImagePins) == 1 { - newSrc, err := addPinToImage(req.Source.Source, slices.Collect(maps.Keys(st.ImagePins))[0]) + newSrc, err := AddPinToImage(req.Source.Source, slices.Collect(maps.Keys(st.ImagePins))[0]) if err != nil { return nil, nil, errors.Wrapf(err, "failed to add image pin to source") } diff --git a/util/imagetools/inspect_test.go b/util/imagetools/inspect_test.go index 67f10841b51a..b4e75434d6d3 100644 --- a/util/imagetools/inspect_test.go +++ b/util/imagetools/inspect_test.go @@ -51,3 +51,27 @@ func TestFetchReferrersOCILayoutArtifactTypeFilter(t *testing.T) { require.Equal(t, attestation.Digest, refs[0].Digest) require.Equal(t, attestation.ArtifactType, refs[0].ArtifactType) } + +func TestFetchReferrersOCILayoutEmpty(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + idx := ociindex.NewStoreIndex(dir) + reachable := ocispecs.Descriptor{ + MediaType: ocispecs.MediaTypeImageManifest, + Digest: digest.FromString("reachable manifest"), + Size: 123, + Annotations: map[string]string{ + images.AnnotationManifestSubject: digest.FromString("different subject").String(), + }, + } + require.NoError(t, idx.Put(reachable, ociindex.Tag("latest"))) + + loc, err := ParseLocation("oci-layout://" + dir + ":latest") + require.NoError(t, err) + + r := New(Opt{}) + refs, err := r.FetchReferrers(context.Background(), loc, digest.FromString("unsigned subject")) + require.NoError(t, err) + require.Empty(t, refs) +} diff --git a/util/imagetools/loader.go b/util/imagetools/loader.go index d30fd49b615d..257aff803f70 100644 --- a/util/imagetools/loader.go +++ b/util/imagetools/loader.go @@ -466,3 +466,61 @@ func withIntotoMediaTypes(ctx context.Context) context.Context { } return ctx } + +// ReadProvenancePredicate loads the SLSA provenance predicate payload from the +// attestation manifest referenced by attestManifest, reading blobs through the +// supplied content provider. Returns the raw predicate JSON bytes and the +// predicate type URI. When the manifest has no provenance layer both return +// values are empty without error so callers can distinguish "no provenance" +// from a hard failure. +// +// This exposes the provenance scan + DSSE unwrap logic used internally by +// scanProvenance for reuse by `buildx replay`. +func ReadProvenancePredicate(ctx context.Context, provider content.Provider, attestManifest ocispecs.Descriptor) ([]byte, string, error) { + ctx = withIntotoMediaTypes(ctx) + dt, err := content.ReadBlob(ctx, provider, attestManifest) + if err != nil { + return nil, "", errors.Wrap(err, "failed to read attestation manifest") + } + var mfst ocispecs.Manifest + if err := json.Unmarshal(dt, &mfst); err != nil { + return nil, "", errors.Wrap(err, "failed to unmarshal attestation manifest") + } + var ( + layer ocispecs.Descriptor + predType string + ) + for _, l := range mfst.Layers { + annot := l.Annotations["in-toto.io/predicate-type"] + if (l.MediaType == inTotoGenericMime || isInTotoDSSE(l.MediaType)) && + strings.HasPrefix(annot, "https://slsa.dev/provenance/") { + layer = l + predType = annot + break + } + } + if predType == "" { + return nil, "", nil + } + layerDt, err := content.ReadBlob(ctx, provider, layer) + if err != nil { + return nil, "", errors.Wrapf(err, "failed to read provenance layer %s", layer.Digest) + } + layerDt, err = decodeDSSE(layerDt, layer.MediaType) + if err != nil { + return nil, "", errors.Wrap(err, "failed to decode DSSE envelope") + } + var stmt struct { + Predicate json.RawMessage `json:"predicate"` + PredicateType string `json:"predicateType"` + } + if err := json.Unmarshal(layerDt, &stmt); err != nil { + return nil, "", errors.Wrap(err, "failed to unmarshal in-toto statement") + } + // Prefer the in-toto Statement's predicateType over the annotation when + // both are set — the annotation is a hint; the payload is canonical. + if stmt.PredicateType != "" { + predType = stmt.PredicateType + } + return stmt.Predicate, predType, nil +} diff --git a/util/imagetools/ocilayout_referrers.go b/util/imagetools/ocilayout_referrers.go index 6d186218add4..dc9ef66fda18 100644 --- a/util/imagetools/ocilayout_referrers.go +++ b/util/imagetools/ocilayout_referrers.go @@ -8,7 +8,6 @@ import ( "github.com/containerd/containerd/v2/core/images" "github.com/containerd/containerd/v2/core/remotes" - "github.com/containerd/errdefs" "github.com/moby/buildkit/client/ociindex" "github.com/moby/buildkit/util/attestation" "github.com/opencontainers/go-digest" @@ -79,7 +78,10 @@ func fetchOCILayoutReferrers(ctx context.Context, getDescriptor func(context.Con } if len(out) == 0 { - return nil, errors.WithStack(errdefs.ErrNotFound) + // ReferrersFetcher treats an absent referrer set as a successful empty + // result. Match registry behavior so callers can distinguish an + // unsigned subject from a failure to read the OCI layout itself. + return nil, nil } refs := make([]ocispecs.Descriptor, 0, len(out)) From bd6c7fda50b44e141d4c559f8f80ec34a8f7dc27 Mon Sep 17 00:00:00 2001 From: Tonis Tiigi Date: Wed, 30 Sep 2026 08:22:42 +0000 Subject: [PATCH 3/4] replay: add replay build command Add an experimental `buildx replay build` command that rebuilds an image from its SLSA provenance. By default, every source is pinned to the digest recorded in the provenance through a session source policy callback. --replay-mode=frontend replays the recorded frontend and options without pinning sources. Replay requires mode=max provenance, a Git or HTTP(S) build context and BuildKit v0.27 or later, and rebuilds one platform at a time. Subjects can be registry images, OCI layouts or attestation files. Sigstore signatures on the provenance are verified when present. --dry-run prints the replay plan and runs the same checks as a real replay. `replay snapshot` and `replay verify` are included as hidden commands until snapshots can be consumed by replay build and verification results can be trusted. Signed-off-by: Tonis Tiigi --- commands/replay/build.go | 604 ++++++++++++++++ commands/replay/build_test.go | 258 +++++++ commands/replay/root.go | 62 ++ commands/replay/snapshot.go | 202 ++++++ commands/replay/snapshot_test.go | 15 + commands/replay/verify.go | 127 ++++ commands/root.go | 2 + docs/reference/buildx.md | 39 +- docs/reference/buildx_replay.md | 57 ++ docs/reference/buildx_replay_build.md | 74 ++ go.mod | 2 +- replay/build.go | 549 ++++++++++++++ replay/build_test.go | 296 ++++++++ replay/dryrun.go | 453 ++++++++++++ replay/dryrun_test.go | 255 +++++++ replay/errors.go | 258 +++++++ replay/errors_test.go | 89 +++ replay/materials.go | 645 +++++++++++++++++ replay/materials_test.go | 216 ++++++ replay/policy.go | 516 ++++++++++++++ replay/policy_test.go | 378 ++++++++++ replay/predicate.go | 190 +++++ replay/predicate_test.go | 152 ++++ replay/snapshot.go | 989 ++++++++++++++++++++++++++ replay/snapshot_format.go | 176 +++++ replay/snapshot_format_test.go | 170 +++++ replay/snapshot_test.go | 418 +++++++++++ replay/subject.go | 737 +++++++++++++++++++ replay/subject_test.go | 624 ++++++++++++++++ replay/verify.go | 285 ++++++++ replay/verify_compare.go | 257 +++++++ replay/verify_compare_test.go | 150 ++++ replay/verify_test.go | 92 +++ replay/warn.go | 40 ++ tests/integration_test.go | 1 + tests/replay.go | 462 ++++++++++++ 36 files changed, 9820 insertions(+), 20 deletions(-) create mode 100644 commands/replay/build.go create mode 100644 commands/replay/build_test.go create mode 100644 commands/replay/root.go create mode 100644 commands/replay/snapshot.go create mode 100644 commands/replay/snapshot_test.go create mode 100644 commands/replay/verify.go create mode 100644 docs/reference/buildx_replay.md create mode 100644 docs/reference/buildx_replay_build.md create mode 100644 replay/build.go create mode 100644 replay/build_test.go create mode 100644 replay/dryrun.go create mode 100644 replay/dryrun_test.go create mode 100644 replay/errors.go create mode 100644 replay/errors_test.go create mode 100644 replay/materials.go create mode 100644 replay/materials_test.go create mode 100644 replay/policy.go create mode 100644 replay/policy_test.go create mode 100644 replay/predicate.go create mode 100644 replay/predicate_test.go create mode 100644 replay/snapshot.go create mode 100644 replay/snapshot_format.go create mode 100644 replay/snapshot_format_test.go create mode 100644 replay/snapshot_test.go create mode 100644 replay/subject.go create mode 100644 replay/subject_test.go create mode 100644 replay/verify.go create mode 100644 replay/verify_compare.go create mode 100644 replay/verify_compare_test.go create mode 100644 replay/verify_test.go create mode 100644 replay/warn.go create mode 100644 tests/replay.go diff --git a/commands/replay/build.go b/commands/replay/build.go new file mode 100644 index 000000000000..409d9aad7367 --- /dev/null +++ b/commands/replay/build.go @@ -0,0 +1,604 @@ +package replay + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "sort" + "strings" + "text/tabwriter" + "time" + + "github.com/containerd/platforms" + "github.com/docker/buildx/builder" + "github.com/docker/buildx/driver" + "github.com/docker/buildx/replay" + "github.com/docker/buildx/util/buildflags" + "github.com/docker/buildx/util/cobrautil/completion" + "github.com/docker/buildx/util/platformutil" + "github.com/docker/buildx/util/progress" + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + "github.com/moby/buildkit/client" + "github.com/moby/buildkit/util/progress/progressui" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/pkg/errors" + "github.com/spf13/cobra" +) + +// buildOptions holds the parsed flags for `replay build`. +type buildOptions struct { + commonOptions + mode string + outputs []string + tags []string + exportLoad bool + exportPush bool + dryRun bool + format string +} + +func buildCmd(dockerCli command.Cli, rootOpts RootOptions) *cobra.Command { + var opts buildOptions + + cmd := &cobra.Command{ + Use: "build [OPTIONS] SUBJECT", + Short: "Rebuild an image from provenance and pinned materials", + Args: cli.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + opts.builder = *rootOpts.Builder + return runBuild(cmd, dockerCli, &opts, args[0]) + }, + ValidArgsFunction: completion.Disable, + DisableFlagsInUseLine: true, + } + + installCommonFlags(cmd, &opts.commonOptions) + + flags := cmd.Flags() + // Only the default provenance materials are supported by replay build. + flags.MarkHidden("materials") + flags.StringVar(&opts.mode, "replay-mode", "materials", `Replay mode ("materials" | "frontend")`) + flags.StringArrayVarP(&opts.outputs, "output", "o", nil, `Output destination (format: "type=local,dest=path")`) + flags.StringArrayVarP(&opts.tags, "tag", "t", nil, `Image identifier (format: "[registry/]repository[:tag]")`) + flags.BoolVar(&opts.exportLoad, "load", false, `Shorthand for "--output=type=docker"`) + flags.BoolVar(&opts.exportPush, "push", false, `Shorthand for "--output=type=registry,unpack=false"`) + flags.BoolVar(&opts.dryRun, "dry-run", false, "Print a plan of the replay without solving or exporting") + flags.StringVar(&opts.format, "format", "pretty", `Format dry-run output ("pretty" | "json")`) + + return cmd +} + +// runBuild wires the CLI flags to the replay.Build entry point. +func runBuild(cmd *cobra.Command, dockerCli command.Cli, opts *buildOptions, input string) error { + ctx := cmd.Context() + + mode := replay.BuildMode(opts.mode) + switch mode { + case replay.BuildModeMaterials, replay.BuildModeFrontend: + // ok + default: + return errors.Errorf("unknown --replay-mode %q", opts.mode) + } + switch opts.format { + case "pretty", "json": + default: + return errors.Errorf("unknown --format %q", opts.format) + } + if cmd.Flags().Changed("format") && !opts.dryRun { + return errors.New("--format requires --dry-run") + } + + // Materials resolver. + resolver, err := replay.NewMaterialsResolver(opts.materials) + if err != nil { + return err + } + + // Parse flags. + secretSpecs, err := buildflags.ParseSecretSpecs(opts.secrets) + if err != nil { + return errors.Wrap(err, "parse --secret") + } + sshSpecs, err := buildflags.ParseSSHSpecs(opts.ssh) + if err != nil { + return errors.Wrap(err, "parse --ssh") + } + exportSpecs, err := buildflags.ParseExports(opts.outputs) + if err != nil { + return errors.Wrap(err, "parse --output") + } + exportSpecs = applyExportShorthands(exportSpecs, opts.exportPush, opts.exportLoad) + + // Subject + predicate. + subjects, err := replay.LoadSubjects(ctx, dockerCli, opts.builder, input) + if err != nil { + return err + } + + subjects, err = filterSubjectsByPlatform(subjects, opts.platforms, builderDefaultPlatform(ctx, dockerCli, opts.builder)) + if err != nil { + return err + } + if len(subjects) == 0 { + return errors.New("no subjects matched the --platform filter") + } + if err := replay.VerifySignatures(ctx, dockerCli, subjects); err != nil { + return err + } + + targets := make([]replay.Target, 0, len(subjects)) + for _, s := range subjects { + pred, err := s.Predicate(ctx) + if err != nil { + return err + } + s = applyPredicateTargetPlatformFallback(s, pred, opts.platforms) + targets = append(targets, replay.Target{Subject: s, Predicate: pred}) + } + + req := &replay.BuildRequest{ + Targets: targets, + Mode: mode, + Materials: resolver, + NetworkMode: opts.network, + Secrets: secretSpecs, + SSH: sshSpecs, + Exports: exportSpecs, + Tags: opts.tags, + Progress: progressui.DisplayMode(opts.progress), + } + + if opts.dryRun { + plan, err := replay.MakeBuildPlan(req) + if err != nil { + return err + } + switch opts.format { + case "pretty": + return printBuildPlan(cmd.OutOrStdout(), plan) + case "json": + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(plan) + default: + return errors.Errorf("unknown --format %q", opts.format) + } + } + return replay.Build(ctx, dockerCli, opts.builder, req) +} + +func printBuildPlan(out io.Writer, plan *replay.BuildPlan) error { + if plan == nil { + return errors.New("nil build plan") + } + tw := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0) + if _, err := fmt.Fprintln(tw, "Replay plan"); err != nil { + return errors.WithStack(err) + } + for i, subject := range plan.Subjects { + if _, err := fmt.Fprintf(tw, "\nSubject %d/%d\n", i+1, len(plan.Subjects)); err != nil { + return errors.WithStack(err) + } + if err := writePlanField(tw, "Platform", formatPlanPlatform(subject.Descriptor.Platform)); err != nil { + return err + } + if err := writePlanField(tw, "Digest", subject.Descriptor.Digest.String()); err != nil { + return err + } + if _, err := fmt.Fprintln(tw, "\nBuild configuration"); err != nil { + return errors.WithStack(err) + } + cfg := subject.BuildConfig + for _, field := range []struct{ name, value string }{ + {"Frontend", cfg.Frontend}, + {"Context", cfg.Context}, + {"Dockerfile", cfg.Filename}, + {"Target", cfg.Target}, + {"Network", cfg.NetworkMode}, + } { + if err := writePlanField(tw, field.name, field.value); err != nil { + return err + } + } + if err := writePlanMap(tw, "Build args", cfg.BuildArgs); err != nil { + return err + } + if len(cfg.Secrets) > 0 { + values := make([]string, 0, len(cfg.Secrets)) + for _, secret := range cfg.Secrets { + value := secret.ID + if secret.Optional { + value += " (optional)" + } + values = append(values, value) + } + if err := writePlanField(tw, "Secrets", fmt.Sprintf("%v", values)); err != nil { + return err + } + } + if err := writePlanSignature(tw, subject.Signature); err != nil { + return err + } + if _, err := fmt.Fprintf(tw, "\nMaterials (%d)\n", len(subject.Materials)); err != nil { + return errors.WithStack(err) + } + for _, material := range subject.Materials { + platform := formatPlanPlatform(material.Platform) + if platform != "" { + platform = " [" + platform + "]" + } + if _, err := fmt.Fprintf(tw, " %s%s\t%s\n", material.Kind, platform, material.URI); err != nil { + return errors.WithStack(err) + } + if material.Digest != "" { + if _, err := fmt.Fprintf(tw, " \t%s\n", material.Digest); err != nil { + return errors.WithStack(err) + } + } + } + } + return errors.WithStack(tw.Flush()) +} + +func writePlanSignature(w io.Writer, signature *replay.SignatureVerification) error { + if signature == nil { + return nil + } + if _, err := fmt.Fprintf(w, "\n%s\n", signature.Type); err != nil { + return errors.WithStack(err) + } + for _, field := range []struct{ name, value string }{ + {"Verified signer", signature.Identity}, + {"Signer identity", signature.SubjectAlternativeName}, + {"Certificate issuer", signature.CertificateIssuer}, + {"OIDC issuer", signature.Issuer}, + {"Runner environment", signature.RunnerEnvironment}, + {"Source repository", signature.SourceRepositoryURI}, + {"Source ref", signature.SourceRepositoryRef}, + } { + if err := writePlanField(w, field.name, field.value); err != nil { + return err + } + } + if signature.BuildSignerURI != signature.SubjectAlternativeName { + if err := writePlanField(w, "Build signer", signature.BuildSignerURI); err != nil { + return err + } + } + if len(signature.Timestamps) > 0 { + if _, err := fmt.Fprintln(w, " TYPE\tTIME\tSOURCE"); err != nil { + return errors.WithStack(err) + } + } + for _, timestamp := range signature.Timestamps { + typeName := timestamp.Type + switch timestamp.Type { + case "Tlog": + typeName = "Transparency log" + case "TimestampAuthority": + typeName = "Timestamp authority" + } + value := timestamp.Timestamp.Format(time.RFC3339) + if _, err := fmt.Fprintf(w, " %s\t%s\t%s\n", typeName, value, timestamp.URI); err != nil { + return errors.WithStack(err) + } + } + if signature.TrustRootWarning != "" { + if err := writePlanField(w, "Trust root warning", signature.TrustRootWarning); err != nil { + return err + } + } + return nil +} + +func writePlanField(w io.Writer, name, value string) error { + if value == "" { + return nil + } + _, err := fmt.Fprintf(w, " %s:\t%s\n", name, value) + return errors.WithStack(err) +} + +func writePlanMap(w io.Writer, name string, values map[string]string) error { + if len(values) == 0 { + return nil + } + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + for i, key := range keys { + label := "" + if i == 0 { + label = name + ":" + } + if _, err := fmt.Fprintf(w, " %s\t%s=%s\n", label, key, values[key]); err != nil { + return errors.WithStack(err) + } + } + return nil +} + +func formatPlanPlatform(platform *ocispecs.Platform) string { + if platform == nil { + return "" + } + return platforms.Format(*platform) +} + +// applyPredicateTargetPlatformFallback fills the platform metadata that a raw +// provenance file cannot carry on its subject descriptor. Image subjects get +// this metadata from their manifest index; attestation files can fall back to +// TARGETPLATFORM inferred from the recorded LLB. An explicit --platform +// has already been applied by filterSubjectsByPlatform and takes precedence. +func applyPredicateTargetPlatformFallback(subject *replay.Subject, pred *replay.Predicate, platformFilter []string) *replay.Subject { + if subject == nil || pred == nil || subject.Descriptor.Platform != nil || len(platformFilter) != 0 { + return subject + } + platform, ok := pred.FallbackTargetPlatform() + if !ok { + return subject + } + clone := *subject + clone.Descriptor = subject.Descriptor + p := *platform + clone.Descriptor.Platform = &p + return &clone +} + +// applyExportShorthands mirrors the --push / --load handling in +// commands/build.go. --push sets push=true (+ unpack=false) on any +// existing type=image export, or appends one; --load appends a +// type=docker export unless an equivalent one is already present. +// type=registry is only normalized to a pushing image export later by +// build.CreateExports, so it already satisfies --push here. +func applyExportShorthands(exports []*buildflags.ExportEntry, push, load bool) []*buildflags.ExportEntry { + if push { + var used bool + for _, e := range exports { + switch e.Type { + case "registry": + used = true + case "image": + if e.Attrs == nil { + e.Attrs = map[string]string{} + } + e.Attrs["push"] = "true" + if _, ok := e.Attrs["unpack"]; !ok { + e.Attrs["unpack"] = "false" + } + used = true + } + } + if !used { + exports = append(exports, &buildflags.ExportEntry{ + Type: "image", + Attrs: map[string]string{"push": "true", "unpack": "false"}, + }) + } + } + if load { + var used bool + for _, e := range exports { + if e.Type == "docker" { + if _, ok := e.Attrs["dest"]; !ok { + used = true + break + } + } + } + if !used { + exports = append(exports, &buildflags.ExportEntry{ + Type: "docker", + Attrs: map[string]string{}, + }) + } + } + return exports +} + +// filterSubjectsByPlatform narrows a subject list to the requested platforms. +// +// Contract: +// - platformFilter == ["all"] keeps every subject. +// - Comma-separated and repeated entries are equivalent. +// - platformFilter empty keeps the only subject when at most one subject +// carries a platform. Otherwise it selects the subject matching +// defaultPlatform (the builder's default platform) — replay is +// single-platform by default. defaultPlatform is only called then. +// - Otherwise each entry is matched strictly after normalization. Platform +// selection identifies an artifact; it is not an execution-compatibility +// check. +// +// An explicit --platform that does not match any subject is an error. +// A sole subject with no descriptor platform inherits each explicit platform +// because there is no index metadata to select from. +func filterSubjectsByPlatform(subjects []*replay.Subject, platformFilter []string, defaultPlatform func() (ocispecs.Platform, error)) ([]*replay.Subject, error) { + explicit := len(platformFilter) > 0 + wantPlatforms, all, err := parsePlatformFilter(platformFilter) + if err != nil { + return nil, err + } + if all { + return subjects, nil + } + var available []string + for _, s := range subjects { + if s.Descriptor.Platform != nil { + available = append(available, platforms.Format(*s.Descriptor.Platform)) + } + } + if !explicit { + if len(available) <= 1 { + return subjects, nil + } + p, err := defaultPlatform() + if err != nil { + return nil, errors.Wrapf(err, "select default platform from %v — pass --platform

or --platform all", available) + } + wantPlatforms = []ocispecs.Platform{p} + } + + wantNames := make([]string, 0, len(wantPlatforms)) + matchers := make([]platforms.MatchComparer, 0, len(wantPlatforms)) + for _, platform := range wantPlatforms { + matchers = append(matchers, platforms.OnlyStrict(platform)) + wantNames = append(wantNames, platforms.Format(platform)) + } + if explicit && len(subjects) == 1 && subjects[0].Descriptor.Platform == nil { + out := make([]*replay.Subject, 0, len(wantPlatforms)) + for _, platform := range wantPlatforms { + subject := *subjects[0] + subject.Descriptor = subjects[0].Descriptor + p := platform + subject.Descriptor.Platform = &p + out = append(out, &subject) + } + return out, nil + } + + // For each requested platform pick the single best-matching subject — + // duplicate descriptors are collapsed to one target. + matchedAny := make([]bool, len(matchers)) + chosen := make([]int, 0, len(matchers)) + chosenSet := map[int]struct{}{} + for i, m := range matchers { + best := -1 + for j, s := range subjects { + if s.Descriptor.Platform == nil { + continue + } + sp := *s.Descriptor.Platform + if !m.Match(sp) { + continue + } + if best < 0 || m.Less(sp, *subjects[best].Descriptor.Platform) { + best = j + } + } + if best >= 0 { + if _, exists := chosenSet[best]; !exists { + chosenSet[best] = struct{}{} + chosen = append(chosen, best) + } + matchedAny[i] = true + } + } + + out := make([]*replay.Subject, 0, len(chosen)) + for _, j := range chosen { + out = append(out, subjects[j]) + } + for _, s := range subjects { + if s.Descriptor.Platform == nil { + out = append(out, s) + } + } + + if explicit { + var missing []string + for i, w := range wantNames { + if !matchedAny[i] { + missing = append(missing, w) + } + } + if len(missing) > 0 { + return nil, errors.Errorf("requested platform(s) not present: %v (available: %v)", missing, available) + } + } + if len(out) == 0 { + return nil, errors.Errorf("no subject for builder default platform %v (available: %v) — pass --platform

or --platform all", wantNames, available) + } + return out, nil +} + +// builderDefaultPlatform returns a lookup of the platform `buildx build` +// targets on the selected builder when no --platform is set: a platformless +// build runs on the first node, where BuildKit defaults to the first +// platform of its first worker. Platforms configured on the node are not +// used as they may list a non-default platform first. Only the first node is +// contacted, and booted when it is not running yet. +func builderDefaultPlatform(ctx context.Context, dockerCli command.Cli, builderName string) func() (ocispecs.Platform, error) { + return func() (ocispecs.Platform, error) { + b, err := builder.New(dockerCli, builder.WithName(builderName)) + if err != nil { + return ocispecs.Platform{}, err + } + nodes, err := b.LoadNodes(ctx) + if err != nil { + return ocispecs.Platform{}, err + } + if len(nodes) == 0 { + return ocispecs.Platform{}, errors.Errorf("builder %q has no nodes", b.Name) + } + n := nodes[0] + if n.Err != nil { + return ocispecs.Platform{}, errors.Wrapf(n.Err, "load builder %q", b.Name) + } + c, err := bootNode(ctx, n) + if err != nil { + return ocispecs.Platform{}, err + } + workers, err := c.ListWorkers(ctx) + if err != nil { + return ocispecs.Platform{}, errors.Wrap(err, "listing workers") + } + if len(workers) == 0 || len(workers[0].Platforms) == 0 { + return ocispecs.Platform{}, errors.Errorf("could not determine default platform of builder %q", b.Name) + } + return platforms.Normalize(workers[0].Platforms[0]), nil + } +} + +// bootNode returns a client for a single builder node, booting it with the +// same progress output as builder.Boot when it is not running. +func bootNode(ctx context.Context, n builder.Node) (_ *client.Client, retErr error) { + info, err := n.Driver.Info(ctx) + if err != nil { + return nil, err + } + if info.Status == driver.Running { + return n.Driver.Client(ctx) + } + printer, err := progress.NewPrinter(context.TODO(), os.Stderr, progressui.AutoMode) + if err != nil { + return nil, err + } + defer func() { + if err := printer.Wait(); retErr == nil { + retErr = err + } + }() + return driver.Boot(ctx, ctx, n.Driver, printer) +} + +func parsePlatformFilter(values []string) ([]ocispecs.Platform, bool, error) { + var flattened []string + for _, value := range values { + for part := range strings.SplitSeq(value, ",") { + part = strings.TrimSpace(part) + if part == "" { + return nil, false, errors.New("invalid empty --platform value") + } + flattened = append(flattened, part) + } + } + for _, value := range flattened { + if value != "all" { + continue + } + if len(flattened) != 1 { + return nil, false, errors.New(`--platform "all" cannot be combined with other platforms`) + } + return nil, true, nil + } + parsed, err := platformutil.Parse(flattened) + if err != nil { + return nil, false, errors.Wrap(err, "invalid --platform") + } + return platformutil.Dedupe(parsed), false, nil +} diff --git a/commands/replay/build_test.go b/commands/replay/build_test.go new file mode 100644 index 000000000000..531731ac30d7 --- /dev/null +++ b/commands/replay/build_test.go @@ -0,0 +1,258 @@ +package replay + +import ( + "bytes" + "io" + "testing" + "time" + + "github.com/containerd/platforms" + "github.com/docker/buildx/build" + "github.com/docker/buildx/replay" + "github.com/docker/buildx/util/buildflags" + "github.com/moby/buildkit/client" + provenancetypes "github.com/moby/buildkit/solver/llbsolver/provenance/types" + solverpb "github.com/moby/buildkit/solver/pb" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/pkg/errors" + "github.com/stretchr/testify/require" +) + +func TestFilterSubjectsByPlatform(t *testing.T) { + amd := &replay.Subject{Descriptor: ocispecs.Descriptor{Platform: &ocispecs.Platform{OS: "linux", Architecture: "amd64"}}} + arm := &replay.Subject{Descriptor: ocispecs.Descriptor{Platform: &ocispecs.Platform{OS: "linux", Architecture: "arm64"}}} + subjects := []*replay.Subject{amd, arm} + builderDefault := func(p string) func() (ocispecs.Platform, error) { + return func() (ocispecs.Platform, error) { + return platforms.MustParse(p), nil + } + } + noDefault := func() (ocispecs.Platform, error) { + t.Fatal("builder default platform must not be looked up") + return ocispecs.Platform{}, nil + } + + // "all" keeps every subject. + out, err := filterSubjectsByPlatform(subjects, []string{"all"}, noDefault) + require.NoError(t, err) + require.Len(t, out, 2) + + // An empty filter collapses to the builder's default platform, never + // the client's. + for _, arch := range []string{"amd64", "arm64"} { + out, err = filterSubjectsByPlatform(subjects, nil, builderDefault("linux/"+arch)) + require.NoError(t, err) + require.Len(t, out, 1) + require.Equal(t, arch, out[0].Descriptor.Platform.Architecture) + } + + // A builder default matching no subject is an error that lists the + // available platforms. + _, err = filterSubjectsByPlatform(subjects, nil, builderDefault("windows/amd64")) + require.ErrorContains(t, err, "linux/amd64 linux/arm64") + + // A failing builder lookup is reported with the available platforms. + _, err = filterSubjectsByPlatform(subjects, nil, func() (ocispecs.Platform, error) { + return ocispecs.Platform{}, errors.New("builder unavailable") + }) + require.ErrorContains(t, err, "builder unavailable") + require.ErrorContains(t, err, "linux/amd64 linux/arm64") + + // A single platform subject is the default regardless of the builder. + out, err = filterSubjectsByPlatform([]*replay.Subject{arm}, nil, noDefault) + require.NoError(t, err) + require.Equal(t, []*replay.Subject{arm}, out) + + // Explicit match on a single platform. + out, err = filterSubjectsByPlatform(subjects, []string{"linux/arm64"}, noDefault) + require.NoError(t, err) + require.Len(t, out, 1) + require.Equal(t, "arm64", out[0].Descriptor.Platform.Architecture) + + // Comma-separated values are equivalent to repeating --platform. + out, err = filterSubjectsByPlatform(subjects, []string{"linux/amd64,linux/arm64"}, noDefault) + require.NoError(t, err) + require.Len(t, out, 2) + require.Equal(t, "amd64", out[0].Descriptor.Platform.Architecture) + require.Equal(t, "arm64", out[1].Descriptor.Platform.Architecture) + outRepeated, err := filterSubjectsByPlatform(subjects, []string{"linux/amd64", "linux/arm64"}, noDefault) + require.NoError(t, err) + require.Equal(t, outRepeated, out) + + // Duplicate values collapse to one target, regardless of flag spelling. + out, err = filterSubjectsByPlatform(subjects, []string{"linux/amd64,linux/amd64"}, noDefault) + require.NoError(t, err) + require.Len(t, out, 1) + require.Equal(t, "amd64", out[0].Descriptor.Platform.Architecture) + + _, err = filterSubjectsByPlatform(subjects, []string{"all,linux/amd64"}, noDefault) + require.Error(t, err) + require.Contains(t, err.Error(), "cannot be combined") + + // Explicit platform with no matching subject is an error. + _, err = filterSubjectsByPlatform([]*replay.Subject{amd}, []string{"linux/arm64"}, noDefault) + require.Error(t, err) + require.Contains(t, err.Error(), "not present") + + // Artifact selection is strict: execution compatibility must not select + // a different architecture or variant. + armv7 := &replay.Subject{Descriptor: ocispecs.Descriptor{Platform: &ocispecs.Platform{OS: "linux", Architecture: "arm", Variant: "v7"}}} + _, err = filterSubjectsByPlatform([]*replay.Subject{armv7}, []string{"linux/arm64"}, noDefault) + require.Error(t, err) + + // A single manifest or attestation without platform metadata inherits an + // explicit requested platform so the solve itself is constrained. + platformless := &replay.Subject{} + out, err = filterSubjectsByPlatform([]*replay.Subject{platformless}, []string{"linux/arm64"}, noDefault) + require.NoError(t, err) + require.Len(t, out, 1) + require.Equal(t, "linux/arm64", platforms.Format(*out[0].Descriptor.Platform)) + require.Nil(t, platformless.Descriptor.Platform, "filtering must not mutate the loaded subject") +} + +func TestApplyPredicateTargetPlatformFallback(t *testing.T) { + subject := &replay.Subject{} + pred := &replay.Predicate{} + pred.BuildDefinition.InternalParameters.BuildConfig = &provenancetypes.BuildConfig{ + Definition: []provenancetypes.BuildStep{{ + Op: &solverpb.Op{Op: &solverpb.Op_Exec{Exec: &solverpb.ExecOp{Meta: &solverpb.Meta{ + Env: []string{"BUILDPLATFORM=linux/amd64", "TARGETPLATFORM=linux/arm64"}, + }}}}, + }}, + } + + got := applyPredicateTargetPlatformFallback(subject, pred, nil) + require.NotSame(t, subject, got) + require.Equal(t, "linux/arm64", platforms.Format(*got.Descriptor.Platform)) + require.Nil(t, subject.Descriptor.Platform, "defaulting must not mutate the loaded subject") + + explicit, err := filterSubjectsByPlatform([]*replay.Subject{subject}, []string{"linux/amd64"}, func() (ocispecs.Platform, error) { + t.Fatal("builder default platform must not be looked up") + return ocispecs.Platform{}, nil + }) + require.NoError(t, err) + require.Len(t, explicit, 1) + got = applyPredicateTargetPlatformFallback(explicit[0], pred, []string{"linux/amd64"}) + require.Equal(t, "linux/amd64", platforms.Format(*got.Descriptor.Platform)) +} + +func TestBuildRejectsInvalidFlagsBeforeLoadingSubject(t *testing.T) { + for _, tc := range []struct { + name string + args []string + err string + }{ + {name: "format-without-dry-run", args: []string{"--format=json"}, err: "--format requires --dry-run"}, + {name: "unknown-format", args: []string{"--dry-run", "--format=yaml"}, err: `unknown --format "yaml"`}, + {name: "unknown-replay-mode", args: []string{"--replay-mode=llb"}, err: `unknown --replay-mode "llb"`}, + } { + t.Run(tc.name, func(t *testing.T) { + builderName := "" + cmd := buildCmd(nil, RootOptions{Builder: &builderName}) + cmd.SetArgs(append(tc.args, "docker-image://example.com/foo:latest")) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + require.EqualError(t, cmd.Execute(), tc.err) + }) + } +} + +func TestApplyExportShorthandsPush(t *testing.T) { + for _, tc := range []struct { + name string + outputs []string + }{ + {name: "no-output"}, + {name: "registry-output", outputs: []string{"type=registry,name=example.com/foo:bar"}}, + {name: "image-output", outputs: []string{"type=image,name=example.com/foo:bar"}}, + } { + t.Run(tc.name, func(t *testing.T) { + specs, err := buildflags.ParseExports(tc.outputs) + require.NoError(t, err) + exports, _, err := build.CreateExports(applyExportShorthands(specs, true, false)) + require.NoError(t, err) + require.Len(t, exports, 1, "--push must not add a second pushing exporter") + require.Equal(t, client.ExporterImage, exports[0].Type) + require.Equal(t, "true", exports[0].Attrs["push"]) + require.Equal(t, "false", exports[0].Attrs["unpack"]) + }) + } +} + +func TestPrintBuildPlan(t *testing.T) { + plan := &replay.BuildPlan{Subjects: []replay.SubjectBuildPlan{{ + Descriptor: ocispecs.Descriptor{ + Digest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + Platform: &ocispecs.Platform{OS: "linux", Architecture: "arm64"}, + }, + Signature: &replay.SignatureVerification{ + Verified: true, + Type: "Sigstore Bundle", + Identity: "Docker GitHub Builder (docker/buildx@v0.37.1)", + CertificateIssuer: "CN=sigstore-intermediate,O=sigstore.dev", + SubjectAlternativeName: "https://github.com/docker/github-builder/.github/workflows/build.yml@refs/heads/main", + Issuer: "https://token.actions.githubusercontent.com", + RunnerEnvironment: "github-hosted", + SourceRepositoryURI: "https://github.com/docker/buildx", + SourceRepositoryRef: "refs/tags/v0.37.1", + BuildSignerURI: "https://github.com/docker/github-builder/.github/workflows/build.yml@refs/heads/main", + Timestamps: []replay.SignatureTimestamp{{ + Type: "Tlog", + URI: "https://rekor.sigstore.dev", + Timestamp: time.Date(2026, time.September, 18, 12, 0, 0, 0, time.UTC), + }, { + Type: "TimestampAuthority", + URI: "https://timestamp.sigstore.dev/api/v1/timestamp", + Timestamp: time.Date(2026, time.September, 18, 12, 0, 1, 0, time.UTC), + }}, + }, + BuildConfig: replay.BuildPlanConfig{ + Frontend: "gateway.v0", + Context: "https://github.com/docker/buildx.git#refs/tags/v0.37.1", + Filename: "Dockerfile", + Target: "binaries", + BuildArgs: map[string]string{"ZED": "last", "ALPHA": "first"}, + Secrets: []replay.PlanSecret{{ID: "GIT_AUTH_TOKEN", Optional: true}}, + }, + Materials: []replay.PlanMaterial{{ + URI: "pkg:docker/golang@1.26-alpine3.23", + Platform: &ocispecs.Platform{OS: "linux", Architecture: "amd64"}, + Digest: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + Kind: "image", + }}, + }}} + + var out bytes.Buffer + require.NoError(t, printBuildPlan(&out, plan)) + require.Equal(t, `Replay plan + +Subject 1/1 + Platform: linux/arm64 + Digest: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + +Build configuration + Frontend: gateway.v0 + Context: https://github.com/docker/buildx.git#refs/tags/v0.37.1 + Dockerfile: Dockerfile + Target: binaries + Build args: ALPHA=first + ZED=last + Secrets: [GIT_AUTH_TOKEN (optional)] + +Sigstore Bundle + Verified signer: Docker GitHub Builder (docker/buildx@v0.37.1) + Signer identity: https://github.com/docker/github-builder/.github/workflows/build.yml@refs/heads/main + Certificate issuer: CN=sigstore-intermediate,O=sigstore.dev + OIDC issuer: https://token.actions.githubusercontent.com + Runner environment: github-hosted + Source repository: https://github.com/docker/buildx + Source ref: refs/tags/v0.37.1 + TYPE TIME SOURCE + Transparency log 2026-09-18T12:00:00Z https://rekor.sigstore.dev + Timestamp authority 2026-09-18T12:00:01Z https://timestamp.sigstore.dev/api/v1/timestamp + +Materials (1) + image [linux/amd64] pkg:docker/golang@1.26-alpine3.23 + sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb +`, out.String()) +} diff --git a/commands/replay/root.go b/commands/replay/root.go new file mode 100644 index 000000000000..67df0f71d2f8 --- /dev/null +++ b/commands/replay/root.go @@ -0,0 +1,62 @@ +package replay + +import ( + "github.com/docker/buildx/util/cobrautil" + "github.com/docker/buildx/util/cobrautil/completion" + "github.com/docker/cli/cli/command" + "github.com/spf13/cobra" +) + +// RootOptions mirrors the shape used by history/policy/imagetools +// (see commands/history/root.go). +type RootOptions struct { + Builder *string +} + +// commonOptions is the shared flag set for every replay subcommand. +type commonOptions struct { + builder string + materials []string + network string + secrets []string + ssh []string + platforms []string + progress string +} + +// installCommonFlags registers the shared flag set on the supplied +// subcommand. Each subcommand owns its own flag registration so that +// `--help` on any leaf prints the full contract. +func installCommonFlags(cmd *cobra.Command, opts *commonOptions) { + flags := cmd.Flags() + + flags.StringArrayVar(&opts.materials, "materials", nil, `Materials store (repeatable; format: "provenance" | "oci-layout://[:]" | "" | "=")`) + flags.StringVar(&opts.network, "network", "default", `Network mode for RUN instructions ("default" | "none")`) + flags.StringArrayVar(&opts.secrets, "secret", nil, `Secret to expose to the replayed build (format: "id=mysecret[,src=/local/secret]")`) + flags.StringArrayVar(&opts.ssh, "ssh", nil, `SSH agent socket or keys to expose (format: "default|[=|[,]]")`) + flags.StringArrayVar(&opts.platforms, "platform", nil, `Platform of the subject to replay (defaults to the only platform of the subject or the builder default platform)`) + flags.StringVar(&opts.progress, "progress", "auto", `Set type of progress output ("auto" | "plain" | "tty" | "quiet" | "rawjson")`) +} + +// RootCmd returns the `buildx replay` root command. The rootcmd argument is +// the buildx root; its RunE is reused when no subcommand is given, matching +// the pattern in commands/history/root.go. +func RootCmd(rootcmd *cobra.Command, dockerCli command.Cli, opts RootOptions) *cobra.Command { + cmd := &cobra.Command{ + Use: "replay", + Short: "Replay a build from its provenance", + ValidArgsFunction: completion.Disable, + RunE: rootcmd.RunE, + + DisableFlagsInUseLine: true, + } + cobrautil.MarkCommandExperimental(cmd) + + cmd.AddCommand( + buildCmd(dockerCli, opts), + snapshotCmd(dockerCli, opts), + verifyCmd(dockerCli, opts), + ) + + return cmd +} diff --git a/commands/replay/snapshot.go b/commands/replay/snapshot.go new file mode 100644 index 000000000000..5d0837bde45e --- /dev/null +++ b/commands/replay/snapshot.go @@ -0,0 +1,202 @@ +package replay + +import ( + "context" + "encoding/json" + "os" + + "github.com/docker/buildx/replay" + "github.com/docker/buildx/util/buildflags" + "github.com/docker/buildx/util/cobrautil/completion" + "github.com/docker/buildx/util/progress" + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + "github.com/moby/buildkit/util/progress/progressui" + "github.com/pkg/errors" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +// snapshotOptions holds the parsed flags for `replay snapshot`. +type snapshotOptions struct { + commonOptions + includeMaterials bool + outputs []string + dryRun bool +} + +func snapshotCmd(dockerCli command.Cli, rootOpts RootOptions) *cobra.Command { + var opts snapshotOptions + + cmd := &cobra.Command{ + Use: "snapshot [OPTIONS] SUBJECT", + Short: "Export replay inputs for a subject as a reusable materials store", + Args: cli.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + opts.builder = *rootOpts.Builder + return runSnapshot(cmd, dockerCli, &opts, args[0]) + }, + ValidArgsFunction: completion.Disable, + DisableFlagsInUseLine: true, + // Hidden until replay build can consume snapshots. + Hidden: true, + } + + installCommonFlags(cmd, &opts.commonOptions) + + flags := cmd.Flags() + flags.BoolVar(&opts.includeMaterials, "include-materials", true, "Include material content in the snapshot") + flags.StringArrayVarP(&opts.outputs, "output", "o", nil, `Output destination (default: "-" — oci tar to stdout; bare "" writes an oci-layout directory; "type=oci,dest=X[,tar=true|false]")`) + flags.BoolVar(&opts.dryRun, "dry-run", false, "Print a JSON plan of the snapshot without writing output") + + return cmd +} + +// runSnapshot wires the CLI flags to the replay.Snapshot entry point. +func runSnapshot(cmd *cobra.Command, dockerCli command.Cli, opts *snapshotOptions, input string) error { + ctx := cmd.Context() + + // Resolve --output → a normalized snapshot export spec. Dry-run does not + // write anything so we skip the TTY refusal and terminal checks there. + var exportSpec *buildflags.ExportEntry + if !opts.dryRun { + spec, err := resolveSnapshotOutput(opts.outputs) + if err != nil { + return err + } + exportSpec = spec + } + + // Materials resolver — used to lookup pre-pinned content when + // --materials is supplied. + resolver, err := replay.NewMaterialsResolver(opts.materials) + if err != nil { + return err + } + + subjects, err := replay.LoadSubjects(ctx, dockerCli, opts.builder, input) + if err != nil { + return err + } + + subjects, err = filterSubjectsByPlatform(subjects, opts.platforms, builderDefaultPlatform(ctx, dockerCli, opts.builder)) + if err != nil { + return err + } + if len(subjects) == 0 { + return errors.New("no subjects matched the --platform filter") + } + if err := replay.VerifySignatures(ctx, dockerCli, subjects); err != nil { + return err + } + + targets := make([]replay.Target, 0, len(subjects)) + for _, s := range subjects { + pred, err := s.Predicate(ctx) + if err != nil { + return err + } + s = applyPredicateTargetPlatformFallback(s, pred, opts.platforms) + targets = append(targets, replay.Target{Subject: s, Predicate: pred}) + } + + req := &replay.SnapshotRequest{ + Targets: targets, + IncludeMaterials: opts.includeMaterials, + Materials: resolver, + Output: exportSpec, + } + + // Both real-run and dry-run do the same staging work (dry-run just + // skips the final output), so both get a progress printer. + // Keep draining progress while cancellation propagates through material + // staging. Sharing ctx can strand a late writer after Ctrl-C. + printerCtx, cancelPrinter := context.WithCancelCause(context.TODO()) + defer func() { cancelPrinter(errors.WithStack(context.Canceled)) }() + printer, err := progress.NewPrinter(printerCtx, os.Stderr, progressui.DisplayMode(opts.progress)) + if err != nil { + return err + } + req.Progress = printer + + if opts.dryRun { + plan, planErr := replay.MakeSnapshotPlan(ctx, dockerCli, opts.builder, req) + // Wait for the progress printer to drain before writing the JSON + // plan: in auto/tty mode the printer owns the terminal and its + // final redraw otherwise interleaves with stdout. + waitErr := printer.Wait() + if planErr != nil { + return planErr + } + if waitErr != nil { + return waitErr + } + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(plan) + } + + snapErr := replay.Snapshot(ctx, dockerCli, opts.builder, req) + if waitErr := printer.Wait(); snapErr == nil { + snapErr = waitErr + } + return snapErr +} + +// resolveSnapshotOutput turns raw --output values into a normalized +// ExportEntry with Type "oci". The command surface is: +// +// (unset) → type=oci, dest=- (stdout tar) +// -o - → type=oci, dest=- (stdout tar) +// -o → type=oci, dest=, tar=false (layout dir) +// -o type=oci,dest=[,tar=...] → oci, defaults to tar=true +// +// A TTY on stdout with no --output (or -o -) is refused: writing a +// multi-megabyte binary tar to a terminal is never what the user wants. +func resolveSnapshotOutput(outputs []string) (*buildflags.ExportEntry, error) { + if len(outputs) > 1 { + return nil, errors.Errorf("snapshot: exactly one --output is required (got %d)", len(outputs)) + } + + var out buildflags.ExportEntry + if len(outputs) == 0 { + out = buildflags.ExportEntry{Type: "oci", Destination: "-"} + } else { + parsed, err := buildflags.ParseExports(outputs) + if err != nil { + return nil, errors.Wrap(err, "parse --output") + } + if len(parsed) != 1 { + return nil, errors.Errorf("snapshot: exactly one --output is required (got %d)", len(parsed)) + } + out = *parsed[0] + } + + // buildflags.ParseExports maps a bare "-" to type="tar" and a bare + // "" to type="local". Translate both into our oci surface. + switch out.Type { + case "tar": + out.Type = "oci" + case "local": + // Bare path → oci-layout directory. + out.Type = "oci" + if out.Attrs == nil { + out.Attrs = map[string]string{} + } + out.Attrs["tar"] = "false" + } + + if out.Type == "oci" && out.Destination == "-" { + if term.IsTerminal(int(os.Stdout.Fd())) { + return nil, errors.New("refusing to write binary snapshot to terminal — set an --output file or directory") + } + } + + if out.Type == "registry" { + return nil, replay.ErrNotImplemented("snapshot registry output") + } + if out.Type != "oci" { + return nil, errors.Errorf("snapshot: unsupported --output type %q (want oci)", out.Type) + } + return &out, nil +} diff --git a/commands/replay/snapshot_test.go b/commands/replay/snapshot_test.go new file mode 100644 index 000000000000..e633c567e583 --- /dev/null +++ b/commands/replay/snapshot_test.go @@ -0,0 +1,15 @@ +package replay + +import ( + "testing" + + replaypkg "github.com/docker/buildx/replay" + "github.com/stretchr/testify/require" +) + +func TestResolveSnapshotOutputRejectsRegistry(t *testing.T) { + _, err := resolveSnapshotOutput([]string{"type=registry,name=example.com/replay:test"}) + require.Error(t, err) + var notImplemented *replaypkg.NotImplementedError + require.ErrorAs(t, err, ¬Implemented) +} diff --git a/commands/replay/verify.go b/commands/replay/verify.go new file mode 100644 index 000000000000..1d45a65cd3e2 --- /dev/null +++ b/commands/replay/verify.go @@ -0,0 +1,127 @@ +package replay + +import ( + "github.com/docker/buildx/replay" + "github.com/docker/buildx/util/buildflags" + "github.com/docker/buildx/util/cobrautil/completion" + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + "github.com/moby/buildkit/util/progress/progressui" + "github.com/pkg/errors" + "github.com/spf13/cobra" +) + +// verifyOptions holds the parsed flags for `replay verify`. +type verifyOptions struct { + commonOptions + compare string + outputs []string +} + +func verifyCmd(dockerCli command.Cli, rootOpts RootOptions) *cobra.Command { + var opts verifyOptions + + cmd := &cobra.Command{ + Use: "verify [OPTIONS] SUBJECT", + Short: "Replay a subject and compare the result against the original artifact", + Args: cli.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + opts.builder = *rootOpts.Builder + return runVerify(cmd, dockerCli, &opts, args[0]) + }, + ValidArgsFunction: completion.Disable, + DisableFlagsInUseLine: true, + // Hidden until the comparison is reliable: builder cache reuse can + // make a non-reproducible build compare equal. + Hidden: true, + } + + installCommonFlags(cmd, &opts.commonOptions) + + flags := cmd.Flags() + flags.StringVar(&opts.compare, "compare", "digest", `Comparison mode ("digest" | "artifact")`) + flags.StringArrayVarP(&opts.outputs, "output", "o", nil, `Output destination for the verification result (VSA) (format: "type=local,dest=path")`) + + return cmd +} + +// runVerify wires the CLI flags to replay.Verify. Verification runs on a +// single platform subject. +func runVerify(cmd *cobra.Command, dockerCli command.Cli, opts *verifyOptions, input string) error { + ctx := cmd.Context() + + mode := opts.compare + switch mode { + case "", replay.CompareModeDigest, replay.CompareModeArtifact: + // ok + default: + return errors.Errorf("unknown --compare %q", opts.compare) + } + + // Parse --output (optional). + var exportSpec *buildflags.ExportEntry + if len(opts.outputs) > 0 { + specs, err := buildflags.ParseExports(opts.outputs) + if err != nil { + return errors.Wrap(err, "parse --output") + } + if len(specs) != 1 { + return errors.Errorf("verify: exactly one --output is required (got %d)", len(specs)) + } + exportSpec = specs[0] + } + + resolver, err := replay.NewMaterialsResolver(opts.materials) + if err != nil { + return err + } + secretSpecs, err := buildflags.ParseSecretSpecs(opts.secrets) + if err != nil { + return errors.Wrap(err, "parse --secret") + } + sshSpecs, err := buildflags.ParseSSHSpecs(opts.ssh) + if err != nil { + return errors.Wrap(err, "parse --ssh") + } + + subjects, err := replay.LoadSubjects(ctx, dockerCli, opts.builder, input) + if err != nil { + return err + } + subjects, err = filterSubjectsByPlatform(subjects, opts.platforms, builderDefaultPlatform(ctx, dockerCli, opts.builder)) + if err != nil { + return err + } + if len(subjects) == 0 { + return errors.New("no subjects matched the --platform filter") + } + if len(subjects) > 1 { + return replay.ErrNotImplemented("multi-subject replay verify output aggregation") + } + if err := replay.VerifySignatures(ctx, dockerCli, subjects); err != nil { + return err + } + + for _, s := range subjects { + pred, err := s.Predicate(ctx) + if err != nil { + return err + } + s = applyPredicateTargetPlatformFallback(s, pred, opts.platforms) + req := &replay.VerifyRequest{ + Subject: s, + Predicate: pred, + Mode: mode, + Materials: resolver, + Network: opts.network, + Secrets: secretSpecs, + SSH: sshSpecs, + Output: exportSpec, + Progress: progressui.DisplayMode(opts.progress), + } + if _, err := replay.Verify(ctx, dockerCli, opts.builder, req); err != nil { + return err + } + } + return nil +} diff --git a/commands/root.go b/commands/root.go index 880244db6862..38e3fd3108b1 100644 --- a/commands/root.go +++ b/commands/root.go @@ -8,6 +8,7 @@ import ( historycmd "github.com/docker/buildx/commands/history" imagetoolscmd "github.com/docker/buildx/commands/imagetools" policycmd "github.com/docker/buildx/commands/policy" + replaycmd "github.com/docker/buildx/commands/replay" "github.com/docker/buildx/util/cobrautil/completion" "github.com/docker/buildx/util/confutil" "github.com/docker/buildx/util/logutil" @@ -133,6 +134,7 @@ func addCommands(cmd *cobra.Command, opts *rootOptions, dockerCli command.Cli) { duCmd(dockerCli, opts), imagetoolscmd.RootCmd(cmd, dockerCli, imagetoolscmd.RootOptions{Builder: &opts.builder}), historycmd.RootCmd(cmd, dockerCli, historycmd.RootOptions{Builder: &opts.builder}), + replaycmd.RootCmd(cmd, dockerCli, replaycmd.RootOptions{Builder: &opts.builder}), dapCmd(dockerCli, opts), ) if confutil.IsExperimental() { diff --git a/docs/reference/buildx.md b/docs/reference/buildx.md index 3aa0cb70f5a2..ffbe150a518c 100644 --- a/docs/reference/buildx.md +++ b/docs/reference/buildx.md @@ -9,25 +9,26 @@ Extended build capabilities with BuildKit ### Subcommands -| Name | Description | -|:-------------------------------------|:-------------------------------------------------| -| [`bake`](buildx_bake.md) | Build from a file | -| [`build`](buildx_build.md) | Start a build | -| [`create`](buildx_create.md) | Create a new builder instance | -| [`dap`](buildx_dap.md) | Start debug adapter protocol compatible debugger | -| [`debug`](buildx_debug.md) | Start debugger (EXPERIMENTAL) | -| [`dial-stdio`](buildx_dial-stdio.md) | Proxy current stdio streams to builder instance | -| [`du`](buildx_du.md) | Disk usage | -| [`history`](buildx_history.md) | Commands to work on build records | -| [`imagetools`](buildx_imagetools.md) | Commands to work on images in registry | -| [`inspect`](buildx_inspect.md) | Inspect current builder instance | -| [`ls`](buildx_ls.md) | List builder instances | -| [`policy`](buildx_policy.md) | Commands for working with build policies | -| [`prune`](buildx_prune.md) | Remove build cache | -| [`rm`](buildx_rm.md) | Remove one or more builder instances | -| [`stop`](buildx_stop.md) | Stop builder instance | -| [`use`](buildx_use.md) | Set the current builder instance | -| [`version`](buildx_version.md) | Show buildx version information | +| Name | Description | +|:-------------------------------------|:--------------------------------------------------| +| [`bake`](buildx_bake.md) | Build from a file | +| [`build`](buildx_build.md) | Start a build | +| [`create`](buildx_create.md) | Create a new builder instance | +| [`dap`](buildx_dap.md) | Start debug adapter protocol compatible debugger | +| [`debug`](buildx_debug.md) | Start debugger (EXPERIMENTAL) | +| [`dial-stdio`](buildx_dial-stdio.md) | Proxy current stdio streams to builder instance | +| [`du`](buildx_du.md) | Disk usage | +| [`history`](buildx_history.md) | Commands to work on build records | +| [`imagetools`](buildx_imagetools.md) | Commands to work on images in registry | +| [`inspect`](buildx_inspect.md) | Inspect current builder instance | +| [`ls`](buildx_ls.md) | List builder instances | +| [`policy`](buildx_policy.md) | Commands for working with build policies | +| [`prune`](buildx_prune.md) | Remove build cache | +| [`replay`](buildx_replay.md) | Replay a build from its provenance (EXPERIMENTAL) | +| [`rm`](buildx_rm.md) | Remove one or more builder instances | +| [`stop`](buildx_stop.md) | Stop builder instance | +| [`use`](buildx_use.md) | Set the current builder instance | +| [`version`](buildx_version.md) | Show buildx version information | ### Options diff --git a/docs/reference/buildx_replay.md b/docs/reference/buildx_replay.md new file mode 100644 index 000000000000..3f4f670b58da --- /dev/null +++ b/docs/reference/buildx_replay.md @@ -0,0 +1,57 @@ +# docker buildx replay + +```text +docker buildx replay [OPTIONS] COMMAND +``` + + +Replay a build from its provenance (EXPERIMENTAL) + +### Subcommands + +| Name | Description | +|:----------------------------------|:------------------------------------------------------| +| [`build`](buildx_replay_build.md) | Rebuild an image from provenance and pinned materials | + + +### Options + +| Name | Type | Default | Description | +|:----------------|:---------|:--------|:-----------------------------------------| +| `--builder` | `string` | | Override the configured builder instance | +| `-D`, `--debug` | `bool` | | Enable debug logging | + + + + +## Description + +`buildx replay` reads the SLSA provenance attestation of an existing build and +reproduces the build with the recorded frontend, options, and source digests. +Replay runs on the selected builder and requires BuildKit v0.27 or later. + +Subjects are accepted in three forms: + +- `docker-image://` or a bare `` — resolve through the registry. +- `oci-layout://[:]` — read from a local OCI layout. +- A local attestation file: an in-toto statement (`.intoto.jsonl`), an + unsigned DSSE envelope, a Sigstore bundle, or a bare SLSA provenance + predicate. + +A build can be replayed when: + +- its provenance was recorded with `mode=max` + (`--provenance=mode=max` or `--attest=type=provenance,mode=max`). `mode=min` + provenance omits the build arguments, secrets, and SSH needed for replay; +- its build context was a Git repository or an HTTP(S) URL. Builds from local + directories cannot be replayed; +- the recorded sources are still available. + +Replay rebuilds one platform at a time. For a multi-platform image, select the +platform with `--platform`. By default, the only platform of the image or the +default platform of the builder is used. + +## Related + +- [SLSA Provenance v1](https://slsa.dev/provenance/v1) +- [`docker buildx history`](buildx_history.md) — inspect locally recorded builds diff --git a/docs/reference/buildx_replay_build.md b/docs/reference/buildx_replay_build.md new file mode 100644 index 000000000000..3976c5f36d3f --- /dev/null +++ b/docs/reference/buildx_replay_build.md @@ -0,0 +1,74 @@ +# docker buildx replay build + + +Rebuild an image from provenance and pinned materials + +### Options + +| Name | Type | Default | Description | +|:-----------------|:--------------|:------------|:-----------------------------------------------------------------------------------------------------------------| +| `--builder` | `string` | | Override the configured builder instance | +| `-D`, `--debug` | `bool` | | Enable debug logging | +| `--dry-run` | `bool` | | Print a plan of the replay without solving or exporting | +| `--format` | `string` | `pretty` | Format dry-run output (`pretty` \| `json`) | +| `--load` | `bool` | | Shorthand for `--output=type=docker` | +| `--network` | `string` | `default` | Network mode for RUN instructions (`default` \| `none`) | +| `-o`, `--output` | `stringArray` | | Output destination (format: `type=local,dest=path`) | +| `--platform` | `stringArray` | | Platform of the subject to replay (defaults to the only platform of the subject or the builder default platform) | +| `--progress` | `string` | `auto` | Set type of progress output (`auto` \| `plain` \| `tty` \| `quiet` \| `rawjson`) | +| `--push` | `bool` | | Shorthand for `--output=type=registry,unpack=false` | +| `--replay-mode` | `string` | `materials` | Replay mode (`materials` \| `frontend`) | +| `--secret` | `stringArray` | | Secret to expose to the replayed build (format: `id=mysecret[,src=/local/secret]`) | +| `--ssh` | `stringArray` | | SSH agent socket or keys to expose (format: `default\|[=\|[,]]`) | +| `-t`, `--tag` | `stringArray` | | Image identifier (format: `[registry/]repository[:tag]`) | + + + + +## Description + +`replay build` reconstructs an image from the provenance attestation attached +to an existing subject. + +The replay mode controls how sources are resolved: + +- `materials` (default) pins every source to the digest recorded in the + provenance. A source that is not recorded, or whose content changed, fails + the build. +- `frontend` replays the recorded frontend and options, but resolves sources + again, so the result can differ from the original build. + +Replayed builds do not add new provenance or SBOM attestations. + +## Examples + +### Replay a registry image and export to an OCI tar + +```console +docker buildx replay build docker-image://example.com/app@sha256:deadbeef \ + --output=type=oci,dest=replay.oci.tar +``` + +### Dry-run a replay to inspect the plan + +```console +docker buildx replay build docker-image://example.com/app@sha256:deadbeef --dry-run --format=json | jq +``` + +Dry-run runs the same checks as a real replay, so a subject that cannot be +replayed fails before any build starts. + +## Signature verification + +For image subjects, replay discovers Sigstore signatures attached to the +selected platform's provenance attestation through OCI referrers. Standalone +Sigstore bundle files are also accepted. Replay verifies the certificate, +transparency-log inclusion, observer timestamp, and signed payload before using +the provenance. Pretty and JSON dry-run output report the verified identity. + +Unsigned provenance remains accepted. If a published signature is discovered +but is invalid, replay fails instead of silently treating it as unsigned. +There is not yet a `--require-signature` option or signer-authorization policy. +For a standalone bundle, verification authenticates the signed statement and +its claimed subject digest; it does not compare that digest with a separately +supplied artifact. diff --git a/go.mod b/go.mod index d5a532ed2f39..a67f8432a6c5 100644 --- a/go.mod +++ b/go.mod @@ -39,6 +39,7 @@ require ( github.com/open-policy-agent/opa v1.19.0 github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/image-spec v1.1.1 + github.com/package-url/packageurl-go v0.1.1 github.com/pelletier/go-toml/v2 v2.4.3 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pkg/errors v0.9.1 @@ -177,7 +178,6 @@ require ( github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/oklog/ulid/v2 v2.1.1 // indirect - github.com/package-url/packageurl-go v0.1.1 // indirect github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect diff --git a/replay/build.go b/replay/build.go new file mode 100644 index 000000000000..14ccc09a513e --- /dev/null +++ b/replay/build.go @@ -0,0 +1,549 @@ +package replay + +import ( + "context" + "fmt" + "sort" + "strings" + + "github.com/containerd/platforms" + "github.com/docker/buildx/build" + "github.com/docker/buildx/builder" + "github.com/docker/buildx/util/buildflags" + "github.com/docker/buildx/util/confutil" + "github.com/docker/buildx/util/dockerutil" + "github.com/docker/buildx/util/progress" + "github.com/docker/cli/cli/command" + "github.com/moby/buildkit/identity" + provenancetypes "github.com/moby/buildkit/solver/llbsolver/provenance/types" + "github.com/moby/buildkit/util/progress/progressui" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/pkg/errors" + "github.com/tonistiigi/go-csvvalue" +) + +// BuildMode is a replay mode. +type BuildMode string + +const ( + BuildModeMaterials BuildMode = "materials" + BuildModeFrontend BuildMode = "frontend" +) + +// Target pairs one subject with its already-loaded predicate. A replay +// operation spans N targets (one per platform, typically from a multi- +// platform LoadSubjects fan-out). +type Target struct { + Subject *Subject + Predicate *Predicate +} + +// BuildRequest is a single replay-build invocation spanning one or more +// targets that share the same user-supplied flags. +type BuildRequest struct { + // Targets is the set of (subject, predicate) pairs to replay. For a + // single-platform subject this is len 1; multi-platform inputs fan + // out into multiple targets sharing the other fields below. + Targets []Target + + // Mode selects a replay strategy. Empty defaults to BuildModeMaterials. + Mode BuildMode + + // Materials resolves provenance materials to local content stores. May + // be nil, in which case the default sentinel-only resolver is used. + Materials *MaterialsResolver + + // NetworkMode controls the network mode for RUN instructions in the + // replayed build (default | none). Material resolution is NOT affected. + NetworkMode string + + // Secrets / SSH hold the user-supplied specs for the replayed solve. + // Cross-checked against each predicate via Secrets()/SSH() before any + // solve begins. + Secrets buildflags.Secrets + SSH []*buildflags.SSH + + // Exports are the buildflags-parsed --output specs. + Exports []*buildflags.ExportEntry + + // Tags are "--tag" values to apply to image/oci/docker exports. Flow + // matches `docker buildx build`: the tags become the `name=` attribute + // on each eligible export via build/opt.go toSolveOpt. + Tags []string + + // Progress controls the display mode for replay progress output. + Progress progressui.DisplayMode +} + +// checkBuildRequest runs the pre-solve checks shared by Build and +// MakeBuildPlan so that a dry-run fails exactly like the real replay would. +func checkBuildRequest(req *BuildRequest) error { + if len(req.Targets) == 0 { + return errors.New("no targets to replay") + } + if len(req.Targets) > 1 { + return ErrNotImplemented("replaying more than one platform at a time; select a single platform with --platform") + } + for _, t := range req.Targets { + if t.Subject == nil || t.Predicate == nil { + return errors.New("target has nil subject or predicate") + } + if err := checkReplayable(t.Predicate, req.Mode, req.Secrets, req.SSH); err != nil { + return err + } + } + return nil +} + +// checkReplayable rejects provenance that cannot be replayed faithfully and +// cross-checks the user-supplied secrets and SSH against the recorded ones. +func checkReplayable(pred *Predicate, mode BuildMode, secrets buildflags.Secrets, ssh []*buildflags.SSH) error { + if locals := pred.Locals(); len(locals) > 0 { + names := make([]string, 0, len(locals)) + for _, l := range locals { + names = append(names, l.Name) + } + return ErrUnreplayableLocalContext(names) + } + if pred.IsMinMode() { + return ErrMinModeProvenance() + } + if mode == "" || mode == BuildModeMaterials { + if err := checkContextPinned(pred); err != nil { + return err + } + } + if err := CheckSecrets(pred.Secrets(), secrets); err != nil { + return err + } + return CheckSSH(pred.SSH(), ssh) +} + +// checkContextPinned rejects a Git subdirectory context whose commit is not +// recorded. BuildKit before v0.25 recorded no digest for the build context +// and recorded the Git material without the subdirectory, so the context +// request cannot be matched to a pinned material and would only fail during +// the solve. +func checkContextPinned(pred *Predicate) error { + cfg := pred.ConfigSource() + if cfg.URI == "" || len(cfg.Digest) > 0 { + return nil + } + if _, fragment, ok := strings.Cut(cfg.URI, "#"); !ok || !strings.Contains(fragment, ":") { + return nil + } + if _, ok := NewPinIndex(pred).Lookup(cfg.URI); ok { + return nil + } + return ErrUnpinnedContext(cfg.URI) +} + +// SubjectKey returns a stable identifier for a subject, used as the map key +// for build.Build's map[string]Options input. +func SubjectKey(s *Subject) string { + if s == nil { + return "" + } + if s.Descriptor.Platform != nil { + return fmt.Sprintf("%s@%s", s.Descriptor.Digest, platforms.Format(*s.Descriptor.Platform)) + } + return s.Descriptor.Digest.String() +} + +// Build executes the replay request against the supplied builder. +// +// Fail-fast: cross-check errors are reported per-subject with typed errors +// (Missing/ExtraSecretError, Missing/ExtraSSHError) before any solve starts; +// a local-context predicate fails with UnreplayableLocalContextError. +// +// Mode selection: +// +// - BuildModeMaterials (default): recorded frontend + strict source-policy +// pinning via the session policy callback. +// - BuildModeFrontend: recorded frontend + NO strict pinning (sources float). +func Build(ctx context.Context, dockerCli command.Cli, builderName string, req *BuildRequest) (retErr error) { + if req == nil { + return errors.New("nil build request") + } + if err := checkBuildRequest(req); err != nil { + return err + } + + // Parse exports once; shared across all targets. + exports, _, err := build.CreateExports(req.Exports) + if err != nil { + return errors.Wrap(err, "parse --output") + } + + // Build the map[string]build.Options keyed by subject key. + buildOpts := make(map[string]build.Options, len(req.Targets)) + for _, t := range req.Targets { + opt, err := BuildOptionsFromPredicate(t.Subject, t.Predicate, req) + if err != nil { + return err + } + opt.Exports = exports + buildOpts[SubjectKey(t.Subject)] = opt + } + + // Builder + printer wiring. + b, err := builder.New(dockerCli, builder.WithName(builderName)) + if err != nil { + return err + } + nodes, err := b.LoadNodes(ctx) + if err != nil { + return err + } + mode := req.Mode + if mode == "" { + mode = BuildModeMaterials + } + warningMsg := "" + if mode == BuildModeMaterials { + warningMsg = materialsModePlatformWarning(req.Targets, nodes) + } + + progressMode := req.Progress + if progressMode == "" { + progressMode = progressui.AutoMode + } + // Keep the printer alive while cancellation propagates through BuildKit. + // If it shares the solve context, Ctrl-C stops its reader before producers + // finish and a late progress write can block solve cleanup indefinitely. + printerCtx, cancelPrinter := context.WithCancelCause(context.TODO()) + defer func() { cancelPrinter(errors.WithStack(context.Canceled)) }() + printer, err := progress.NewPrinter(printerCtx, dockerCli.Err(), progressMode, + progress.WithDesc( + fmt.Sprintf("rebuilding %d subject(s) with %q instance using %s driver", len(req.Targets), b.Name, b.Driver), + fmt.Sprintf("%s:%s", b.Driver, b.Name), + ), + ) + if err != nil { + return err + } + defer func() { + werr := printer.Wait() + if retErr == nil { + retErr = werr + } + }() + if warningMsg != "" { + if err := progress.Wrap("check replay environment", printer.Write, func(sub progress.SubLogger) error { + sub.Log(2, []byte("warning: "+warningMsg+"\n")) + return nil + }); err != nil { + return err + } + } + + if _, err := build.Build(ctx, nodes, buildOpts, dockerutil.NewClient(dockerCli), confutil.NewConfig(dockerCli), printer, nil); err != nil { + return errors.Wrap(err, "replay build") + } + return nil +} + +func materialsModePlatformWarning(targets []Target, nodes []builder.Node) string { + hostPlat := platforms.Normalize(platforms.DefaultSpec()) + instancePlat := &hostPlat + if len(nodes) == 0 { + instanceFmt := platforms.Format(*instancePlat) + for _, t := range targets { + if t.Predicate == nil { + continue + } + prov, ok := t.Predicate.DefaultPlatform() + if !ok || prov == nil { + continue + } + provFmt := platforms.Format(*prov) + if provFmt == instanceFmt { + continue + } + return fmt.Sprintf("provenance default platform %s does not match current builder instance default platform %s; materials-mode replay may be inefficient or fail", provFmt, instanceFmt) + } + return "" + } + matchedHost := false + for _, n := range nodes { + if n.Err != nil || len(n.Platforms) == 0 { + continue + } + for i := range n.Platforms { + p := platforms.Normalize(n.Platforms[i]) + if platforms.Only(hostPlat).Match(p) { + matchedHost = true + break + } + } + if matchedHost { + break + } + } + if !matchedHost { + for _, n := range nodes { + if n.Err != nil || len(n.Platforms) == 0 { + continue + } + p := platforms.Normalize(n.Platforms[0]) + instancePlat = &p + break + } + } + instanceFmt := platforms.Format(platforms.Normalize(*instancePlat)) + for _, t := range targets { + if t.Predicate == nil { + continue + } + prov, ok := t.Predicate.DefaultPlatform() + if !ok || prov == nil { + continue + } + provFmt := platforms.Format(*prov) + if provFmt == instanceFmt { + continue + } + return fmt.Sprintf("provenance default platform %s does not match current builder instance default platform %s; materials-mode replay may be inefficient or fail", provFmt, instanceFmt) + } + return "" +} + +// BuildOptionsFromPredicate maps a (subject, predicate) pair to a +// build.Options. The resulting options have Exports left empty; Build +// populates them from the request. +func BuildOptionsFromPredicate(s *Subject, pred *Predicate, req *BuildRequest) (build.Options, error) { + if pred == nil { + return build.Options{}, errors.New("nil predicate") + } + if req == nil { + return build.Options{}, errors.New("nil build request") + } + if req.Materials.HasExplicitSources() { + return build.Options{}, ErrNotImplemented("replay build with explicit --materials sources") + } + + networkMode, err := networkModeForReplay(req.NetworkMode) + if err != nil { + return build.Options{}, err + } + + attrs := pred.FrontendAttrs() + cfgSrc := pred.ConfigSource() + + labels := collectPrefixed(attrs, "label:") + buildArgs := collectPrefixed(attrs, "build-arg:") + var nocacheFilter []string + noCache := false + if v, ok := attrs["no-cache"]; ok { + if v == "" { + noCache = true + } else if fields, err := csvvalue.Fields(v, nil); err == nil { + nocacheFilter = fields + } + } + + // NamedContexts from recorded "context:*" attrs. + namedContexts := map[string]build.NamedContext{} + for k, v := range attrs { + name, ok := strings.CutPrefix(k, "context:") + if !ok { + continue + } + namedContexts[name] = build.NamedContext{Path: v} + } + + target := attrs["target"] + var extraHosts []string + if v := attrs["add-hosts"]; v != "" { + if fields, err := csvvalue.Fields(v, nil); err == nil { + extraHosts = fields + } + } + cgroupParent := attrs["cgroup-parent"] + + // Dockerfile path comes from configSource.path when present — that is the + // canonical provenance field for the build definition. The recorded + // frontend attr is only used as a compatibility fallback. + dockerfilePath := cfgSrc.Path + if dockerfilePath == "" { + dockerfilePath = attrs["filename"] + } + + // The build context comes from configSource.uri when present — that is + // the canonical provenance field for the source location. The recorded + // frontend attr is only used as a compatibility fallback. Replay rejects + // local filesystem contexts up-front via the Locals check, so by the time + // we get here the predicate is expected to carry a remote source URL. + contextPath := cfgSrc.URI + if contextPath == "" { + contextPath = attrs["context"] + } + if contextPath == "" { + return build.Options{}, errors.Errorf("predicate has no recorded build context; replay requires a remote-source build (git / https)") + } + + frontend := pred.Frontend() + frontendAttrs := map[string]string{} + if frontend == "gateway.v0" { + if source := attrs["source"]; source != "" { + frontendAttrs["source"] = source + } + if cmdline := strings.TrimSpace(attrs["cmdline"]); cmdline != "" { + frontendAttrs["cmdline"] = cmdline + if frontendAttrs["source"] == "" { + frontendAttrs["source"] = strings.Fields(cmdline)[0] + } + } + if frontendAttrs["source"] == "" { + return build.Options{}, errors.New("gateway.v0 predicate has no recorded frontend source") + } + } + + opt := build.Options{ + Ref: identity.NewID(), + Frontend: frontend, + FrontendAttrs: frontendAttrs, + Target: target, + Inputs: build.Inputs{ + ContextPath: contextPath, + DockerfilePath: dockerfilePath, + NamedContexts: namedContexts, + }, + BuildArgs: buildArgs, + Labels: labels, + NoCache: noCache, + NoCacheFilter: nocacheFilter, + ExtraHosts: extraHosts, + CgroupParent: cgroupParent, + NetworkMode: networkMode, + SecretSpecs: req.Secrets, + SSHSpecs: req.SSH, + Tags: req.Tags, + // A replay provenance attestation would describe the replay operation, + // not the original build. Disable default provenance/SBOM attachment. + Attests: map[string]*string{"provenance": nil, "sbom": nil}, + } + + if s.Descriptor.Platform != nil { + opt.Platforms = []ocispecs.Platform{*s.Descriptor.Platform} + } + + // Strict source pinning applies in materials mode only (the default). + // Attach via the shared Policy slot as a callback-only entry — composes + // with any file-based user policies the caller may have configured. + if req.Mode == "" || req.Mode == BuildModeMaterials { + opt.Policy = append(opt.Policy, buildflags.PolicyConfig{ + Callback: ReplayPinCallback(NewPinIndex(pred)), + }) + } + + return opt, nil +} + +func networkModeForReplay(mode string) (string, error) { + switch mode { + case "", "default": + return "", nil + case "none": + return "none", nil + default: + return "", errors.Errorf("unsupported replay network mode %q (want default or none)", mode) + } +} + +// CheckSecrets enforces the provenance vs. user-supplied secret-ID cross +// check: required (non-optional) IDs declared in provenance must be +// provided; any provided IDs not declared in provenance are rejected. +func CheckSecrets(declared []*provenancetypes.Secret, provided buildflags.Secrets) error { + required := map[string]struct{}{} + declaredAll := map[string]struct{}{} + for _, s := range declared { + if s == nil || s.ID == "" { + continue + } + declaredAll[s.ID] = struct{}{} + if !s.Optional { + required[s.ID] = struct{}{} + } + } + + providedIDs := map[string]struct{}{} + for _, s := range provided { + if s == nil || s.ID == "" { + continue + } + providedIDs[s.ID] = struct{}{} + } + + missing := setDiff(required, providedIDs) + extra := setDiff(providedIDs, declaredAll) + if len(missing) > 0 { + return ErrMissingSecret(missing) + } + if len(extra) > 0 { + return ErrExtraSecret(extra) + } + return nil +} + +// CheckSSH enforces the provenance vs. user-supplied SSH cross check. +func CheckSSH(declared []*provenancetypes.SSH, provided []*buildflags.SSH) error { + required := map[string]struct{}{} + declaredAll := map[string]struct{}{} + for _, s := range declared { + if s == nil || s.ID == "" { + continue + } + declaredAll[s.ID] = struct{}{} + if !s.Optional { + required[s.ID] = struct{}{} + } + } + + providedIDs := map[string]struct{}{} + for _, s := range provided { + if s == nil || s.ID == "" { + continue + } + providedIDs[s.ID] = struct{}{} + } + + missing := setDiff(required, providedIDs) + extra := setDiff(providedIDs, declaredAll) + if len(missing) > 0 { + return ErrMissingSSH(missing) + } + if len(extra) > 0 { + return ErrExtraSSH(extra) + } + return nil +} + +// setDiff returns the ordered list of elements in a that are not in b. +func setDiff(a, b map[string]struct{}) []string { + var out []string + for k := range a { + if _, ok := b[k]; !ok { + out = append(out, k) + } + } + sort.Strings(out) + return out +} + +// collectPrefixed returns the keys from attrs whose key starts with prefix, +// with the prefix stripped. Values are copied verbatim. +func collectPrefixed(attrs map[string]string, prefix string) map[string]string { + out := map[string]string{} + for k, v := range attrs { + name, ok := strings.CutPrefix(k, prefix) + if !ok { + continue + } + out[name] = v + } + if len(out) == 0 { + return nil + } + return out +} diff --git a/replay/build_test.go b/replay/build_test.go new file mode 100644 index 000000000000..7de4e3417037 --- /dev/null +++ b/replay/build_test.go @@ -0,0 +1,296 @@ +package replay + +import ( + "context" + "testing" + + "github.com/docker/buildx/builder" + "github.com/docker/buildx/util/buildflags" + slsa1 "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v1" + provenancetypes "github.com/moby/buildkit/solver/llbsolver/provenance/types" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/stretchr/testify/require" +) + +func TestCheckSecretsMissing(t *testing.T) { + declared := []*provenancetypes.Secret{ + {ID: "required"}, + {ID: "optional", Optional: true}, + } + err := CheckSecrets(declared, nil) + require.Error(t, err) + var mse *MissingSecretError + require.ErrorAs(t, err, &mse) + require.Equal(t, []string{"required"}, mse.IDs) +} + +func TestCheckSecretsExtra(t *testing.T) { + declared := []*provenancetypes.Secret{{ID: "a"}} + provided := buildflags.Secrets{{ID: "a"}, {ID: "rogue"}} + err := CheckSecrets(declared, provided) + require.Error(t, err) + var ese *ExtraSecretError + require.ErrorAs(t, err, &ese) + require.Equal(t, []string{"rogue"}, ese.IDs) +} + +func TestCheckSecretsOptionalOmitted(t *testing.T) { + declared := []*provenancetypes.Secret{ + {ID: "required"}, + {ID: "optional", Optional: true}, + } + provided := buildflags.Secrets{{ID: "required"}} + require.NoError(t, CheckSecrets(declared, provided)) +} + +func TestCheckSecretsOptionalProvidedAllowed(t *testing.T) { + declared := []*provenancetypes.Secret{ + {ID: "required"}, + {ID: "optional", Optional: true}, + } + provided := buildflags.Secrets{{ID: "required"}, {ID: "optional"}} + require.NoError(t, CheckSecrets(declared, provided)) +} + +func TestCheckSSHMissing(t *testing.T) { + declared := []*provenancetypes.SSH{{ID: "default"}} + err := CheckSSH(declared, nil) + var mse *MissingSSHError + require.ErrorAs(t, err, &mse) + require.Equal(t, []string{"default"}, mse.IDs) +} + +func TestCheckSSHExtra(t *testing.T) { + declared := []*provenancetypes.SSH{{ID: "default"}} + provided := []*buildflags.SSH{{ID: "default"}, {ID: "rogue"}} + err := CheckSSH(declared, provided) + var ese *ExtraSSHError + require.ErrorAs(t, err, &ese) + require.Equal(t, []string{"rogue"}, ese.IDs) +} + +func predicateWithAttrs(attrs map[string]string) *Predicate { + // Ensure every fixture predicate carries a remote-source `context` + // so BuildOptionsFromPredicate passes the replay context check. + if attrs == nil { + attrs = map[string]string{} + } + if _, ok := attrs["context"]; !ok { + attrs["context"] = "https://github.com/docker/buildx.git" + } + pred := &Predicate{} + pred.BuildDefinition.ExternalParameters.Request.Args = attrs + return pred +} + +func subjectWithPlatform(arch string) *Subject { + return &Subject{ + Descriptor: ocispecs.Descriptor{ + Platform: &ocispecs.Platform{OS: "linux", Architecture: arch}, + }, + } +} + +func TestBuildOptionsFromPredicate(t *testing.T) { + s := subjectWithPlatform("amd64") + + pred := predicateWithAttrs(map[string]string{ + "target": "myapp", + "filename": "Dockerfile.prod", + "label:org.example.owner": "alice", + "build-arg:FOO": "bar", + "build-arg:BUILDKIT_INLINE_CACHE": "1", + "add-hosts": "foo:1.2.3.4,bar:5.6.7.8", + "no-cache": "stage1,stage2", + "context:app": "docker-image://alpine:3.18", + "attest:provenance": "mode=max", + "attest:sbom": "true", + }) + + req := &BuildRequest{Mode: BuildModeFrontend} + opt, err := BuildOptionsFromPredicate(s, pred, req) + require.NoError(t, err) + + require.Equal(t, "myapp", opt.Target) + require.Equal(t, "Dockerfile.prod", opt.Inputs.DockerfilePath) + require.Equal(t, map[string]string{"org.example.owner": "alice"}, opt.Labels) + require.Equal(t, map[string]string{"FOO": "bar", "BUILDKIT_INLINE_CACHE": "1"}, opt.BuildArgs) + require.Equal(t, []string{"foo:1.2.3.4", "bar:5.6.7.8"}, opt.ExtraHosts) + require.Equal(t, []string{"stage1", "stage2"}, opt.NoCacheFilter) + require.False(t, opt.NoCache) + + require.Contains(t, opt.Inputs.NamedContexts, "app") + require.Equal(t, "docker-image://alpine:3.18", opt.Inputs.NamedContexts["app"].Path) + + // Replay must not attach fresh attestations that describe the replay + // operation rather than the original build. + require.Contains(t, opt.Attests, "provenance") + require.Nil(t, opt.Attests["provenance"]) + require.Contains(t, opt.Attests, "sbom") + require.Nil(t, opt.Attests["sbom"]) + + // Platform should mirror the subject descriptor. + require.Len(t, opt.Platforms, 1) + require.Equal(t, "amd64", opt.Platforms[0].Architecture) + + // In frontend mode, no pin callback is attached. + require.Empty(t, opt.Policy) +} + +func TestBuildOptionsFromPredicateRejectsUnsupportedNetworkMode(t *testing.T) { + _, err := BuildOptionsFromPredicate(subjectWithPlatform("amd64"), predicateWithAttrs(nil), &BuildRequest{ + Mode: BuildModeFrontend, + NetworkMode: "host", + }) + require.ErrorContains(t, err, `unsupported replay network mode "host"`) +} + +func TestBuildOptionsFromPredicateRejectsUnwiredExplicitMaterials(t *testing.T) { + resolver, err := NewMaterialsResolver([]string{t.TempDir()}) + require.NoError(t, err) + + for _, mode := range []BuildMode{BuildModeMaterials, BuildModeFrontend} { + t.Run(string(mode), func(t *testing.T) { + _, err := BuildOptionsFromPredicate(subjectWithPlatform("amd64"), predicateWithAttrs(nil), &BuildRequest{ + Mode: mode, + Materials: resolver, + }) + require.Error(t, err) + var notImplemented *NotImplementedError + require.ErrorAs(t, err, ¬Implemented) + }) + } +} + +func TestBuildRejectsMultipleSubjectsUntilOutputsAreAggregated(t *testing.T) { + req := &BuildRequest{Targets: []Target{ + {Subject: subjectWithPlatform("amd64"), Predicate: predicateWithAttrs(nil)}, + {Subject: subjectWithPlatform("arm64"), Predicate: predicateWithAttrs(nil)}, + }} + + err := Build(context.Background(), nil, "", req) + require.Error(t, err) + var notImplemented *NotImplementedError + require.ErrorAs(t, err, ¬Implemented) + require.ErrorContains(t, err, "select a single platform with --platform") +} + +func TestBuildOptionsFromPredicateNoCacheAll(t *testing.T) { + pred := predicateWithAttrs(map[string]string{ + "no-cache": "", + }) + opt, err := BuildOptionsFromPredicate(subjectWithPlatform("amd64"), pred, &BuildRequest{Mode: BuildModeFrontend}) + require.NoError(t, err) + require.True(t, opt.NoCache) + require.Nil(t, opt.NoCacheFilter) +} + +func TestBuildOptionsFromPredicateMaterialsModeAttachesPinCallback(t *testing.T) { + pred := predicateWithAttrs(map[string]string{}) + opt, err := BuildOptionsFromPredicate(subjectWithPlatform("amd64"), pred, &BuildRequest{Mode: BuildModeMaterials}) + require.NoError(t, err) + require.Len(t, opt.Policy, 1, "materials mode must attach strict pin callback") + require.NotNil(t, opt.Policy[0].Callback, "Policy entry must carry a non-nil Callback") + require.Empty(t, opt.Policy[0].Files, "replay pin entry must not reference policy files") +} + +func TestBuildOptionsFromPredicateUsesConfigSourceAsPrimary(t *testing.T) { + pred := predicateWithAttrs(map[string]string{ + "context": "https://github.com/example/attrs.git", + "filename": "Dockerfile.attrs", + "target": "myapp", + }) + pred.BuildDefinition.ExternalParameters.ConfigSource.URI = "https://github.com/moby/buildkit.git#refs/tags/v0.29.0" + pred.BuildDefinition.ExternalParameters.ConfigSource.Path = "Dockerfile" + + opt, err := BuildOptionsFromPredicate(subjectWithPlatform("amd64"), pred, &BuildRequest{Mode: BuildModeFrontend}) + require.NoError(t, err) + require.Equal(t, "https://github.com/moby/buildkit.git#refs/tags/v0.29.0", opt.Inputs.ContextPath) + require.Equal(t, "Dockerfile", opt.Inputs.DockerfilePath) +} + +func TestBuildOptionsFromPredicateUsesConfigSourcePathForGatewayFrontend(t *testing.T) { + pred := predicateWithAttrs(map[string]string{ + "source": "docker/dockerfile-upstream:master", + "cmdline": "docker/dockerfile-upstream:master", + }) + pred.BuildDefinition.ExternalParameters.Request.Frontend = "gateway.v0" + pred.BuildDefinition.ExternalParameters.ConfigSource.URI = "https://github.com/moby/buildkit.git#refs/tags/dockerfile/1.23.0" + pred.BuildDefinition.ExternalParameters.ConfigSource.Path = "frontend/dockerfile/cmd/dockerfile-frontend/Dockerfile" + + opt, err := BuildOptionsFromPredicate(subjectWithPlatform("arm64"), pred, &BuildRequest{Mode: BuildModeMaterials}) + require.NoError(t, err) + require.Equal(t, "https://github.com/moby/buildkit.git#refs/tags/dockerfile/1.23.0", opt.Inputs.ContextPath) + require.Equal(t, "frontend/dockerfile/cmd/dockerfile-frontend/Dockerfile", opt.Inputs.DockerfilePath) + require.Equal(t, "gateway.v0", opt.Frontend) + require.Equal(t, map[string]string{ + "source": "docker/dockerfile-upstream:master", + "cmdline": "docker/dockerfile-upstream:master", + }, opt.FrontendAttrs) +} + +func TestMaterialsModePlatformWarningMismatch(t *testing.T) { + pred := predicateWithAttrs(map[string]string{}) + pred.BuildDefinition.InternalParameters.BuilderPlatform = "linux/amd64" + msg := materialsModePlatformWarning([]Target{{ + Subject: subjectWithPlatform("amd64"), + Predicate: pred, + }}, []builder.Node{{ + Platforms: []ocispecs.Platform{{OS: "linux", Architecture: "arm64"}}, + }}) + require.Contains(t, msg, "provenance default platform linux/amd64") + require.Contains(t, msg, "current builder instance default platform linux/arm64") +} + +func TestMaterialsModePlatformWarningMatch(t *testing.T) { + pred := predicateWithAttrs(map[string]string{}) + pred.BuildDefinition.InternalParameters.BuilderPlatform = "linux/amd64" + msg := materialsModePlatformWarning([]Target{{ + Subject: subjectWithPlatform("amd64"), + Predicate: pred, + }}, []builder.Node{{ + Platforms: []ocispecs.Platform{{OS: "linux", Architecture: "amd64"}}, + }}) + require.Empty(t, msg) +} + +func TestMaterialsModePlatformWarningInferredFromMaterials(t *testing.T) { + pred := predicateWithAttrs(map[string]string{}) + pred.BuildDefinition.ResolvedDependencies = []slsa1.ResourceDescriptor{ + {URI: "pkg:docker/golang@1.26-alpine3.23?platform=linux%2Famd64"}, + {URI: "pkg:docker/tonistiigi/xx@1.9.0?platform=linux%2Famd64"}, + } + msg := materialsModePlatformWarning([]Target{{ + Subject: subjectWithPlatform("arm64"), + Predicate: pred, + }}, []builder.Node{{ + Platforms: []ocispecs.Platform{{OS: "linux", Architecture: "arm64"}}, + }}) + require.Contains(t, msg, "provenance default platform linux/amd64") + require.Contains(t, msg, "current builder instance default platform linux/arm64") +} + +func TestMaterialsModePlatformWarningPrefersInferredDefaultPlatform(t *testing.T) { + pred := predicateWithAttrs(map[string]string{}) + pred.BuildDefinition.InternalParameters.BuilderPlatform = "linux/arm64" + pred.BuildDefinition.ResolvedDependencies = []slsa1.ResourceDescriptor{ + {URI: "pkg:docker/golang@1.26-alpine3.23?platform=linux%2Famd64"}, + {URI: "pkg:docker/tonistiigi/xx@1.9.0?platform=linux%2Famd64"}, + } + msg := materialsModePlatformWarning([]Target{{ + Subject: subjectWithPlatform("arm64"), + Predicate: pred, + }}, []builder.Node{{ + Platforms: []ocispecs.Platform{{OS: "linux", Architecture: "arm64"}}, + }}) + require.Contains(t, msg, "provenance default platform linux/amd64") + require.Contains(t, msg, "current builder instance default platform linux/arm64") +} + +func TestSubjectKeyWithPlatform(t *testing.T) { + s := &Subject{Descriptor: ocispecs.Descriptor{ + Digest: "sha256:deadbeef", + Platform: &ocispecs.Platform{OS: "linux", Architecture: "arm64", Variant: "v8"}, + }} + require.Equal(t, "sha256:deadbeef@linux/arm64/v8", SubjectKey(s)) +} diff --git a/replay/dryrun.go b/replay/dryrun.go new file mode 100644 index 000000000000..4f5f49be1a6a --- /dev/null +++ b/replay/dryrun.go @@ -0,0 +1,453 @@ +package replay + +import ( + "context" + "encoding/json" + "fmt" + "sort" + + "github.com/containerd/containerd/v2/core/content" + "github.com/docker/buildx/builder" + "github.com/docker/buildx/util/buildflags" + "github.com/docker/buildx/util/imagetools" + "github.com/docker/buildx/util/progress" + "github.com/docker/cli/cli/command" + slsa1 "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v1" + "github.com/moby/buildkit/client" + provenancetypes "github.com/moby/buildkit/solver/llbsolver/provenance/types" + "github.com/moby/buildkit/util/purl" + digest "github.com/opencontainers/go-digest" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/package-url/packageurl-go" + "github.com/pkg/errors" + "github.com/tonistiigi/go-csvvalue" +) + +// BuildPlan is the JSON-serializable dry-run payload for `replay build`. +// Field names are stable and consumed by tests / tooling. +type BuildPlan struct { + // Subjects is one SubjectBuildPlan per replay target. + Subjects []SubjectBuildPlan `json:"subjects"` +} + +// SubjectBuildPlan is the per-subject build-mode dry-run plan. +type SubjectBuildPlan struct { + // Descriptor is the subject descriptor (digest + mediaType + size). + Descriptor ocispecs.Descriptor `json:"descriptor"` + // Signature describes the verified signer of a standalone Sigstore bundle + // or image-attached provenance. It is absent for unsigned provenance. + Signature *SignatureVerification `json:"signature,omitempty"` + // BuildConfig summarises the solve parameters replay would use. + BuildConfig BuildPlanConfig `json:"buildConfig"` + // Materials lists the resolved provenance materials. + Materials []PlanMaterial `json:"materials"` +} + +// BuildPlanConfig mirrors the build.Options fields replay derives from the +// predicate — enough for a user to eyeball that the replay will run as +// expected. +type BuildPlanConfig struct { + Frontend string `json:"frontend"` + FrontendAttrs map[string]string `json:"frontendAttrs,omitempty"` + Context string `json:"context,omitempty"` + Filename string `json:"filename,omitempty"` + Target string `json:"target,omitempty"` + BuildArgs map[string]string `json:"buildArgs,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + NoCache bool `json:"noCache,omitempty"` + NoCacheFilter []string `json:"noCacheFilter,omitempty"` + Secrets []PlanSecret `json:"secrets,omitempty"` + SSH []string `json:"ssh,omitempty"` + NetworkMode string `json:"networkMode,omitempty"` + Exports []string `json:"exports,omitempty"` +} + +// PlanMaterial describes one provenance material. Different dry-run modes +// populate different subsets of the fields, but the JSON shape stays stable. +type PlanMaterial struct { + URI string `json:"uri,omitempty"` + // Platform is populated for image materials only — either parsed + // from the purl `?platform=` qualifier or, when the URI doesn't + // carry one, from the predicate's builder platform. + Platform *ocispecs.Platform `json:"platform,omitempty"` + Digest string `json:"digest,omitempty"` + // Kind is one of: "image", "image-blob" (container-blob), "http", + // "git", or "unknown". + Kind string `json:"kind"` + // Included reports whether `replay snapshot` would copy this + // material's bytes into the snapshot. + Included bool `json:"included,omitempty"` + // Size is the total byte size this material contributes to the + // snapshot — the root index plus the platform-matched manifest + // chain (config + all layer descriptor sizes). Only populated for + // image materials during snapshot dry-run; computed from manifest + // metadata alone (no layer bodies are fetched). + Size int64 `json:"size,omitempty"` +} + +// PlanSecret describes one declared secret plus whether it is optional. +type PlanSecret struct { + ID string `json:"id"` + Optional bool `json:"optional,omitempty"` +} + +// SnapshotPlan is the JSON-serializable dry-run payload for +// `replay snapshot` — one entry per snapshot target. +type SnapshotPlan []SnapshotPlanTarget + +// SnapshotPlanTarget is the per-subject snapshot dry-run plan. +type SnapshotPlanTarget struct { + // Subject is the subject descriptor (already carries platform). + Subject ocispecs.Descriptor `json:"subject"` + // Materials lists each recorded material and whether the snapshot + // would include its content. + Materials []PlanMaterial `json:"materials"` +} + +// MakeBuildPlan constructs the dry-run plan for a BuildRequest. +// It runs the same pre-solve checks as Build, so any condition that would +// fail the replay before the solve fails the dry-run with the same error. +func MakeBuildPlan(req *BuildRequest) (*BuildPlan, error) { + if req == nil { + return nil, errors.New("nil build request") + } + if err := checkBuildRequest(req); err != nil { + return nil, err + } + + plan := &BuildPlan{Subjects: make([]SubjectBuildPlan, 0, len(req.Targets))} + + for _, t := range req.Targets { + if _, err := BuildOptionsFromPredicate(t.Subject, t.Predicate, req); err != nil { + return nil, err + } + + plan.Subjects = append(plan.Subjects, subjectBuildPlan(t.Subject, t.Predicate, req)) + } + return plan, nil +} + +// MakeSnapshotPlan constructs the dry-run plan for a SnapshotRequest. +// For each image material, the root index + platform-matched manifest +// bodies are fetched so their descriptor sizes can be summed — layer +// bodies are not fetched. +func MakeSnapshotPlan(ctx context.Context, dockerCli command.Cli, builderName string, req *SnapshotRequest) (SnapshotPlan, error) { + if req == nil { + return nil, errors.New("nil snapshot request") + } + if len(req.Targets) == 0 { + return nil, errors.New("no targets to snapshot") + } + + // Register ref-key prefixes so MakeRefKey in containerd does not log + // warnings while we fetch manifest bodies. + ctx = withMediaTypeKeyPrefix(ctx) + + // Lazily-constructed registry resolver for image materials, shared + // across targets — mirrors the real-run setup. + var registryResolver *imagetools.Resolver + lazyResolver := func() (*imagetools.Resolver, error) { + if registryResolver != nil { + return registryResolver, nil + } + if dockerCli == nil { + registryResolver = imagetools.New(imagetools.Opt{}) + return registryResolver, nil + } + b, err := builder.New(dockerCli, builder.WithName(builderName)) + if err != nil { + return nil, err + } + imgOpt, err := b.ImageOpt() + if err != nil { + return nil, err + } + registryResolver = imagetools.New(imgOpt) + return registryResolver, nil + } + + plan := make(SnapshotPlan, 0, len(req.Targets)) + var pwlog progress.Logger = func(*client.SolveStatus) {} + if req.Progress != nil { + pwlog = req.Progress.Write + } + + for ti, t := range req.Targets { + s, pred := t.Subject, t.Predicate + if s == nil || pred == nil { + return nil, errors.New("target has nil subject or predicate") + } + if s.IsAttestationFile() { + return nil, ErrUnsupportedSubject("snapshot requires an image or oci-layout subject") + } + if s.AttestationManifest().Digest == "" { + return nil, ErrNoProvenance(s.InputRef()) + } + + var mats []PlanMaterial + targetName := fmt.Sprintf("[%d/%d] snapshot %s", ti+1, len(req.Targets), snapshotTargetLabel(s)) + err := progress.Wrap(targetName, pwlog, func(sub progress.SubLogger) error { + var err error + mats, err = planMaterials(ctx, s, pred, req, lazyResolver, sub) + return err + }) + if err != nil { + return nil, err + } + plan = append(plan, SnapshotPlanTarget{ + Subject: s.Descriptor, + Materials: mats, + }) + } + return plan, nil +} + +// planMaterials builds the material list for the dry-run plan. For image +// materials it resolves the root index + picks the platform-matched +// manifest and sums every descriptor's size — layer bytes are not +// fetched. +func planMaterials(ctx context.Context, s *Subject, pred *Predicate, req *SnapshotRequest, lazyResolver func() (*imagetools.Resolver, error), sub progress.SubLogger) ([]PlanMaterial, error) { + builder := pred.BuilderPlatform() + var out []PlanMaterial + for _, m := range pred.ResolvedDependencies() { + entry := materialPlan(m, builder) + kind := classifyMaterial(m) + entry.Included = req.IncludeMaterials && kind != materialKindGit && kind != materialKindUnknown + if kind == materialKindImage { + if req.IncludeMaterials { + var size int64 + err := sub.Wrap(fmt.Sprintf("plan image material %s", entry.URI), func() error { + var err error + size, err = imageMaterialSize(ctx, req.Materials, lazyResolver, m, preferredDigest(m.Digest), s.Descriptor.Platform, pred.BuilderPlatform()) + return err + }) + if err != nil { + return nil, errors.Wrapf(err, "size image material %s", m.URI) + } + entry.Size = size + } + } + out = append(out, entry) + } + return out, nil +} + +// imageMaterialSize fetches only the manifests needed to size an image +// material: root index → platform-matched manifest → its config + layers. +// Returns the sum of every descriptor's declared size. Layer bodies are +// never fetched. +func imageMaterialSize(ctx context.Context, resolver *MaterialsResolver, lazyResolver func() (*imagetools.Resolver, error), m slsa1.ResourceDescriptor, rootDgst digest.Digest, subjectPlat *ocispecs.Platform, builderPlat ocispecs.Platform) (int64, error) { + rootDesc, provider, err := resolveImageMaterial(ctx, resolver, lazyResolver, m, rootDgst, WithPlatform(subjectPlat), WithBuilderPlatform(builderPlat)) + if err != nil { + return 0, err + } + total := rootDesc.Size + + platDesc, err := pickPlatformChild(ctx, provider, rootDesc, subjectPlat, builderPlat) + if err != nil { + return 0, err + } + // pickPlatformChild may return rootDesc unchanged for single-platform + // images; avoid double-counting. + if platDesc.Digest != rootDesc.Digest { + total += platDesc.Size + } + + mfstData, err := content.ReadBlob(ctx, provider, platDesc) + if err != nil { + return 0, errors.Wrapf(err, "read platform manifest %s", platDesc.Digest) + } + var mfst ocispecs.Manifest + if err := json.Unmarshal(mfstData, &mfst); err != nil { + return 0, errors.Wrapf(err, "parse platform manifest %s", platDesc.Digest) + } + total += mfst.Config.Size + for _, l := range mfst.Layers { + total += l.Size + } + return total, nil +} + +// stripImagePurlQualifiers removes the `digest` and `platform` purl +// qualifiers from an image material URI — those values are already +// reported as separate fields on the plan entry so carrying them in the +// URI is pure noise. Falls back to the original URI on parse failure. +func stripImagePurlQualifiers(uri string) string { + p, err := packageurl.FromString(uri) + if err != nil { + return uri + } + kept := p.Qualifiers[:0] + for _, q := range p.Qualifiers { + switch q.Key { + case "digest", "platform": + continue + } + kept = append(kept, q) + } + p.Qualifiers = kept + return p.ToString() +} + +func subjectBuildPlan(s *Subject, pred *Predicate, req *BuildRequest) SubjectBuildPlan { + attrs := pred.FrontendAttrs() + cfgSrc := pred.ConfigSource() + contextPath := cfgSrc.URI + if contextPath == "" { + contextPath = attrs["context"] + } + dockerfilePath := cfgSrc.Path + if dockerfilePath == "" { + dockerfilePath = attrs["filename"] + } + networkMode, _ := networkModeForReplay(req.NetworkMode) + + cfg := BuildPlanConfig{ + Frontend: pred.Frontend(), + FrontendAttrs: frontendAttrSummary(attrs), + Context: contextPath, + Filename: dockerfilePath, + Target: attrs["target"], + BuildArgs: collectPrefixed(attrs, "build-arg:"), + Labels: collectPrefixed(attrs, "label:"), + Secrets: planSecrets(pred.Secrets()), + SSH: sshIDs(pred.SSH()), + NetworkMode: networkMode, + Exports: exportSummaries(req.Exports), + } + if v, ok := attrs["no-cache"]; ok { + if v == "" { + cfg.NoCache = true + } else if fields, err := csvvalue.Fields(v, nil); err == nil { + cfg.NoCacheFilter = fields + } + } + + // Materials summary. + mats := make([]PlanMaterial, 0, len(pred.ResolvedDependencies())) + for _, m := range pred.ResolvedDependencies() { + mats = append(mats, materialPlan(m, pred.BuilderPlatform())) + } + + plan := SubjectBuildPlan{ + Descriptor: s.Descriptor, + Signature: s.Signature(), + BuildConfig: cfg, + Materials: mats, + } + return plan +} + +func frontendAttrSummary(attrs map[string]string) map[string]string { + if len(attrs) == 0 { + return nil + } + out := make(map[string]string, 2) + for _, key := range []string{"source", "cmdline"} { + if v, ok := attrs[key]; ok && v != "" { + out[key] = v + } + } + if len(out) == 0 { + return nil + } + return out +} + +// materialPlan builds the shared dry-run material summary shape. +func materialPlan(m slsa1.ResourceDescriptor, builder ocispecs.Platform) PlanMaterial { + kind := classifyMaterial(m) + pm := PlanMaterial{ + URI: m.URI, + Kind: materialKindString(kind), + } + d := preferredDigest(m.Digest) + if d != "" { + pm.Digest = d.String() + } + if kind == materialKindImage { + if _, p, err := purl.PURLToRef(m.URI); err == nil && p != nil { + pm.Platform = p + } else { + b := builder + pm.Platform = &b + } + pm.URI = stripImagePurlQualifiers(m.URI) + } + return pm +} + +// materialKindString returns the stable JSON kind tag for a material kind. +func materialKindString(k materialKind) string { + switch k { + case materialKindImage: + return "image" + case materialKindContainerBlob: + return "image-blob" + case materialKindHTTP: + return "http" + case materialKindGit: + return "git" + } + return "unknown" +} + +// planSecrets returns the sorted unique declared secrets, preserving whether +// any declaration of the secret marked it optional. +func planSecrets(secrets []*provenancetypes.Secret) []PlanSecret { + seen := map[string]bool{} + for _, s := range secrets { + if s == nil || s.ID == "" { + continue + } + seen[s.ID] = seen[s.ID] || s.Optional + } + ids := make([]string, 0, len(seen)) + for id := range seen { + ids = append(ids, id) + } + sort.Strings(ids) + out := make([]PlanSecret, 0, len(ids)) + for _, id := range ids { + out = append(out, PlanSecret{ID: id, Optional: seen[id]}) + } + return out +} + +// sshIDs returns the sorted unique IDs of declared SSH entries. +func sshIDs(entries []*provenancetypes.SSH) []string { + seen := map[string]struct{}{} + var out []string + for _, s := range entries { + if s == nil || s.ID == "" { + continue + } + if _, ok := seen[s.ID]; ok { + continue + } + seen[s.ID] = struct{}{} + out = append(out, s.ID) + } + sort.Strings(out) + return out +} + +// exportSummaries renders --output specs into a short "type=..." list for +// dry-run JSON output. +func exportSummaries(exports []*buildflags.ExportEntry) []string { + out := make([]string, 0, len(exports)) + for _, e := range exports { + if e == nil { + continue + } + s := "type=" + e.Type + if e.Destination != "" { + s += ",dest=" + e.Destination + } + if name, ok := e.Attrs["name"]; ok && name != "" { + s += ",name=" + name + } + out = append(out, s) + } + return out +} diff --git a/replay/dryrun_test.go b/replay/dryrun_test.go new file mode 100644 index 000000000000..e86db7a8eab9 --- /dev/null +++ b/replay/dryrun_test.go @@ -0,0 +1,255 @@ +package replay + +import ( + "context" + "encoding/json" + "testing" + + "github.com/docker/buildx/util/buildflags" + slsa1 "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v1" + provenancetypes "github.com/moby/buildkit/solver/llbsolver/provenance/types" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/stretchr/testify/require" +) + +func testSubject(t *testing.T) *Subject { + t.Helper() + return &Subject{ + Descriptor: ocispecs.Descriptor{ + MediaType: ocispecs.MediaTypeImageManifest, + Digest: "sha256:aaaa", + Platform: &ocispecs.Platform{OS: "linux", Architecture: "amd64"}, + }, + attestManifest: ocispecs.Descriptor{ + MediaType: ocispecs.MediaTypeImageManifest, + Digest: "sha256:bbbb", + }, + } +} + +func testPredicate(secretSpecs []struct { + id string + optional bool +}, locals []string) *Predicate { + p := &Predicate{} + for _, spec := range secretSpecs { + p.BuildDefinition.ExternalParameters.Request.Secrets = append( + p.BuildDefinition.ExternalParameters.Request.Secrets, + &provenancetypes.Secret{ID: spec.id, Optional: spec.optional}, + ) + } + for _, name := range locals { + p.BuildDefinition.ExternalParameters.Request.Locals = append( + p.BuildDefinition.ExternalParameters.Request.Locals, + &provenancetypes.LocalSource{Name: name}, + ) + } + p.BuildDefinition.ExternalParameters.Request.Frontend = "dockerfile.v0" + p.BuildDefinition.ExternalParameters.Request.Args = map[string]string{ + "context": "https://github.com/example/repo.git", + "source": "docker/dockerfile:1.8", + "cmdline": "docker/dockerfile:1.8", + "target": "default", + "build-arg:EXAMPLE": "1", + "label:org.example.test": "yes", + } + p.BuildDefinition.ExternalParameters.ConfigSource.URI = "https://github.com/example/repo.git" + p.BuildDefinition.ExternalParameters.ConfigSource.Path = "Dockerfile" + p.BuildDefinition.ResolvedDependencies = []slsa1.ResourceDescriptor{ + {URI: "pkg:docker/alpine@3.20", Digest: map[string]string{"sha256": "deadbeef"}}, + {URI: "https://example.com/foo.tar", Digest: map[string]string{"sha256": "feed"}}, + } + // mode=max provenance records the build definition. + p.BuildDefinition.InternalParameters.BuildConfig = &provenancetypes.BuildConfig{ + Definition: []provenancetypes.BuildStep{{ID: "step0"}}, + } + return p +} + +func TestMakeBuildPlanMinModeRejected(t *testing.T) { + pred := testPredicate(nil, nil) + pred.BuildDefinition.InternalParameters.BuildConfig = nil + // mode=min drops recorded secrets, so a user-supplied secret must not be + // reported as an extra secret. + _, err := MakeBuildPlan(&BuildRequest{ + Targets: []Target{{Subject: testSubject(t), Predicate: pred}}, + Secrets: buildflags.Secrets{&buildflags.Secret{ID: "mysecret"}}, + }) + var minMode *MinModeProvenanceError + require.ErrorAs(t, err, &minMode) + require.ErrorContains(t, err, "--provenance=mode=max") +} + +func TestMakeBuildPlanRejectsMultipleSubjects(t *testing.T) { + _, err := MakeBuildPlan(&BuildRequest{Targets: []Target{ + {Subject: testSubject(t), Predicate: testPredicate(nil, nil)}, + {Subject: testSubject(t), Predicate: testPredicate(nil, nil)}, + }}) + var notImplemented *NotImplementedError + require.ErrorAs(t, err, ¬Implemented) + require.ErrorContains(t, err, "--platform") +} + +func TestMakeBuildPlanUnpinnedGitSubdirContext(t *testing.T) { + // BuildKit before v0.25 recorded no digest for a Git subdirectory + // context and recorded the Git material without the subdirectory. + gitSubdirPredicate := func() *Predicate { + pred := testPredicate(nil, nil) + pred.BuildDefinition.ExternalParameters.ConfigSource.URI = "https://github.com/example/repo.git#main:sub" + pred.BuildDefinition.ExternalParameters.Request.Args["context"] = "https://github.com/example/repo.git#main:sub" + pred.BuildDefinition.ResolvedDependencies = append(pred.BuildDefinition.ResolvedDependencies, slsa1.ResourceDescriptor{ + URI: "https://github.com/example/repo.git#main", + Digest: map[string]string{"sha1": "0123456789abcdef0123456789abcdef01234567"}, + }) + return pred + } + + _, err := MakeBuildPlan(&BuildRequest{Targets: []Target{{Subject: testSubject(t), Predicate: gitSubdirPredicate()}}}) + var unpinned *UnpinnedContextError + require.ErrorAs(t, err, &unpinned) + require.Equal(t, "https://github.com/example/repo.git#main:sub", unpinned.URI) + + // Frontend mode does not pin sources, so the context is replayable. + _, err = MakeBuildPlan(&BuildRequest{ + Targets: []Target{{Subject: testSubject(t), Predicate: gitSubdirPredicate()}}, + Mode: BuildModeFrontend, + }) + require.NoError(t, err) + + // A recorded context digest pins the context. + pred := gitSubdirPredicate() + pred.BuildDefinition.ExternalParameters.ConfigSource.Digest = map[string]string{"sha1": "0123456789abcdef0123456789abcdef01234567"} + _, err = MakeBuildPlan(&BuildRequest{Targets: []Target{{Subject: testSubject(t), Predicate: pred}}}) + require.NoError(t, err) + + // A root context is requested with the same identifier as its Git + // material, so a missing context digest is not a problem. + pred = gitSubdirPredicate() + pred.BuildDefinition.ExternalParameters.ConfigSource.URI = "https://github.com/example/repo.git#main" + pred.BuildDefinition.ExternalParameters.Request.Args["context"] = "https://github.com/example/repo.git#main" + _, err = MakeBuildPlan(&BuildRequest{Targets: []Target{{Subject: testSubject(t), Predicate: pred}}}) + require.NoError(t, err) +} + +func TestMakeBuildPlanHappyPath(t *testing.T) { + s := testSubject(t) + pred := testPredicate([]struct { + id string + optional bool + }{ + {id: "required"}, + {id: "optional", optional: true}, + }, nil) + resolver, err := NewMaterialsResolver(nil) + require.NoError(t, err) + + req := &BuildRequest{ + Targets: []Target{{Subject: s, Predicate: pred}}, + Mode: BuildModeMaterials, + Materials: resolver, + Secrets: buildflags.Secrets{{ID: "required"}}, + } + plan, err := MakeBuildPlan(req) + require.NoError(t, err) + require.Len(t, plan.Subjects, 1) + require.Equal(t, s.Descriptor.Digest, plan.Subjects[0].Descriptor.Digest) + require.Len(t, plan.Subjects[0].Materials, 2) + require.Equal(t, "https://github.com/example/repo.git", plan.Subjects[0].BuildConfig.Context) + require.Equal(t, "Dockerfile", plan.Subjects[0].BuildConfig.Filename) + require.Equal(t, map[string]string{ + "cmdline": "docker/dockerfile:1.8", + "source": "docker/dockerfile:1.8", + }, plan.Subjects[0].BuildConfig.FrontendAttrs) + require.Equal(t, []PlanSecret{ + {ID: "optional", Optional: true}, + {ID: "required"}, + }, plan.Subjects[0].BuildConfig.Secrets) + // First material is image-kind. + require.Equal(t, "image", plan.Subjects[0].Materials[0].Kind) + // Second material is http. + require.Equal(t, "http", plan.Subjects[0].Materials[1].Kind) + // JSON shape is stable. + dt, err := json.Marshal(plan) + require.NoError(t, err) + require.NotContains(t, string(dt), `"inputRef":`) + require.NotContains(t, string(dt), `"platform":"linux/amd64"`) + require.NotContains(t, string(dt), `"predicateType":`) + require.NotContains(t, string(dt), `"pins":`) + require.NotContains(t, string(dt), `"replayMode":`) + require.NotContains(t, string(dt), `"warnings":`) + require.NotContains(t, string(dt), `"build-arg:`) + require.NotContains(t, string(dt), `"label:`) +} + +func TestMakeBuildPlanLocalContextRejected(t *testing.T) { + s := testSubject(t) + pred := testPredicate(nil, []string{"ctx"}) + req := &BuildRequest{ + Targets: []Target{{Subject: s, Predicate: pred}}, + } + _, err := MakeBuildPlan(req) + require.Error(t, err) + var ulc *UnreplayableLocalContextError + require.ErrorAs(t, err, &ulc) +} + +func TestMakeBuildPlanExtraSecretRejected(t *testing.T) { + s := testSubject(t) + pred := testPredicate(nil, nil) + req := &BuildRequest{ + Targets: []Target{{Subject: s, Predicate: pred}}, + Secrets: buildflags.Secrets{{ID: "rogue"}}, + } + _, err := MakeBuildPlan(req) + require.Error(t, err) + var es *ExtraSecretError + require.ErrorAs(t, err, &es) + require.Equal(t, []string{"rogue"}, es.IDs) +} + +func TestMakeBuildPlanMissingRecordedContextRejected(t *testing.T) { + s := testSubject(t) + pred := testPredicate(nil, nil) + delete(pred.BuildDefinition.ExternalParameters.Request.Args, "context") + pred.BuildDefinition.ExternalParameters.ConfigSource.URI = "" + req := &BuildRequest{ + Targets: []Target{{Subject: s, Predicate: pred}}, + } + _, err := MakeBuildPlan(req) + require.EqualError(t, err, "predicate has no recorded build context; replay requires a remote-source build (git / https)") +} + +func TestMakeSnapshotPlanHappyPath(t *testing.T) { + fx := makeSnapshotFixture(t) + + req := &SnapshotRequest{ + Targets: []Target{{Subject: fx.subject, Predicate: fx.predicate}}, + IncludeMaterials: true, + Materials: snapshotOverrideResolver(t, fx.httpURI, fx.httpBytes), + } + plan, err := MakeSnapshotPlan(context.Background(), nil, "", req) + require.NoError(t, err) + require.Len(t, plan, 1) + require.Equal(t, fx.subject.Descriptor.Digest, plan[0].Subject.Digest) + require.NotEmpty(t, plan[0].Materials) + // Fixture has an http material only; non-image entries must not + // carry a manifest-derived size. + for _, m := range plan[0].Materials { + if m.Kind != "image" { + require.Zero(t, m.Size, "non-image materials must not report a size") + } + } +} + +func TestMakeSnapshotPlanAttestationFileRejected(t *testing.T) { + s := testSubject(t) + s.kind = subjectKindAttestationFile + pred := testPredicate(nil, nil) + req := &SnapshotRequest{ + Targets: []Target{{Subject: s, Predicate: pred}}, + } + _, err := MakeSnapshotPlan(context.Background(), nil, "", req) + require.Error(t, err) + var us *UnsupportedSubjectError + require.ErrorAs(t, err, &us) +} diff --git a/replay/errors.go b/replay/errors.go new file mode 100644 index 000000000000..2b4cd118688e --- /dev/null +++ b/replay/errors.go @@ -0,0 +1,258 @@ +package replay + +import ( + "fmt" + "sort" + "strings" + + "github.com/pkg/errors" +) + +// UnreplayableLocalContextError signals that the original build used a local +// filesystem context which replay cannot reproduce. +type UnreplayableLocalContextError struct { + LocalSources []string +} + +func (e *UnreplayableLocalContextError) Error() string { + const hint = "only builds from a Git repository or HTTP(S) URL context can be replayed" + if len(e.LocalSources) == 0 { + return "image was built from local files; " + hint + } + return fmt.Sprintf("image was built from local files (%s); %s", strings.Join(e.LocalSources, ", "), hint) +} + +// ErrUnreplayableLocalContext constructs an UnreplayableLocalContextError. +func ErrUnreplayableLocalContext(sources []string) error { + sorted := append([]string(nil), sources...) + sort.Strings(sorted) + return errors.WithStack(&UnreplayableLocalContextError{LocalSources: sorted}) +} + +// MissingSecretError is returned when provenance declares required secrets +// that the user did not provide. +type MissingSecretError struct { + IDs []string +} + +func (e *MissingSecretError) Error() string { + return fmt.Sprintf("missing required secrets: %s (pass them with --secret id=,src=)", strings.Join(e.IDs, ", ")) +} + +// ErrMissingSecret constructs a MissingSecretError. +func ErrMissingSecret(ids []string) error { + sorted := append([]string(nil), ids...) + sort.Strings(sorted) + return errors.WithStack(&MissingSecretError{IDs: sorted}) +} + +// ExtraSecretError is returned when the user supplies secrets that the +// provenance does not declare. +type ExtraSecretError struct { + IDs []string +} + +func (e *ExtraSecretError) Error() string { + return fmt.Sprintf("extra secrets not declared in provenance: %s", strings.Join(e.IDs, ", ")) +} + +// ErrExtraSecret constructs an ExtraSecretError. +func ErrExtraSecret(ids []string) error { + sorted := append([]string(nil), ids...) + sort.Strings(sorted) + return errors.WithStack(&ExtraSecretError{IDs: sorted}) +} + +// MissingSSHError is returned when provenance declares required SSH agents +// that the user did not provide. +type MissingSSHError struct { + IDs []string +} + +func (e *MissingSSHError) Error() string { + return fmt.Sprintf("missing required ssh entries: %s (pass them with --ssh =)", strings.Join(e.IDs, ", ")) +} + +// ErrMissingSSH constructs a MissingSSHError. +func ErrMissingSSH(ids []string) error { + sorted := append([]string(nil), ids...) + sort.Strings(sorted) + return errors.WithStack(&MissingSSHError{IDs: sorted}) +} + +// ExtraSSHError is returned when the user supplies SSH agents that the +// provenance does not declare. +type ExtraSSHError struct { + IDs []string +} + +func (e *ExtraSSHError) Error() string { + return fmt.Sprintf("extra ssh entries not declared in provenance: %s", strings.Join(e.IDs, ", ")) +} + +// ErrExtraSSH constructs an ExtraSSHError. +func ErrExtraSSH(ids []string) error { + sorted := append([]string(nil), ids...) + sort.Strings(sorted) + return errors.WithStack(&ExtraSSHError{IDs: sorted}) +} + +// MaterialNotFoundError indicates a provenance material that the resolver +// could not locate in any configured store. +type MaterialNotFoundError struct { + URI string + Digest string +} + +func (e *MaterialNotFoundError) Error() string { + return fmt.Sprintf("material not found: uri=%q digest=%q", e.URI, e.Digest) +} + +// ErrMaterialNotFound constructs a MaterialNotFoundError. +func ErrMaterialNotFound(uri, dgst string) error { + return errors.WithStack(&MaterialNotFoundError{URI: uri, Digest: dgst}) +} + +// CompareMismatchError is returned by `replay verify` when the replayed +// artifact does not match the subject. The wrapped Report may be nil when no +// structured diff is available (digest comparison). +type CompareMismatchError struct { + // Report is typed as any so callers can surface either the basic compare + // tree or a future richer report format without breaking the error type. + Report any + Reason string +} + +func (e *CompareMismatchError) Error() string { + if e.Reason != "" { + return fmt.Sprintf("replay mismatch: %s", e.Reason) + } + return "replay mismatch" +} + +// ErrCompareMismatch constructs a CompareMismatchError. +func ErrCompareMismatch(reason string, report any) error { + return errors.WithStack(&CompareMismatchError{Reason: reason, Report: report}) +} + +// NotImplementedError marks a feature that is not yet implemented. +type NotImplementedError struct { + Feature string +} + +func (e *NotImplementedError) Error() string { + return fmt.Sprintf("not implemented: %s", e.Feature) +} + +// ErrNotImplemented constructs a NotImplementedError. +func ErrNotImplemented(feature string) error { + return errors.WithStack(&NotImplementedError{Feature: feature}) +} + +// UnsupportedSubjectError signals that the supplied subject kind is not +// compatible with the invoked subcommand. +type UnsupportedSubjectError struct { + Kind string +} + +func (e *UnsupportedSubjectError) Error() string { + return fmt.Sprintf("unsupported subject: %s", e.Kind) +} + +// ErrUnsupportedSubject constructs an UnsupportedSubjectError. +func ErrUnsupportedSubject(kind string) error { + return errors.WithStack(&UnsupportedSubjectError{Kind: kind}) +} + +// NoProvenanceError is returned when no SLSA provenance attestation could be +// found for a subject. +type NoProvenanceError struct { + Subject string +} + +func (e *NoProvenanceError) Error() string { + const hint = "build the image with --provenance=mode=max to make it replayable" + if e.Subject == "" { + return "no SLSA provenance attestation found; " + hint + } + return fmt.Sprintf("no SLSA provenance attestation found for %s; %s", e.Subject, hint) +} + +// ErrNoProvenance constructs a NoProvenanceError. +func ErrNoProvenance(subject string) error { + return errors.WithStack(&NoProvenanceError{Subject: subject}) +} + +// UnsupportedPredicateError signals that the attached predicate is not SLSA +// provenance. +type UnsupportedPredicateError struct { + PredicateType string +} + +func (e *UnsupportedPredicateError) Error() string { + return fmt.Sprintf("unsupported predicate type %q; replay requires SLSA provenance (v0.2 or v1)", e.PredicateType) +} + +// ErrUnsupportedPredicate constructs an UnsupportedPredicateError. +func ErrUnsupportedPredicate(predicateType string) error { + return errors.WithStack(&UnsupportedPredicateError{PredicateType: predicateType}) +} + +// SignatureVerificationRequiredError is returned when a signed envelope +// cannot be verified from the available trust material. Replay never silently +// unwraps such an attestation. +type SignatureVerificationRequiredError struct { + // Source is the user-visible input that carries the signed envelope + // (file path for attestation-file inputs). + Source string + // Envelope describes the detected envelope shape ("dsse" or + // "sigstore-bundle") so the user can tell what was rejected. + Envelope string +} + +func (e *SignatureVerificationRequiredError) Error() string { + src := e.Source + if src == "" { + src = "attestation" + } + env := e.Envelope + if env == "" { + env = "signed envelope" + } + return fmt.Sprintf("%s for %s carries signatures but cannot be verified from the available trust material; refusing to accept unverified signed attestation", env, src) +} + +// ErrSignatureVerificationRequired constructs a +// SignatureVerificationRequiredError. +func ErrSignatureVerificationRequired(source, envelope string) error { + return errors.WithStack(&SignatureVerificationRequiredError{Source: source, Envelope: envelope}) +} + +// MinModeProvenanceError is returned when the provenance was recorded with +// mode=min, which omits the build arguments, secrets and SSH needed to +// reconstruct the build. +type MinModeProvenanceError struct{} + +func (e *MinModeProvenanceError) Error() string { + return "provenance was recorded with mode=min, which omits build arguments, secrets and SSH needed for replay; build the image with --provenance=mode=max to make it replayable" +} + +// ErrMinModeProvenance constructs a MinModeProvenanceError. +func ErrMinModeProvenance() error { + return errors.WithStack(&MinModeProvenanceError{}) +} + +// UnpinnedContextError is returned when the provenance does not record a +// digest for a Git subdirectory build context, so replay cannot pin it. +type UnpinnedContextError struct { + URI string +} + +func (e *UnpinnedContextError) Error() string { + return fmt.Sprintf("cannot pin the Git build context %s: provenance from BuildKit before v0.25 does not record a digest for subdirectory contexts; use --replay-mode=frontend to replay without pinning sources", e.URI) +} + +// ErrUnpinnedContext constructs an UnpinnedContextError. +func ErrUnpinnedContext(uri string) error { + return errors.WithStack(&UnpinnedContextError{URI: uri}) +} diff --git a/replay/errors_test.go b/replay/errors_test.go new file mode 100644 index 000000000000..a68d03327f4c --- /dev/null +++ b/replay/errors_test.go @@ -0,0 +1,89 @@ +package replay + +import ( + "testing" + + "github.com/pkg/errors" + "github.com/stretchr/testify/require" +) + +// TestErrorTypes asserts that each replay constructor returns the typed error +// expected by errors.As-based consumers. +func TestErrorTypes(t *testing.T) { + tests := []struct { + name string + err error + want any + }{ + {"local context", ErrUnreplayableLocalContext([]string{"default"}), &UnreplayableLocalContextError{}}, + {"missing secret", ErrMissingSecret([]string{"mysecret"}), &MissingSecretError{}}, + {"extra secret", ErrExtraSecret([]string{"mysecret"}), &ExtraSecretError{}}, + {"missing ssh", ErrMissingSSH([]string{"default"}), &MissingSSHError{}}, + {"extra ssh", ErrExtraSSH([]string{"default"}), &ExtraSSHError{}}, + {"material not found", ErrMaterialNotFound("docker-image://foo", "sha256:aa"), &MaterialNotFoundError{}}, + {"compare mismatch", ErrCompareMismatch("digest differs", nil), &CompareMismatchError{}}, + {"not implemented", ErrNotImplemented("registry snapshot output"), &NotImplementedError{}}, + {"unsupported subject", ErrUnsupportedSubject("attestation-file"), &UnsupportedSubjectError{}}, + {"no provenance", ErrNoProvenance("foo:latest"), &NoProvenanceError{}}, + {"unsupported predicate", ErrUnsupportedPredicate("https://slsa.dev/provenance/v0.2"), &UnsupportedPredicateError{}}, + {"min mode provenance", ErrMinModeProvenance(), &MinModeProvenanceError{}}, + {"unpinned context", ErrUnpinnedContext("https://example.com/repo.git#main:sub"), &UnpinnedContextError{}}, + {"signature verification required", ErrSignatureVerificationRequired("./att.json", "dsse"), &SignatureVerificationRequiredError{}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Error(t, tt.err) + switch want := tt.want.(type) { + case *UnreplayableLocalContextError: + require.ErrorAs(t, tt.err, &want) + case *MissingSecretError: + require.ErrorAs(t, tt.err, &want) + case *ExtraSecretError: + require.ErrorAs(t, tt.err, &want) + case *MissingSSHError: + require.ErrorAs(t, tt.err, &want) + case *ExtraSSHError: + require.ErrorAs(t, tt.err, &want) + case *MaterialNotFoundError: + require.ErrorAs(t, tt.err, &want) + case *CompareMismatchError: + require.ErrorAs(t, tt.err, &want) + case *NotImplementedError: + require.ErrorAs(t, tt.err, &want) + case *UnsupportedSubjectError: + require.ErrorAs(t, tt.err, &want) + case *NoProvenanceError: + require.ErrorAs(t, tt.err, &want) + case *UnsupportedPredicateError: + require.ErrorAs(t, tt.err, &want) + case *MinModeProvenanceError: + require.ErrorAs(t, tt.err, &want) + case *UnpinnedContextError: + require.ErrorAs(t, tt.err, &want) + case *SignatureVerificationRequiredError: + require.ErrorAs(t, tt.err, &want) + default: + t.Fatalf("unexpected want type %T", tt.want) + } + }) + } +} + +// TestErrorsAsWrapped asserts that pkg/errors stack-wrapped and fmt-wrapped +// replay errors remain errors.As-matchable. +func TestErrorsAsWrapped(t *testing.T) { + e := ErrMaterialNotFound("foo", "sha256:a") + wrapped := errors.Wrap(e, "resolver failed") + var mnf *MaterialNotFoundError + require.ErrorAs(t, wrapped, &mnf) + require.Equal(t, "foo", mnf.URI) +} + +// TestSignatureVerificationRequiredMessage asserts the error message carries +// both the source and envelope kind so users know what was rejected. +func TestSignatureVerificationRequiredMessage(t *testing.T) { + e := ErrSignatureVerificationRequired("/tmp/att.json", "dsse") + require.Contains(t, e.Error(), "/tmp/att.json") + require.Contains(t, e.Error(), "dsse") +} diff --git a/replay/materials.go b/replay/materials.go new file mode 100644 index 000000000000..cc1a5e2de678 --- /dev/null +++ b/replay/materials.go @@ -0,0 +1,645 @@ +package replay + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + + "github.com/containerd/containerd/v2/core/content" + contentlocal "github.com/containerd/containerd/v2/plugins/content/local" + "github.com/containerd/platforms" + "github.com/docker/buildx/util/ocilayout" + "github.com/moby/buildkit/client/ociindex" + "github.com/moby/buildkit/util/contentutil" + digest "github.com/opencontainers/go-digest" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/pkg/errors" +) + +// MaterialsResolver resolves provenance materials to a (descriptor, provider) +// pair the replay pipeline can use to serve content locally. Lookup order: +// +// 1. overrides keyed by URI or "sha256:" +// 2. explicit stores, in the order listed on `--materials` +// 3. the `provenance` sentinel (fetch from the URI recorded in provenance) +// +// Explicit stores and overrides are only consumed by `replay snapshot`; +// `replay build` and `replay verify` support the provenance sentinel only. +type MaterialsResolver struct { + overrides map[string]materialOverride + stores []materialStore + sentinel bool // true when `provenance` sentinel is enabled +} + +type materialOverride struct { + // spec is the raw right-hand side of a `key=value` override. Resolved + // lazily so a bad override doesn't poison the whole resolver build. + spec string +} + +// materialStore is an entry on the --materials list that is NOT an override. +// Exactly one of path/ociLayout is populated. +type materialStore struct { + // path is a filesystem directory laid out as a raw content store + // (blobs//). + path string + // ociLayout is an absolute path to an OCI image layout directory. + // Lookup is by descriptor digest only. + ociLayout string +} + +// NewMaterialsResolver parses the --materials list and returns a resolver. +// Spec forms accepted: +// +// - "provenance" — sentinel (default when the +// --materials list is empty) +// - "oci-layout://[:]" — OCI layout store +// - "" — raw content store +// (blobs//) +// - "=" — override: is the URI or +// "sha256:"; is any of +// the above narrowed to one blob. +func NewMaterialsResolver(specs []string) (*MaterialsResolver, error) { + r := &MaterialsResolver{ + overrides: map[string]materialOverride{}, + } + + // Default behavior when the user supplied no stores: use the provenance + // sentinel (fetch from the URIs recorded on each material). + if len(specs) == 0 { + r.sentinel = true + return r, nil + } + + for _, raw := range specs { + spec := strings.TrimSpace(raw) + if spec == "" { + continue + } + + // "=" override. The key is a URI that may itself contain + // '=' (purl qualifiers, query strings), so splitOverride looks for + // the '=' that is followed by a valid store. + if isOverrideSpec(spec) { + key, val, ok := splitOverride(spec) + if !ok { + return nil, errors.Errorf("malformed --materials override %q (want =)", spec) + } + r.overrides[key] = materialOverride{spec: val} + continue + } + + switch { + case spec == "provenance": + r.sentinel = true + case strings.HasPrefix(spec, "oci-layout://"): + ref, _, err := ocilayout.Parse(spec) + if err != nil { + return nil, errors.Wrapf(err, "invalid --materials oci-layout spec %q", spec) + } + r.stores = append(r.stores, materialStore{ociLayout: ref.Path}) + case filepath.IsAbs(spec): + if fi, err := os.Stat(spec); err != nil || !fi.IsDir() { + return nil, errors.Errorf("--materials path %q is not a directory", spec) + } + r.stores = append(r.stores, materialStore{path: spec}) + default: + return nil, errors.Errorf("unrecognized --materials spec %q", spec) + } + } + + return r, nil +} + +// isOverrideSpec decides whether a raw --materials token is shaped like +// "=". A token that starts with a known store sentinel/prefix or +// is an absolute path is never an override, even if it happens to contain an +// '=' somewhere inside (e.g. "oci-layout:///path/to/layout:tag=foo"). +func isOverrideSpec(spec string) bool { + switch { + case spec == "provenance": + return false + case strings.HasPrefix(spec, "oci-layout://"): + return false + case filepath.IsAbs(spec): + return false + } + return strings.Contains(spec, "=") +} + +func splitOverride(spec string) (key, val string, ok bool) { + for offset := 0; offset < len(spec); { + rel := strings.IndexByte(spec[offset:], '=') + if rel < 0 { + break + } + i := offset + rel + if i > 0 && i < len(spec)-1 && isOverrideValue(spec[i+1:]) { + return spec[:i], spec[i+1:], true + } + offset = i + 1 + } + return "", "", false +} + +func isOverrideValue(spec string) bool { + return strings.HasPrefix(spec, "oci-layout://") || filepath.IsAbs(spec) +} + +// Sentinel reports whether the `provenance` sentinel is enabled. The sentinel +// authorises a fallback fetch from the URI recorded in the provenance. +func (r *MaterialsResolver) Sentinel() bool { + return r != nil && r.sentinel +} + +// Overrides returns an iteration-stable copy of the configured overrides for +// tests and dry-run inspection. +func (r *MaterialsResolver) Overrides() map[string]string { + if r == nil { + return nil + } + out := make(map[string]string, len(r.overrides)) + for k, v := range r.overrides { + out[k] = v.spec + } + return out +} + +// HasStores reports whether any explicit stores are configured. The primary +// use is driving behavior when the resolver has only a sentinel. +func (r *MaterialsResolver) HasStores() bool { + return r != nil && len(r.stores) > 0 +} + +// HasExplicitSources reports whether replay would need to inject locally +// resolved material content into a solve. The provenance sentinel alone does +// not require injection because BuildKit fetches those sources normally. +func (r *MaterialsResolver) HasExplicitSources() bool { + return r != nil && (len(r.stores) > 0 || len(r.overrides) > 0) +} + +// ResolveOption customises a Resolve call. +type ResolveOption func(*resolveOptions) + +type resolveOptions struct { + // platform is used when resolving image materials out of a + // snapshot-backed store: the materials-manifest carries the original + // root-index blob, and the snapshot index's manifests[] holds the + // per-platform children. The caller supplies the platform it is + // replaying. + platform *ocispecs.Platform + // builderPlatform is the platform the original builder ran on + // (predicate InternalParameters.builderPlatform). Used as the + // fall-back when no child matches the subject platform — frontend + // images and cross-compile toolchains run on the builder host, not + // the target. + builderPlatform *ocispecs.Platform +} + +// WithPlatform attaches a target platform to a Resolve call. When resolving +// an image material against a snapshot-backed store this selects the +// per-platform child to return. +func WithPlatform(p *ocispecs.Platform) ResolveOption { + return func(o *resolveOptions) { o.platform = p } +} + +// WithBuilderPlatform attaches the original builder's platform (recorded in +// provenance) so the snapshot-backed store can fall back to it when the +// subject platform has no match in an image material's root index. +func WithBuilderPlatform(p ocispecs.Platform) ResolveOption { + return func(o *resolveOptions) { o.builderPlatform = &p } +} + +// Resolve returns the descriptor and content.Provider that serve the material +// with the given (uri, dgst). Exactly one of uri / dgst may be empty; when +// both are empty an error is returned. +// +// Strict by default: materials not covered by the configured stores / overrides +// and not reachable via the sentinel produce MaterialNotFoundError. The +// provenance sentinel is NOT a network fetch — it signals that +// BuildKit may resolve the material itself, subject to the source-policy +// pin callback. A caller that requires a concrete (descriptor, provider) +// pair for a sentinel-only material should use the store-backed +// resolution path. +// +// Snapshot-backed stores are detected at lookup time. When `dgst` matches a +// snapshot's materials-manifest layer AND the layer is an image-material root +// manifest/index stashed as opaque bytes by `replay snapshot`, +// Resolve returns the platform-specific child manifest descriptor reachable +// through the snapshot index's `manifests[]`. The caller should pass +// WithPlatform so the correct child can be picked. +func (r *MaterialsResolver) Resolve(ctx context.Context, uri string, dgst digest.Digest, opts ...ResolveOption) (ocispecs.Descriptor, content.Provider, error) { + if r == nil { + return ocispecs.Descriptor{}, nil, ErrMaterialNotFound(uri, dgst.String()) + } + if uri == "" && dgst == "" { + return ocispecs.Descriptor{}, nil, errors.New("resolve called with empty uri and empty digest") + } + + var ro resolveOptions + for _, opt := range opts { + opt(&ro) + } + + // 1. Overrides. + if key, o, ok := r.lookupOverride(uri, dgst); ok { + desc, provider, err := r.resolveOverride(ctx, o, dgst, &ro) + if err != nil { + return ocispecs.Descriptor{}, nil, errors.Wrapf(err, "override %s", key) + } + return desc, provider, nil + } + + // 2. Explicit stores, in order. Snapshot-backed lookup is preferred + // when a store root carries a snapshot index at its root. + for _, s := range r.stores { + if dgst == "" { + continue + } + if desc, provider, ok, err := s.lookupSnapshot(ctx, dgst, &ro); err != nil { + return ocispecs.Descriptor{}, nil, err + } else if ok { + return desc, provider, nil + } + desc, provider, ok, err := s.lookupByDigest(ctx, dgst) + if err != nil { + return ocispecs.Descriptor{}, nil, err + } + if ok { + return desc, provider, nil + } + } + + // 3. Sentinel fallback. Replay relies on BuildKit fetching the material + // over the network subject to the policy callback; we cannot + // materialise the content locally without going online, so we + // surface a sentinel-only descriptor (empty provider) that the + // caller may use to signal "let BuildKit resolve". + if r.sentinel { + return ocispecs.Descriptor{Digest: dgst}, nil, nil + } + + return ocispecs.Descriptor{}, nil, ErrMaterialNotFound(uri, dgst.String()) +} + +func (r *MaterialsResolver) lookupOverride(uri string, dgst digest.Digest) (string, materialOverride, bool) { + if uri != "" { + if o, ok := r.overrides[uri]; ok { + return uri, o, true + } + } + if dgst != "" { + if o, ok := r.overrides[dgst.String()]; ok { + return dgst.String(), o, true + } + } + return "", materialOverride{}, false +} + +// resolveOverride resolves an override value to a concrete (descriptor, +// provider) pair. Override values accept the same forms as non-override +// specs: "oci-layout://[:]", "". +func (r *MaterialsResolver) resolveOverride(ctx context.Context, o materialOverride, dgst digest.Digest, ro *resolveOptions) (ocispecs.Descriptor, content.Provider, error) { + spec := strings.TrimSpace(o.spec) + switch { + case strings.HasPrefix(spec, "oci-layout://"): + ref, _, err := ocilayout.Parse(spec) + if err != nil { + return ocispecs.Descriptor{}, nil, err + } + store := materialStore{ociLayout: ref.Path} + if dgst == "" { + return ocispecs.Descriptor{}, nil, errors.New("override oci-layout requires a material digest") + } + if desc, provider, ok, err := store.lookupSnapshot(ctx, dgst, ro); err != nil { + return ocispecs.Descriptor{}, nil, err + } else if ok { + return desc, provider, nil + } + desc, provider, ok, err := store.lookupByDigest(ctx, dgst) + if err != nil { + return ocispecs.Descriptor{}, nil, err + } + if !ok { + return ocispecs.Descriptor{}, nil, ErrMaterialNotFound("", dgst.String()) + } + return desc, provider, nil + case filepath.IsAbs(spec): + fi, err := os.Stat(spec) + if err != nil { + return ocispecs.Descriptor{}, nil, errors.WithStack(err) + } + if fi.IsDir() { + store := materialStore{path: spec} + if dgst == "" { + return ocispecs.Descriptor{}, nil, errors.New("override path requires a material digest") + } + if desc, provider, ok, err := store.lookupSnapshot(ctx, dgst, ro); err != nil { + return ocispecs.Descriptor{}, nil, err + } else if ok { + return desc, provider, nil + } + desc, provider, ok, err := store.lookupByDigest(ctx, dgst) + if err != nil { + return ocispecs.Descriptor{}, nil, err + } + if !ok { + return ocispecs.Descriptor{}, nil, ErrMaterialNotFound("", dgst.String()) + } + return desc, provider, nil + } + // A file override addresses exactly one blob. We expose it as a + // synthetic provider rooted at the file's bytes. + dt, err := os.ReadFile(spec) + if err != nil { + return ocispecs.Descriptor{}, nil, errors.WithStack(err) + } + actual := digest.FromBytes(dt) + if dgst != "" && actual != dgst { + return ocispecs.Descriptor{}, nil, errors.Errorf("override file %s has digest %s, want %s", spec, actual, dgst) + } + desc := ocispecs.Descriptor{Digest: actual, Size: int64(len(dt))} + buf := contentutil.NewBuffer() + if err := content.WriteBlob(ctx, buf, actual.String(), bytes.NewReader(dt), desc); err != nil { + return ocispecs.Descriptor{}, nil, errors.WithStack(err) + } + return desc, buf, nil + } + return ocispecs.Descriptor{}, nil, errors.Errorf("unsupported override value %q", spec) +} + +// lookupByDigest serves a blob by digest from a filesystem or oci-layout +// store. The returned descriptor has Digest+Size populated; MediaType is +// left empty — callers that need it must inspect the bytes. +func (s materialStore) lookupByDigest(ctx context.Context, dgst digest.Digest) (ocispecs.Descriptor, content.Provider, bool, error) { + if dgst == "" { + return ocispecs.Descriptor{}, nil, false, nil + } + root := s.path + if root == "" { + root = s.ociLayout + } + if root == "" { + return ocispecs.Descriptor{}, nil, false, nil + } + blobPath := filepath.Join(root, "blobs", dgst.Algorithm().String(), dgst.Encoded()) + fi, err := os.Stat(blobPath) + if err != nil { + if os.IsNotExist(err) { + return ocispecs.Descriptor{}, nil, false, nil + } + return ocispecs.Descriptor{}, nil, false, errors.WithStack(err) + } + if fi.IsDir() { + return ocispecs.Descriptor{}, nil, false, errors.Errorf("blob path %s is a directory", blobPath) + } + desc := ocispecs.Descriptor{Digest: dgst, Size: fi.Size()} + + // An OCI-layout store is expected to be a real containerd content + // store; use contentlocal.NewStore so Readers are proper. + provider, err := contentlocal.NewStore(root) + if err != nil { + return ocispecs.Descriptor{}, nil, false, errors.Wrapf(err, "store at %s", root) + } + // Ensure the blob is actually readable (guards against partial layouts). + ra, err := provider.ReaderAt(ctx, desc) + if err != nil { + return ocispecs.Descriptor{}, nil, false, errors.WithStack(err) + } + _ = ra.Close() + return desc, provider, true, nil +} + +// lookupSnapshot attempts to serve `dgst` through a snapshot-backed view of +// the store. The store's root is inspected once per call; +// if it carries a snapshot index (artifactType = ArtifactTypeSnapshot) the +// lookup proceeds by: +// +// 1. Scanning every per-platform snapshot index's materials-manifest +// layers for a layer whose digest matches `dgst`. When found, the +// layer descriptor and a filesystem-backed provider are returned. If +// the matched layer's media type is an OCI image index (i.e. the +// original image-material root index kept opaque in the snapshot), +// the function parses the root index, selects the platform child +// matching ro.platform, and returns that child's descriptor as +// reachable from the per-platform snapshot index's manifests[]. +// 2. Scanning the per-platform snapshot index's manifests[] directly so +// a caller that already has the platform manifest's digest can +// resolve it without going through the root. +// +// Returns ok == false when the store is not snapshot-shaped or when the +// digest is not covered. In both cases the caller falls back to a plain +// digest lookup. +func (s materialStore) lookupSnapshot(ctx context.Context, dgst digest.Digest, ro *resolveOptions) (ocispecs.Descriptor, content.Provider, bool, error) { + if s.ociLayout == "" { + return ocispecs.Descriptor{}, nil, false, nil + } + root, roots, err := readSnapshotRoots(s.ociLayout) + if err != nil { + // A store that just isn't a snapshot: fall back to plain lookup. + return ocispecs.Descriptor{}, nil, false, nil + } + if root.ArtifactType != ArtifactTypeSnapshot && !anyIsSnapshot(roots) { + return ocispecs.Descriptor{}, nil, false, nil + } + + store, err := contentlocal.NewStore(s.ociLayout) + if err != nil { + return ocispecs.Descriptor{}, nil, false, errors.Wrapf(err, "store at %s", s.ociLayout) + } + + perPlatformDescs, err := collectPerPlatformSnapshotIndexes(ctx, store, root, roots) + if err != nil { + return ocispecs.Descriptor{}, nil, false, err + } + + var ( + wantPlat *ocispecs.Platform + builderPlat = platforms.DefaultSpec() + ) + if ro != nil { + wantPlat = ro.platform + if ro.builderPlatform != nil { + builderPlat = *ro.builderPlatform + } + } + + for _, ppDesc := range perPlatformDescs { + ppDt, err := content.ReadBlob(ctx, store, ppDesc) + if err != nil { + return ocispecs.Descriptor{}, nil, false, errors.WithStack(err) + } + var pp ocispecs.Index + if err := json.Unmarshal(ppDt, &pp); err != nil { + return ocispecs.Descriptor{}, nil, false, errors.WithStack(err) + } + + // Load the materials manifest (first manifest with artifactType + // ArtifactTypeMaterials — may be absent when the snapshot was + // created with --include-materials=false). + var materialsLayers []ocispecs.Descriptor + for _, m := range pp.Manifests { + if m.ArtifactType != ArtifactTypeMaterials { + continue + } + mData, err := content.ReadBlob(ctx, store, m) + if err != nil { + return ocispecs.Descriptor{}, nil, false, errors.WithStack(err) + } + var mm ocispecs.Manifest + if err := json.Unmarshal(mData, &mm); err != nil { + return ocispecs.Descriptor{}, nil, false, errors.WithStack(err) + } + materialsLayers = mm.Layers + break + } + + // 1a. Direct hit on a materials-manifest layer. + for _, l := range materialsLayers { + if l.Digest != dgst { + continue + } + if isIndexMediaType(l.MediaType) { + // Image material root — pick the platform-specific child + // from the per-platform index's manifests[]. + child, err := pickPerPlatformChild(ctx, store, l, pp, wantPlat, builderPlat) + if err != nil { + return ocispecs.Descriptor{}, nil, false, err + } + return child, store, true, nil + } + return l, store, true, nil + } + + // 1b. Direct hit on a manifests[] entry (a platform-specific image + // manifest descriptor that the caller already looked up). + for _, m := range pp.Manifests { + if m.Digest == dgst { + return m, store, true, nil + } + } + } + + return ocispecs.Descriptor{}, nil, false, nil +} + +// readSnapshotRoots loads the root manifest references from the store's +// index.json. Returns the single-descriptor "root" when only one is present +// (the per-platform case) plus the full list for multi-platform snapshots. +func readSnapshotRoots(path string) (ocispecs.Descriptor, []ocispecs.Descriptor, error) { + idx, err := ociindex.NewStoreIndex(path).Read() + if err != nil { + return ocispecs.Descriptor{}, nil, err + } + if len(idx.Manifests) == 0 { + return ocispecs.Descriptor{}, nil, errors.New("empty index") + } + return idx.Manifests[0], idx.Manifests, nil +} + +// anyIsSnapshot reports whether any descriptor in roots carries the snapshot +// artifact type. A top-level multi-platform snapshot's descriptor may itself +// carry artifactType = ArtifactTypeSnapshot; so will each per-platform child. +func anyIsSnapshot(roots []ocispecs.Descriptor) bool { + for _, r := range roots { + if r.ArtifactType == ArtifactTypeSnapshot { + return true + } + } + return false +} + +// collectPerPlatformSnapshotIndexes traverses the supplied roots and returns +// every per-platform snapshot index descriptor reachable from them. A +// per-platform snapshot index is identified by mediaType=image index and +// artifactType = ArtifactTypeSnapshot with a `subject`. The +// single-platform case returns the root itself; the multi-platform case +// unwraps the top-level index and returns its children. +func collectPerPlatformSnapshotIndexes(ctx context.Context, store content.Provider, root ocispecs.Descriptor, roots []ocispecs.Descriptor) ([]ocispecs.Descriptor, error) { + candidates := roots + if len(candidates) == 0 { + candidates = []ocispecs.Descriptor{root} + } + var out []ocispecs.Descriptor + for _, c := range candidates { + if !isIndexMediaType(c.MediaType) { + continue + } + dt, err := content.ReadBlob(ctx, store, c) + if err != nil { + return nil, errors.WithStack(err) + } + var idx ocispecs.Index + if err := json.Unmarshal(dt, &idx); err != nil { + return nil, errors.WithStack(err) + } + // A per-platform snapshot index carries a non-nil Subject. + if idx.Subject != nil { + out = append(out, c) + continue + } + // Top-level multi-platform index — unwrap one level. + for _, child := range idx.Manifests { + if child.ArtifactType == ArtifactTypeSnapshot && isIndexMediaType(child.MediaType) { + out = append(out, child) + } + } + } + return out, nil +} + +// pickPerPlatformChild selects the platform-specific manifest from a +// per-platform snapshot index's manifests[] that corresponds to the +// recorded image-material root `rootLayer`. The matcher prefers the +// subject's platform then falls back to the builder platform; when the +// root index has a single child it is returned unconditionally. +func pickPerPlatformChild(ctx context.Context, store content.Provider, rootLayer ocispecs.Descriptor, pp ocispecs.Index, wantPlat *ocispecs.Platform, builderPlat ocispecs.Platform) (ocispecs.Descriptor, error) { + dt, err := content.ReadBlob(ctx, store, rootLayer) + if err != nil { + return ocispecs.Descriptor{}, errors.WithStack(err) + } + var rootIdx ocispecs.Index + if err := json.Unmarshal(dt, &rootIdx); err != nil { + return ocispecs.Descriptor{}, errors.WithStack(err) + } + matcher := replayPlatformMatcher(wantPlat, builderPlat) + var best *ocispecs.Descriptor + for i := range rootIdx.Manifests { + c := rootIdx.Manifests[i] + if c.Platform == nil || !matcher.Match(*c.Platform) { + continue + } + if best == nil || matcher.Less(*c.Platform, *best.Platform) { + best = &c + } + } + var wantDgst digest.Digest + switch { + case best != nil: + wantDgst = best.Digest + case len(rootIdx.Manifests) == 1: + wantDgst = rootIdx.Manifests[0].Digest + default: + return ocispecs.Descriptor{}, errors.Errorf("snapshot lookup: root %s has no child matching subject platform %s or builder %s", rootLayer.Digest, formatPlatformPtr(wantPlat), platforms.Format(builderPlat)) + } + // Resolve against manifests[] for the concrete descriptor (includes + // size / mediaType as recorded by the snapshot). + for _, m := range pp.Manifests { + if m.Digest == wantDgst { + return m, nil + } + } + // Not present in the snapshot's manifests[] — return a synthetic + // descriptor so the caller can still address content-by-digest. + return ocispecs.Descriptor{ + MediaType: ocispecs.MediaTypeImageManifest, + Digest: wantDgst, + }, nil +} + +func isIndexMediaType(mt string) bool { + return mt == ocispecs.MediaTypeImageIndex || mt == "application/vnd.docker.distribution.manifest.list.v2+json" +} diff --git a/replay/materials_test.go b/replay/materials_test.go new file mode 100644 index 000000000000..23d23c962369 --- /dev/null +++ b/replay/materials_test.go @@ -0,0 +1,216 @@ +package replay + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/containerd/containerd/v2/core/content" + "github.com/docker/buildx/util/buildflags" + "github.com/opencontainers/go-digest" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/stretchr/testify/require" +) + +func writeBlob(t *testing.T, root string, dt []byte) digest.Digest { + t.Helper() + d := digest.FromBytes(dt) + dir := filepath.Join(root, "blobs", d.Algorithm().String()) + require.NoError(t, os.MkdirAll(dir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, d.Encoded()), dt, 0o644)) + return d +} + +// seedOCILayout creates a minimum OCI layout skeleton: a `oci-layout` marker +// file plus the blobs/sha256 dir. The caller writes blobs on top. +func seedOCILayout(t *testing.T, root string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Join(root, "blobs", "sha256"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "oci-layout"), []byte(`{"imageLayoutVersion":"1.0.0"}`), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(root, "index.json"), []byte(`{"schemaVersion":2,"manifests":[]}`), 0o644)) +} + +func TestMaterialsResolverProvenanceDefault(t *testing.T) { + r, err := NewMaterialsResolver(nil) + require.NoError(t, err) + require.True(t, r.Sentinel()) + require.False(t, r.HasStores()) +} + +func TestMaterialsResolverProvenanceSentinelExplicit(t *testing.T) { + r, err := NewMaterialsResolver([]string{"provenance"}) + require.NoError(t, err) + require.True(t, r.Sentinel()) +} + +func TestMaterialsResolverOCILayoutDigestLookup(t *testing.T) { + dir := t.TempDir() + seedOCILayout(t, dir) + payload := []byte(`{"mediaType":"application/vnd.oci.image.manifest.v1+json"}`) + d := writeBlob(t, dir, payload) + + r, err := NewMaterialsResolver([]string{"oci-layout://" + dir}) + require.NoError(t, err) + require.False(t, r.Sentinel()) + require.True(t, r.HasStores()) + + desc, provider, err := r.Resolve(context.Background(), "pkg:docker/alpine@3.18", d) + require.NoError(t, err) + require.Equal(t, d, desc.Digest) + require.NotNil(t, provider) + + // Verify that the returned provider serves the blob. + dt, err := content.ReadBlob(context.Background(), provider, ocispecs.Descriptor{Digest: d, Size: int64(len(payload))}) + require.NoError(t, err) + require.Equal(t, payload, dt) +} + +func TestMaterialsResolverOCILayoutMiss(t *testing.T) { + dir := t.TempDir() + seedOCILayout(t, dir) + + r, err := NewMaterialsResolver([]string{"oci-layout://" + dir}) + require.NoError(t, err) + + absent := digest.FromBytes([]byte("nope")) + _, _, err = r.Resolve(context.Background(), "pkg:docker/missing@1.0", absent) + require.Error(t, err) + var mnf *MaterialNotFoundError + require.ErrorAs(t, err, &mnf) + require.Equal(t, absent.String(), mnf.Digest) +} + +func TestMaterialsResolverOverridesByURI(t *testing.T) { + dir := t.TempDir() + seedOCILayout(t, dir) + payload := []byte("hello-override") + d := writeBlob(t, dir, payload) + + uri := "https://example.com/whatever.tar" + specs := []string{uri + "=oci-layout://" + dir} + r, err := NewMaterialsResolver(specs) + require.NoError(t, err) + + overrides := r.Overrides() + require.Contains(t, overrides, uri) + + desc, provider, err := r.Resolve(context.Background(), uri, d) + require.NoError(t, err) + require.Equal(t, d, desc.Digest) + require.NotNil(t, provider) +} + +func TestMaterialsResolverOverrideURIContainingEquals(t *testing.T) { + dir := t.TempDir() + for _, uri := range []string{ + "pkg:docker/alpine@3.18?platform=linux%2Famd64", + "https://example.com/archive.tgz?token=abc", + } { + r, err := NewMaterialsResolver([]string{uri + "=" + dir}) + require.NoError(t, err) + require.Equal(t, map[string]string{uri: dir}, r.Overrides()) + } +} + +func TestMaterialsResolverOverridesByDigest(t *testing.T) { + dir := t.TempDir() + seedOCILayout(t, dir) + payload := []byte("bytes-for-digest-override") + d := writeBlob(t, dir, payload) + + specs := []string{d.String() + "=oci-layout://" + dir} + r, err := NewMaterialsResolver(specs) + require.NoError(t, err) + + desc, provider, err := r.Resolve(context.Background(), "", d) + require.NoError(t, err) + require.Equal(t, d, desc.Digest) + require.NotNil(t, provider) +} + +func TestMaterialsResolverOverrideFile(t *testing.T) { + dir := t.TempDir() + payload := []byte("standalone-file-override") + path := filepath.Join(dir, "payload.bin") + require.NoError(t, os.WriteFile(path, payload, 0o644)) + + d := digest.FromBytes(payload) + uri := "https://example.com/a.tar" + r, err := NewMaterialsResolver([]string{uri + "=" + path}) + require.NoError(t, err) + + desc, provider, err := r.Resolve(context.Background(), uri, d) + require.NoError(t, err) + require.Equal(t, d, desc.Digest) + require.Equal(t, int64(len(payload)), desc.Size) + require.NotNil(t, provider) + + dt, err := content.ReadBlob(context.Background(), provider, desc) + require.NoError(t, err) + require.Equal(t, payload, dt) +} + +func TestMaterialsResolverMalformedOverride(t *testing.T) { + _, err := NewMaterialsResolver([]string{"=oci-layout:///foo"}) + require.Error(t, err) +} + +func TestMaterialsResolverUnknownSpec(t *testing.T) { + _, err := NewMaterialsResolver([]string{"gopher://x"}) + require.Error(t, err) +} + +func TestMaterialsResolverOverrideIsNotConfusedWithPath(t *testing.T) { + // An absolute path that happens to contain '=' characters must still be + // recognised as a path, not as an override. + dir := t.TempDir() + oddDir := filepath.Join(dir, "has=equals") + require.NoError(t, os.MkdirAll(filepath.Join(oddDir, "blobs", "sha256"), 0o755)) + + r, err := NewMaterialsResolver([]string{oddDir}) + require.NoError(t, err) + require.True(t, r.HasStores()) +} + +// TestMaterialsResolverSnapshotBackedLookup builds a snapshot via Snapshot(), +// then points a fresh MaterialsResolver at the resulting OCI layout and +// resolves both an http and (synthetic) image material. The snapshot-backed +// lookup path (materials.go lookupSnapshot) is exercised. +func TestMaterialsResolverSnapshotBackedLookup(t *testing.T) { + fx := makeSnapshotFixture(t) + dest := t.TempDir() + exp := buildflags.ExportEntry{Type: "oci", Destination: dest, Attrs: map[string]string{"tar": "false"}} + req := &SnapshotRequest{ + Targets: []Target{{Subject: fx.subject, Predicate: fx.predicate}}, + IncludeMaterials: true, + Materials: snapshotOverrideResolver(t, fx.httpURI, fx.httpBytes), + Output: &exp, + } + require.NoError(t, Snapshot(context.Background(), nil, "", req)) + + layout := "oci-layout://" + dest + r, err := NewMaterialsResolver([]string{layout}) + require.NoError(t, err) + + // 1. Http material: resolves by digest into the materials manifest's + // layer set. + desc, provider, err := r.Resolve(context.Background(), fx.httpURI, fx.httpDigest) + require.NoError(t, err) + require.NotNil(t, provider) + require.Equal(t, fx.httpDigest, desc.Digest) + got, err := content.ReadBlob(context.Background(), provider, desc) + require.NoError(t, err) + require.Equal(t, fx.httpBytes, got) + + // 2. The subject manifest itself is reachable by its digest: Snapshot + // does not store it in the materials manifest, but the per-platform + // snapshot index's `manifests[]` references the attestation-manifest + // chain which CopyChain has copied into the layout. A lookup by the + // attestation manifest's digest must resolve (direct content-by- + // digest path). + attestDesc, attestProvider, err := r.Resolve(context.Background(), "", fx.attestDigest) + require.NoError(t, err) + require.NotNil(t, attestProvider) + require.Equal(t, fx.attestDigest, attestDesc.Digest) +} diff --git a/replay/policy.go b/replay/policy.go new file mode 100644 index 000000000000..502a5a51e0e3 --- /dev/null +++ b/replay/policy.go @@ -0,0 +1,516 @@ +package replay + +import ( + "context" + "fmt" + "sort" + "strings" + + "github.com/containerd/platforms" + "github.com/distribution/reference" + "github.com/docker/buildx/policy" + slsa1 "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v1" + gwpb "github.com/moby/buildkit/frontend/gateway/pb" + solverpb "github.com/moby/buildkit/solver/pb" + spb "github.com/moby/buildkit/sourcepolicy/pb" + "github.com/moby/buildkit/sourcepolicy/policysession" + "github.com/moby/buildkit/util/purl" + "github.com/opencontainers/go-digest" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/pkg/errors" +) + +// PinIndex is a resolved, URI/digest-keyed view of the predicate's +// ResolvedDependencies suitable for fast policy-callback lookup. +// +// Exact material identifiers and lossy aliases are kept separate. An alias can +// name more than one pin (for example the same image tag at two digests), so it +// must never use last-write-wins selection. +type PinIndex struct { + byURI map[string][]sourcePin + byAlias map[string][]sourcePin + byDigest map[digest.Digest]struct{} + materials []string +} + +type sourcePin struct { + digest digest.Digest + platform *ocispecs.Platform +} + +// NewPinIndex builds a PinIndex from the predicate's ResolvedDependencies. +// When a material has multiple digest entries (e.g. sha256 + sha512), the +// sha256 entry is preferred; otherwise the first entry wins. Materials without +// a usable digest are skipped. +func NewPinIndex(p *Predicate) *PinIndex { + idx := &PinIndex{ + byURI: map[string][]sourcePin{}, + byAlias: map[string][]sourcePin{}, + byDigest: map[digest.Digest]struct{}{}, + } + if p == nil { + return idx + } + materials := p.ResolvedDependencies() + if cfg := p.ConfigSource(); cfg.URI != "" && len(cfg.Digest) > 0 { + materials = append(materials, slsa1.ResourceDescriptor{ + URI: cfg.URI, + Digest: cfg.Digest, + }) + } + seenMaterials := map[string]struct{}{} + for _, m := range materials { + if disp := formatPinMaterial(m); disp != "" { + if _, ok := seenMaterials[disp]; !ok { + seenMaterials[disp] = struct{}{} + idx.materials = append(idx.materials, disp) + } + } + d := preferredDigest(m.Digest) + if d == "" { + continue + } + pin := sourcePin{digest: d} + if m.URI != "" { + if canon, platform, ok := canonicalMaterialIdentifier(m); ok { + pin.platform = platform + idx.byURI[canon] = appendPin(idx.byURI[canon], pin) + for _, alias := range canonicalIdentifierAliases(canon) { + idx.byAlias[alias] = appendPin(idx.byAlias[alias], pin) + } + } + idx.byURI[m.URI] = appendPin(idx.byURI[m.URI], pin) + } + idx.byDigest[d] = struct{}{} + } + sort.Strings(idx.materials) + return idx +} + +// preferredDigest picks a single digest from an in-toto DigestSet. sha256 is +// preferred so it aligns with how BuildKit's source-meta responses return +// image and http digests. Other algorithms are considered in lexical order +// so provenance with multiple non-sha256 entries is deterministic. +func preferredDigest(set map[string]string) digest.Digest { + if set == nil { + return "" + } + if v, ok := set["sha256"]; ok && v != "" { + return digest.NewDigestFromEncoded(digest.SHA256, v) + } + algorithms := make([]string, 0, len(set)) + for alg, v := range set { + if v == "" { + continue + } + algorithms = append(algorithms, alg) + } + sort.Strings(algorithms) + for _, alg := range algorithms { + return digest.NewDigestFromEncoded(digest.Algorithm(alg), set[alg]) + } + return "" +} + +// Len reports the number of pin entries. Used by `--dry-run`. +func (p *PinIndex) Len() int { + if p == nil { + return 0 + } + return len(p.byDigest) +} + +// Lookup resolves a URI to its pinned digest. Returns ("", false) when the +// URI is not covered by the index. +func (p *PinIndex) Lookup(uri string) (digest.Digest, bool) { + if p == nil { + return "", false + } + if pins, ok := p.byURI[uri]; ok { + return uniquePinDigest(pins) + } + return uniquePinDigest(p.byAlias[uri]) +} + +func appendPin(pins []sourcePin, pin sourcePin) []sourcePin { + for _, existing := range pins { + if existing.digest == pin.digest && platformEqual(existing.platform, pin.platform) { + return pins + } + } + return append(pins, pin) +} + +func platformEqual(a, b *ocispecs.Platform) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + return platforms.OnlyStrict(platforms.Normalize(*a)).Match(platforms.Normalize(*b)) +} + +func uniquePinDigest(pins []sourcePin) (digest.Digest, bool) { + var found digest.Digest + for _, pin := range pins { + if found == "" { + found = pin.digest + continue + } + if found != pin.digest { + return "", false + } + } + return found, found != "" +} + +// ReplayPinCallback returns a policysession.PolicyCallback that enforces the +// pin index. Sources covered by the index are ALLOWed when their requested +// digest matches; unknown sources are DENY (fail-closed); covered sources +// with wrong digest are DENY with a DenyMessage. +func ReplayPinCallback(idx *PinIndex) policysession.PolicyCallback { + return func(ctx context.Context, req *policysession.CheckPolicyRequest) (*policysession.DecisionResponse, *gwpb.ResolveSourceMetaRequest, error) { + uri, observed := extractSourceIdentity(req) + if uri == "" && observed == "" { + return denyResponse("replay pin: request carried no source identifier"), nil, nil + } + + // URI-matched: rewrite the source operation so BuildKit's source + // implementation enforces the recorded pin. Metadata is not present on + // BuildKit's initial policy request, so allowing a covered URI before + // adding these attributes would leave HTTP and Git sources unpinned. + if idx != nil && uri != "" { + if pinned, covered, reason := idx.resolve(req, uri, observed); covered { + if reason != "" { + return denyResponse(reason), nil, nil + } + decision, supported, err := convertPinnedSource(req, pinned) + if err != nil { + return nil, nil, err + } else if decision != nil { + return decision, nil, nil + } + if observed == "" { + if supported { + return allowResponse(), nil, nil + } + return denyResponse(fmt.Sprintf("replay pin: cannot enforce recorded digest for %s", uri)), nil, nil + } + if pinned == observed { + return allowResponse(), nil, nil + } + return denyResponse(fmt.Sprintf("replay pin mismatch for %s: expected %s, got %s", uri, pinned, observed)), nil, nil + } + } + + // Digest-only match: the observed digest matches a pinned material + // whose URI was not available on the request. Accept this — the + // content is the pinned bytes regardless of how the frontend named + // them. + if idx != nil && observed != "" { + if _, ok := idx.byDigest[observed]; ok { + return allowResponse(), nil, nil + } + } + + // Source is not covered by the pin index. Fail closed. + return denyResponse(notCoveredMessage(idx, req, displaySource(uri, observed))), nil, nil + } +} + +func (p *PinIndex) resolve(req *policysession.CheckPolicyRequest, uri string, observed digest.Digest) (digest.Digest, bool, string) { + keys := []string{uri} + if canon := canonicalRequestSource(req); canon != "" && canon != uri { + keys = append(keys, canon) + } + for _, key := range keys { + if pins, ok := p.byURI[key]; ok { + return selectPin(pins, requestPlatform(req), observed, key) + } + } + for _, key := range keys { + aliases := append([]string{key}, canonicalIdentifierAliases(key)...) + for _, alias := range aliases { + if pins, ok := p.byAlias[alias]; ok { + return selectPin(pins, requestPlatform(req), observed, alias) + } + } + } + return "", false, "" +} + +func selectPin(pins []sourcePin, platform *ocispecs.Platform, observed digest.Digest, source string) (digest.Digest, bool, string) { + // A platform-qualified material still pins the same immutable image index. + // If every matching provenance entry agrees on that digest, the request + // platform cannot make the pin ambiguous and must not exclude it. This is + // important when replaying on a builder whose BUILDPLATFORM differs from + // the builder that produced the provenance. + if pinned, ok := uniquePinDigest(pins); ok { + return pinned, true, "" + } + + // Platform is only a tie-breaker when the source name has multiple recorded + // digests. In that case it prevents silently selecting another platform's + // pin while retaining the fail-closed behavior for unknown platforms. + candidates := pins + if platform != nil { + candidates = nil + matcher := platforms.OnlyStrict(platforms.Normalize(*platform)) + for _, pin := range pins { + if pin.platform == nil || matcher.Match(platforms.Normalize(*pin.platform)) { + candidates = append(candidates, pin) + } + } + if len(candidates) == 0 { + return "", true, fmt.Sprintf("replay pin: no recorded pin for %s on platform %s", source, platforms.Format(*platform)) + } + } + if pinned, ok := uniquePinDigest(candidates); ok { + return pinned, true, "" + } + if observed != "" { + for _, pin := range candidates { + if pin.digest == observed { + return observed, true, "" + } + } + } + return "", true, fmt.Sprintf("replay pin: ambiguous provenance pins for %s", source) +} + +func convertPinnedSource(req *policysession.CheckPolicyRequest, pinned digest.Digest) (*policysession.DecisionResponse, bool, error) { + if req == nil || req.Source == nil || req.Source.GetSource() == nil { + return nil, false, nil + } + src := req.Source.GetSource() + if canon := canonicalRequestSource(req); canon != "" && canon != src.Identifier { + clone := src.CloneVT() + clone.Identifier = canon + src = clone + } + switch { + case strings.HasPrefix(src.Identifier, "docker-image://"): + newSrc, err := policy.AddPinToImage(src, pinned) + if err != nil { + return nil, false, errors.Wrap(err, "failed to pin covered image source") + } + if newSrc.Identifier == src.Identifier { + return nil, true, nil + } + return convertResponse(newSrc), true, nil + case strings.HasPrefix(src.Identifier, "http://"), strings.HasPrefix(src.Identifier, "https://"): + return convertPinnedAttr(src, solverpb.AttrHTTPChecksum, pinned.String()), true, nil + case strings.HasPrefix(src.Identifier, "git://"): + return convertPinnedAttr(src, solverpb.AttrGitChecksum, pinned.Encoded()), true, nil + default: + return nil, false, nil + } +} + +func convertPinnedAttr(src *solverpb.SourceOp, key, value string) *policysession.DecisionResponse { + if src.Attrs[key] == value { + return nil + } + newSrc := src.CloneVT() + if newSrc.Attrs == nil { + newSrc.Attrs = map[string]string{} + } + newSrc.Attrs[key] = value + return convertResponse(newSrc) +} + +func convertResponse(src *solverpb.SourceOp) *policysession.DecisionResponse { + return &policysession.DecisionResponse{ + Action: spb.PolicyAction_CONVERT, + Update: src, + } +} + +// extractSourceIdentity pulls the URI and the digest (if known on this call) +// from a CheckPolicyRequest. Supports the three source shapes BuildKit emits +// on a session policy request: image, http, git. +func extractSourceIdentity(req *policysession.CheckPolicyRequest) (uri string, dgst digest.Digest) { + if req == nil || req.Source == nil { + return "", "" + } + src := req.Source.GetSource() + if src != nil { + uri = src.GetIdentifier() + } + + // Image response carries the resolved digest once BuildKit has asked the + // registry. First invocation (before resolution) comes without a digest — + // that is the expected path for the ALLOW-pending branch. + if img := req.Source.GetImage(); img != nil { + if v := img.GetDigest(); v != "" { + dgst = digest.Digest(v) + } + } + if h := req.Source.GetHTTP(); h != nil { + if v := h.GetChecksum(); v != "" { + dgst = digest.Digest(v) + } + } + if g := req.Source.GetGit(); g != nil { + if v := g.GetCommitChecksum(); v != "" && dgst == "" { + dgst = gitDigest(v) + } + } + if uri != "" { + uri, dgst = normalizeRequestSourceIdentity(uri, dgst) + } + return uri, dgst +} + +func gitDigest(v string) digest.Digest { + if _, err := digest.Parse(v); err == nil { + return digest.Digest(v) + } + switch len(v) { + case 40: + return digest.NewDigestFromEncoded(digest.Algorithm("sha1"), v) + case 64: + return digest.NewDigestFromEncoded(digest.SHA256, v) + default: + return digest.Digest(v) + } +} + +func displaySource(uri string, dgst digest.Digest) string { + switch { + case uri != "" && dgst != "": + return fmt.Sprintf("%s@%s", uri, dgst) + case uri != "": + return uri + case dgst != "": + return dgst.String() + default: + return "" + } +} + +func canonicalMaterialIdentifier(m slsa1.ResourceDescriptor) (string, *ocispecs.Platform, bool) { + src, platform, err := policy.ParseSLSAMaterial(m) + if err != nil || src == nil || src.Identifier == "" { + return "", nil, false + } + return src.Identifier, platform, true +} + +func canonicalIdentifierAliases(id string) []string { + refStr, ok := strings.CutPrefix(id, "docker-image://") + if !ok { + return nil + } + if refBase, _, ok := strings.Cut(refStr, "@"); ok { + return []string{"docker-image://" + refBase} + } + if _, err := reference.ParseNormalizedNamed(refStr); err != nil { + return nil + } + return nil +} + +func normalizeRequestSourceIdentity(uri string, dgst digest.Digest) (string, digest.Digest) { + refStr, ok := strings.CutPrefix(uri, "docker-image://") + if !ok { + return uri, dgst + } + if _, refDigest, ok := strings.Cut(refStr, "@"); ok { + if dgst == "" { + if parsed, err := digest.Parse(refDigest); err == nil { + dgst = parsed + } + } + return uri, dgst + } + return uri, dgst +} + +func notCoveredMessage(idx *PinIndex, req *policysession.CheckPolicyRequest, src string) string { + if idx == nil || len(idx.materials) == 0 { + return fmt.Sprintf("replay pin: no provenance material matched requested source\n target: %s", formatTargetSource(req, src)) + } + var b strings.Builder + b.WriteString("replay pin: no provenance material matched requested source") + b.WriteString("\n target: ") + b.WriteString(formatTargetSource(req, src)) + b.WriteString("\n provenance materials:") + for _, m := range idx.materials { + b.WriteString("\n - ") + b.WriteString(m) + } + return b.String() +} + +func requestPlatform(req *policysession.CheckPolicyRequest) *ocispecs.Platform { + if req == nil || req.Platform == nil { + return nil + } + p := ocispecs.Platform{ + OS: req.Platform.OS, + Architecture: req.Platform.Architecture, + Variant: req.Platform.Variant, + } + norm := platforms.Normalize(p) + return &norm +} + +func formatPinMaterial(m slsa1.ResourceDescriptor) string { + if m.URI == "" { + return "" + } + src, p, err := policy.ParseSLSAMaterial(m) + switch { + case err != nil || src == nil || src.Identifier == "": + return m.URI + case p != nil: + return fmt.Sprintf("uri=%s canonical=%s platform=%s", m.URI, src.Identifier, platforms.Format(*p)) + case src.Identifier != m.URI: + return fmt.Sprintf("uri=%s canonical=%s", m.URI, src.Identifier) + default: + return "uri=" + m.URI + } +} + +func formatTargetSource(req *policysession.CheckPolicyRequest, src string) string { + parts := []string{"uri=" + src} + if canon := canonicalRequestSource(req); canon != "" && canon != src { + parts = append(parts, "canonical="+canon) + } + if p := requestPlatform(req); p != nil { + parts = append(parts, "platform="+platforms.Format(*p)) + } + return strings.Join(parts, " ") +} + +func canonicalRequestSource(req *policysession.CheckPolicyRequest) string { + if req == nil || req.Source == nil || req.Source.Source == nil { + return "" + } + uri := req.Source.Source.Identifier + if uri == "" { + return "" + } + if strings.HasPrefix(uri, "pkg:docker/") { + refStr, _, err := purl.PURLToRef(uri) + if err != nil { + return "" + } + named, err := reference.ParseNormalizedNamed(refStr) + if err != nil { + return "" + } + return "docker-image://" + named.String() + } + return uri +} + +func allowResponse() *policysession.DecisionResponse { + return &policysession.DecisionResponse{Action: spb.PolicyAction_ALLOW} +} + +func denyResponse(msg string) *policysession.DecisionResponse { + return &policysession.DecisionResponse{ + Action: spb.PolicyAction_DENY, + DenyMessages: []*policysession.DenyMessage{{Message: msg}}, + } +} diff --git a/replay/policy_test.go b/replay/policy_test.go new file mode 100644 index 000000000000..0a13d11e09b6 --- /dev/null +++ b/replay/policy_test.go @@ -0,0 +1,378 @@ +package replay + +import ( + "context" + "strings" + "testing" + + buildxpolicy "github.com/docker/buildx/policy" + slsacommon "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/common" + slsa1 "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v1" + gwpb "github.com/moby/buildkit/frontend/gateway/pb" + provenancetypes "github.com/moby/buildkit/solver/llbsolver/provenance/types" + solverpb "github.com/moby/buildkit/solver/pb" + spb "github.com/moby/buildkit/sourcepolicy/pb" + "github.com/moby/buildkit/sourcepolicy/policysession" + "github.com/stretchr/testify/require" +) + +func TestPreferredDigestDeterministicFallback(t *testing.T) { + set := map[string]string{ + "sha512": strings.Repeat("5", 128), + "sha384": strings.Repeat("3", 96), + } + require.Equal(t, "sha384:"+strings.Repeat("3", 96), preferredDigest(set).String()) + + set["sha256"] = strings.Repeat("2", 64) + require.Equal(t, "sha256:"+strings.Repeat("2", 64), preferredDigest(set).String()) +} + +const ( + imageURIAlpine = "pkg:docker/alpine@3.18?platform=linux%2Famd64" + imageSHA = "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + imageSHAOther = "sha256:bbbbbb0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + + httpURI = "https://example.com/payload.tar" + httpSHA = "sha256:1111111111111111111111111111111111111111111111111111111111111111" + + gitURIHTTPS = "https://github.com/moby/buildkit.git#refs/tags/v0.29.0" + gitURIGit = "git://github.com/moby/buildkit.git#refs/tags/v0.29.0" + gitCommit = "sha1:8543ce4428265d547cb009e5ad62348284497a88" +) + +func predicateWithMaterials(mats ...slsa1.ResourceDescriptor) *Predicate { + pred := &Predicate{} + pred.BuildDefinition = provenancetypes.ProvenanceBuildDefinitionSLSA1{} + pred.BuildDefinition.ResolvedDependencies = mats + return pred +} + +func imageMaterial(uri, sha string) slsa1.ResourceDescriptor { + return slsa1.ResourceDescriptor{ + URI: uri, + Digest: slsacommon.DigestSet{"sha256": stripSHA256(sha)}, + } +} + +func stripSHA256(s string) string { + if i := len("sha256:"); len(s) > i && s[:i] == "sha256:" { + return s[i:] + } + if i := len("sha1:"); len(s) > i && s[:i] == "sha1:" { + return s[i:] + } + return s +} + +func imageCheckRequest(uri, observed string) *policysession.CheckPolicyRequest { + return &policysession.CheckPolicyRequest{ + Source: &gwpb.ResolveSourceMetaResponse{ + Source: &solverpb.SourceOp{Identifier: uri}, + Image: &gwpb.ResolveSourceImageResponse{Digest: observed}, + }, + } +} + +func httpCheckRequest(uri, observed string) *policysession.CheckPolicyRequest { + return &policysession.CheckPolicyRequest{ + Source: &gwpb.ResolveSourceMetaResponse{ + Source: &solverpb.SourceOp{Identifier: uri}, + HTTP: &gwpb.ResolveSourceHTTPResponse{Checksum: observed}, + }, + } +} + +func gitCheckRequest(uri, observed string) *policysession.CheckPolicyRequest { + return &policysession.CheckPolicyRequest{ + Source: &gwpb.ResolveSourceMetaResponse{ + Source: &solverpb.SourceOp{Identifier: uri}, + Git: &gwpb.ResolveSourceGitResponse{CommitChecksum: observed}, + }, + } +} + +func sourceCheckRequest(uri string, attrs map[string]string) *policysession.CheckPolicyRequest { + return &policysession.CheckPolicyRequest{ + Source: &gwpb.ResolveSourceMetaResponse{ + Source: &solverpb.SourceOp{Identifier: uri, Attrs: attrs}, + }, + } +} + +func TestPinIndexURIAllowAndDeny(t *testing.T) { + pred := predicateWithMaterials(imageMaterial(imageURIAlpine, imageSHA)) + idx := NewPinIndex(pred) + cb := ReplayPinCallback(idx) + + // Covered image URI is converted to the pinned digest from provenance. + resp, _, err := cb(context.Background(), imageCheckRequest(imageURIAlpine, imageSHA)) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, spb.PolicyAction_CONVERT, resp.Action) + require.NotNil(t, resp.Update) + require.Equal(t, "docker-image://docker.io/library/alpine:3.18@"+imageSHA, resp.Update.Identifier) + + // Digest drift on a covered image still converts to the provenance pin. + wrongSHA := "sha256:0000000000000000000000000000000000000000000000000000000000000000" + resp, _, err = cb(context.Background(), imageCheckRequest(imageURIAlpine, wrongSHA)) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, spb.PolicyAction_CONVERT, resp.Action) + require.NotNil(t, resp.Update) + require.Equal(t, "docker-image://docker.io/library/alpine:3.18@"+imageSHA, resp.Update.Identifier) +} + +func TestPinIndexImagePURLCanonicalIdentifierAllowed(t *testing.T) { + mat := imageMaterial(imageURIAlpine, imageSHA) + src, _, err := buildxpolicy.ParseSLSAMaterial(mat) + require.NoError(t, err) + require.NotNil(t, src) + + idx := NewPinIndex(predicateWithMaterials(mat)) + cb := ReplayPinCallback(idx) + + resp, _, err := cb(context.Background(), imageCheckRequest(src.Identifier, imageSHA)) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, spb.PolicyAction_ALLOW, resp.Action) +} + +func TestPinIndexImageCanonicalUnpinnedIdentifierAllowed(t *testing.T) { + mat := imageMaterial("pkg:docker/docker/dockerfile-upstream@master", "sha256:02bce6c486f5bbd7b2eb6b9a16e3734110face1c70a6bacd827dcdb80c3f9a24") + idx := NewPinIndex(predicateWithMaterials(mat)) + cb := ReplayPinCallback(idx) + + resp, _, err := cb(context.Background(), imageCheckRequest("docker-image://docker.io/docker/dockerfile-upstream:master", "")) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, spb.PolicyAction_CONVERT, resp.Action) + require.NotNil(t, resp.Update) + require.Equal(t, "docker-image://docker.io/docker/dockerfile-upstream:master@sha256:02bce6c486f5bbd7b2eb6b9a16e3734110face1c70a6bacd827dcdb80c3f9a24", resp.Update.Identifier) +} + +func TestPinIndexImageCanonicalResolvedDigestMismatchConverted(t *testing.T) { + mat := imageMaterial("pkg:docker/docker/dockerfile-upstream@master", "sha256:02bce6c486f5bbd7b2eb6b9a16e3734110face1c70a6bacd827dcdb80c3f9a24") + idx := NewPinIndex(predicateWithMaterials(mat)) + cb := ReplayPinCallback(idx) + + resp, _, err := cb(context.Background(), imageCheckRequest("docker-image://docker.io/docker/dockerfile-upstream:master", "sha256:a7308cdb4411614c503aee073f5cb4caa5245b8e89fceb41887129219da0b267")) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, spb.PolicyAction_CONVERT, resp.Action) + require.NotNil(t, resp.Update) + require.Equal(t, "docker-image://docker.io/docker/dockerfile-upstream:master@sha256:02bce6c486f5bbd7b2eb6b9a16e3734110face1c70a6bacd827dcdb80c3f9a24", resp.Update.Identifier) +} + +func TestPinIndexImageCanonicalIdentifierCarriesDigestInSource(t *testing.T) { + mat := imageMaterial("pkg:docker/docker/dockerfile-upstream@master", "sha256:02bce6c486f5bbd7b2eb6b9a16e3734110face1c70a6bacd827dcdb80c3f9a24") + idx := NewPinIndex(predicateWithMaterials(mat)) + cb := ReplayPinCallback(idx) + + resp, _, err := cb(context.Background(), imageCheckRequest("docker-image://docker.io/docker/dockerfile-upstream:master@sha256:a7308cdb4411614c503aee073f5cb4caa5245b8e89fceb41887129219da0b267", "")) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, spb.PolicyAction_CONVERT, resp.Action) + require.NotNil(t, resp.Update) + require.Equal(t, "docker-image://docker.io/docker/dockerfile-upstream:master@sha256:02bce6c486f5bbd7b2eb6b9a16e3734110face1c70a6bacd827dcdb80c3f9a24", resp.Update.Identifier) +} + +func TestPinIndexDigestQualifiedImagesDoNotCollide(t *testing.T) { + matA := imageMaterial("pkg:docker/alpine?digest="+imageSHA+"&platform=linux%2Famd64", imageSHA) + matB := imageMaterial("pkg:docker/alpine?digest="+imageSHAOther+"&platform=linux%2Famd64", imageSHAOther) + cb := ReplayPinCallback(NewPinIndex(predicateWithMaterials(matA, matB))) + + for _, pinned := range []string{imageSHA, imageSHAOther} { + resp, _, err := cb(context.Background(), imageCheckRequest("docker-image://docker.io/library/alpine@"+pinned, "")) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, spb.PolicyAction_ALLOW, resp.Action) + } + + resp, _, err := cb(context.Background(), imageCheckRequest("docker-image://docker.io/library/alpine", "")) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, spb.PolicyAction_DENY, resp.Action) + require.Contains(t, resp.DenyMessages[0].Message, "ambiguous provenance pins") +} + +func TestPinIndexUsesRequestPlatformToDisambiguateImage(t *testing.T) { + matAMD64 := imageMaterial("pkg:docker/alpine?digest="+imageSHA+"&platform=linux%2Famd64", imageSHA) + matARM64 := imageMaterial("pkg:docker/alpine?digest="+imageSHAOther+"&platform=linux%2Farm64", imageSHAOther) + cb := ReplayPinCallback(NewPinIndex(predicateWithMaterials(matAMD64, matARM64))) + req := imageCheckRequest("docker-image://docker.io/library/alpine", "") + req.Platform = &solverpb.Platform{OS: "linux", Architecture: "amd64"} + + resp, _, err := cb(context.Background(), req) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, spb.PolicyAction_CONVERT, resp.Action) + require.Equal(t, "docker-image://docker.io/library/alpine@"+imageSHA, resp.Update.Identifier) +} + +func TestPinIndexUniqueDigestIgnoresRequestPlatform(t *testing.T) { + mat := imageMaterial("pkg:docker/alpine?digest="+imageSHA+"&platform=linux%2Famd64", imageSHA) + cb := ReplayPinCallback(NewPinIndex(predicateWithMaterials(mat))) + req := imageCheckRequest("docker-image://docker.io/library/alpine", "") + req.Platform = &solverpb.Platform{OS: "linux", Architecture: "arm64"} + + resp, _, err := cb(context.Background(), req) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, spb.PolicyAction_CONVERT, resp.Action) + require.Equal(t, "docker-image://docker.io/library/alpine@"+imageSHA, resp.Update.Identifier) +} + +func TestPinIndexAmbiguousDigestsRejectUnknownRequestPlatform(t *testing.T) { + matAMD64 := imageMaterial("pkg:docker/alpine?digest="+imageSHA+"&platform=linux%2Famd64", imageSHA) + matARM64 := imageMaterial("pkg:docker/alpine?digest="+imageSHAOther+"&platform=linux%2Farm64", imageSHAOther) + cb := ReplayPinCallback(NewPinIndex(predicateWithMaterials(matAMD64, matARM64))) + req := imageCheckRequest("docker-image://docker.io/library/alpine", "") + req.Platform = &solverpb.Platform{OS: "linux", Architecture: "s390x"} + + resp, _, err := cb(context.Background(), req) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, spb.PolicyAction_DENY, resp.Action) + require.Contains(t, resp.DenyMessages[0].Message, "no recorded pin") + require.Contains(t, resp.DenyMessages[0].Message, "linux/s390x") +} + +func TestPinIndexUnknownSourceDenied(t *testing.T) { + idx := NewPinIndex(predicateWithMaterials(imageMaterial(imageURIAlpine, imageSHA))) + cb := ReplayPinCallback(idx) + + // Unknown URI, unknown digest → DENY (fail-closed). + other := "pkg:docker/ubuntu@22.04?platform=linux%2Famd64" + resp, _, err := cb(context.Background(), imageCheckRequest(other, "sha256:deadbeef")) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, spb.PolicyAction_DENY, resp.Action) + require.NotEmpty(t, resp.DenyMessages) + require.Contains(t, resp.DenyMessages[0].Message, "no provenance material matched requested source") + require.Contains(t, resp.DenyMessages[0].Message, "\n target: uri="+other+"@sha256:deadbeef") + require.Contains(t, resp.DenyMessages[0].Message, "\n provenance materials:") + require.Contains(t, resp.DenyMessages[0].Message, "\n - uri="+imageURIAlpine) + require.Contains(t, resp.DenyMessages[0].Message, "canonical=docker-image://docker.io/library/alpine:3.18@"+imageSHA) + require.Contains(t, resp.DenyMessages[0].Message, "platform=linux/amd64") +} + +func TestPinIndexHTTPConvertedToRecordedChecksum(t *testing.T) { + pred := predicateWithMaterials(slsa1.ResourceDescriptor{ + URI: httpURI, + Digest: slsacommon.DigestSet{"sha256": stripSHA256(httpSHA)}, + }) + idx := NewPinIndex(pred) + cb := ReplayPinCallback(idx) + + resp, _, err := cb(context.Background(), sourceCheckRequest(httpURI, nil)) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, spb.PolicyAction_CONVERT, resp.Action) + require.NotNil(t, resp.Update) + require.Equal(t, httpSHA, resp.Update.Attrs[solverpb.AttrHTTPChecksum]) + + resp, _, err = cb(context.Background(), sourceCheckRequest(httpURI, resp.Update.Attrs)) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, spb.PolicyAction_ALLOW, resp.Action) +} + +func TestPinIndexIncludesRecordedConfigSource(t *testing.T) { + pred := predicateWithMaterials() + pred.BuildDefinition.ExternalParameters.ConfigSource.URI = httpURI + pred.BuildDefinition.ExternalParameters.ConfigSource.Digest = slsacommon.DigestSet{ + "sha256": stripSHA256(httpSHA), + } + cb := ReplayPinCallback(NewPinIndex(pred)) + + resp, _, err := cb(context.Background(), sourceCheckRequest(httpURI, nil)) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, spb.PolicyAction_CONVERT, resp.Action) + require.Equal(t, httpSHA, resp.Update.Attrs[solverpb.AttrHTTPChecksum]) +} + +func TestPinIndexGitSchemeNormalizationConvertedToRecordedCommit(t *testing.T) { + pred := predicateWithMaterials(slsa1.ResourceDescriptor{ + URI: gitURIHTTPS, + Digest: slsacommon.DigestSet{"sha1": stripSHA256(gitCommit)}, + }) + idx := NewPinIndex(pred) + cb := ReplayPinCallback(idx) + + resp, _, err := cb(context.Background(), sourceCheckRequest(gitURIGit, nil)) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, spb.PolicyAction_CONVERT, resp.Action) + require.NotNil(t, resp.Update) + require.Equal(t, stripSHA256(gitCommit), resp.Update.Attrs[solverpb.AttrGitChecksum]) + + resp, _, err = cb(context.Background(), sourceCheckRequest(gitURIGit, resp.Update.Attrs)) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, spb.PolicyAction_ALLOW, resp.Action) +} + +func TestPinIndexHTTPResolvedDigestMismatchDenied(t *testing.T) { + pred := predicateWithMaterials(slsa1.ResourceDescriptor{ + URI: httpURI, + Digest: slsacommon.DigestSet{"sha256": stripSHA256(httpSHA)}, + }) + cb := ReplayPinCallback(NewPinIndex(pred)) + req := httpCheckRequest(httpURI, "sha256:2222222222222222222222222222222222222222222222222222222222222222") + req.Source.Source.Attrs = map[string]string{solverpb.AttrHTTPChecksum: httpSHA} + + resp, _, err := cb(context.Background(), req) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, spb.PolicyAction_DENY, resp.Action) + require.Contains(t, resp.DenyMessages[0].Message, "replay pin mismatch") +} + +func TestPinIndexGitResolvedCommitMismatchDenied(t *testing.T) { + pred := predicateWithMaterials(slsa1.ResourceDescriptor{ + URI: gitURIHTTPS, + Digest: slsacommon.DigestSet{"sha1": stripSHA256(gitCommit)}, + }) + cb := ReplayPinCallback(NewPinIndex(pred)) + req := gitCheckRequest(gitURIGit, "1111111111111111111111111111111111111111") + req.Source.Source.Attrs = map[string]string{solverpb.AttrGitChecksum: stripSHA256(gitCommit)} + + resp, _, err := cb(context.Background(), req) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, spb.PolicyAction_DENY, resp.Action) + require.Contains(t, resp.DenyMessages[0].Message, "replay pin mismatch") +} + +func TestPinIndexAllowPending(t *testing.T) { + // First callback invocation on an image source often arrives without + // an observed digest (the source-meta roundtrip produces it). The + // replay callback must not fail-closed on that pending shape when the + // URI is covered. + idx := NewPinIndex(predicateWithMaterials(imageMaterial(imageURIAlpine, imageSHA))) + cb := ReplayPinCallback(idx) + + resp, _, err := cb(context.Background(), imageCheckRequest(imageURIAlpine, "")) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, spb.PolicyAction_CONVERT, resp.Action) + require.NotNil(t, resp.Update) + require.Equal(t, "docker-image://docker.io/library/alpine:3.18@"+imageSHA, resp.Update.Identifier) +} + +func TestMultiPolicyCallbackReplayStrict(t *testing.T) { + // A permissive user-defined overlay must not defeat the strict replay + // callback. Compose a pass-through first and the replay pin last. + passthrough := policysession.PolicyCallback(func(ctx context.Context, req *policysession.CheckPolicyRequest) (*policysession.DecisionResponse, *gwpb.ResolveSourceMetaRequest, error) { + return &policysession.DecisionResponse{Action: spb.PolicyAction_ALLOW}, nil, nil + }) + idx := NewPinIndex(predicateWithMaterials(imageMaterial(imageURIAlpine, imageSHA))) + combined := buildxpolicy.MultiPolicyCallback(passthrough, ReplayPinCallback(idx)) + + unknown := "pkg:docker/ubuntu@22.04?platform=linux%2Famd64" + resp, _, err := combined(context.Background(), imageCheckRequest(unknown, "sha256:deadbeef")) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, spb.PolicyAction_DENY, resp.Action) +} diff --git a/replay/predicate.go b/replay/predicate.go new file mode 100644 index 000000000000..2dd1beace33b --- /dev/null +++ b/replay/predicate.go @@ -0,0 +1,190 @@ +package replay + +import ( + "strings" + + "github.com/containerd/platforms" + "github.com/docker/buildx/policy" + slsa1 "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v1" + provenancetypes "github.com/moby/buildkit/solver/llbsolver/provenance/types" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" +) + +// Predicate is a named type over ProvenancePredicateSLSA1 so replay code can +// attach accessors without copying or wrapping. The receiver is never nil: +// callers must have obtained a non-nil *Predicate from Subject.Predicate. +type Predicate provenancetypes.ProvenancePredicateSLSA1 + +// defaultFrontend matches BuildKit's default when no frontend is recorded on +// the request (see build/opt.go:309 — dockerfile.v0). +const defaultFrontend = "dockerfile.v0" + +// Frontend returns the frontend id recorded on the predicate, falling back +// to dockerfile.v0 when the predicate does not record one. +func (p *Predicate) Frontend() string { + if f := p.BuildDefinition.ExternalParameters.Request.Frontend; f != "" { + return f + } + return defaultFrontend +} + +// FrontendAttrs returns the recorded frontend attrs with attestation-related +// keys stripped (see Attests for those). Returns a fresh map so callers can +// mutate it. +func (p *Predicate) FrontendAttrs() map[string]string { + src := p.BuildDefinition.ExternalParameters.Request.Args + out := make(map[string]string, len(src)) + for k, v := range src { + if strings.HasPrefix(k, "attest:") { + continue + } + out[k] = v + } + return out +} + +// Attests returns the recorded attestation-related frontend attrs as the +// map shape consumed by build.Options.Attests: key is the attestation type +// (the text after "attest:"), value is the recorded attr payload. +func (p *Predicate) Attests() map[string]*string { + src := p.BuildDefinition.ExternalParameters.Request.Args + out := map[string]*string{} + for k, v := range src { + name, ok := strings.CutPrefix(k, "attest:") + if !ok { + continue + } + vv := v + out[name] = &vv + } + return out +} + +// ConfigSource returns the configSource descriptor recorded on the predicate. +func (p *Predicate) ConfigSource() provenancetypes.ProvenanceConfigSourceSLSA1 { + return p.BuildDefinition.ExternalParameters.ConfigSource +} + +// Secrets returns the declared secrets from the predicate's request. +func (p *Predicate) Secrets() []*provenancetypes.Secret { + return p.BuildDefinition.ExternalParameters.Request.Secrets +} + +// SSH returns the declared SSH entries from the predicate's request. +func (p *Predicate) SSH() []*provenancetypes.SSH { + return p.BuildDefinition.ExternalParameters.Request.SSH +} + +// Locals returns the local-context sources recorded on the predicate. A +// non-empty result should cause replay to fail with +// UnreplayableLocalContextError. +func (p *Predicate) Locals() []*provenancetypes.LocalSource { + return p.BuildDefinition.ExternalParameters.Request.Locals +} + +// BuilderPlatform returns the platform the original builder ran on, parsed +// from InternalParameters.builderPlatform. Falls back to the runtime host +// platform when the field is missing or malformed. +func (p *Predicate) BuilderPlatform() ocispecs.Platform { + if plat, ok := p.RecordedBuilderPlatform(); ok { + return *plat + } + return platforms.DefaultSpec() +} + +// RecordedBuilderPlatform returns the platform recorded in +// InternalParameters.builderPlatform when present and valid. +func (p *Predicate) RecordedBuilderPlatform() (*ocispecs.Platform, bool) { + s := p.BuildDefinition.InternalParameters.BuilderPlatform + if s == "" { + return nil, false + } + plat, err := platforms.Parse(s) + if err != nil { + return nil, false + } + norm := platforms.Normalize(plat) + return &norm, true +} + +// DefaultPlatform returns the effective provenance default platform for +// resolving host-side image sources during replay. It prefers the recorded +// platform-qualified image materials when they all agree, and otherwise +// falls back to the recorded builderPlatform field. +func (p *Predicate) DefaultPlatform() (*ocispecs.Platform, bool) { + var inferred *ocispecs.Platform + for _, m := range p.ResolvedDependencies() { + _, mp, err := policy.ParseSLSAMaterial(m) + if err != nil || mp == nil { + continue + } + norm := platforms.Normalize(*mp) + if inferred == nil { + inferred = &norm + continue + } + if platforms.Format(*inferred) != platforms.Format(norm) { + return nil, false + } + } + if inferred != nil { + return inferred, true + } + if plat, ok := p.RecordedBuilderPlatform(); ok { + return plat, true + } + return nil, false +} + +// FallbackTargetPlatform infers the target platform from the build's LLB when +// the provenance subject has no platform metadata. BuildKit injects +// TARGETPLATFORM into Dockerfile exec environments. A single provenance +// statement must agree on that value; mixed or malformed values are not safe +// to use as an implicit replay target. +// +// This is a compatibility fallback, not an authoritative provenance field. +// TODO: Prefer an explicit target platform once BuildKit records one in the +// provenance build parameters or subject metadata. +func (p *Predicate) FallbackTargetPlatform() (*ocispecs.Platform, bool) { + buildConfig := p.BuildDefinition.InternalParameters.BuildConfig + if buildConfig == nil { + return nil, false + } + var target *ocispecs.Platform + for _, step := range buildConfig.Definition { + if step.Op == nil || step.Op.GetExec() == nil || step.Op.GetExec().Meta == nil { + continue + } + for _, env := range step.Op.GetExec().Meta.Env { + value, ok := strings.CutPrefix(env, "TARGETPLATFORM=") + if !ok || value == "" { + continue + } + platform, err := platforms.Parse(value) + if err != nil { + return nil, false + } + norm := platforms.Normalize(platform) + if target != nil && platforms.Format(*target) != platforms.Format(norm) { + return nil, false + } + target = &norm + } + } + return target, target != nil +} + +// ResolvedDependencies returns every material recorded on the predicate. +// Classification by URI scheme is left to the caller (see MaterialsResolver). +func (p *Predicate) ResolvedDependencies() []slsa1.ResourceDescriptor { + return p.BuildDefinition.ResolvedDependencies +} + +// IsMinMode reports whether the provenance was recorded with mode=min. Only +// mode=max records the build definition. mode=min also drops build arguments, +// labels, secrets and SSH from the recorded request, so the original build +// cannot be reconstructed from it. +func (p *Predicate) IsMinMode() bool { + bc := p.BuildDefinition.InternalParameters.BuildConfig + return bc == nil || len(bc.Definition) == 0 +} diff --git a/replay/predicate_test.go b/replay/predicate_test.go new file mode 100644 index 000000000000..ef361c3df2fb --- /dev/null +++ b/replay/predicate_test.go @@ -0,0 +1,152 @@ +package replay + +import ( + "testing" + + "github.com/containerd/platforms" + slsa "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/common" + slsa1 "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v1" + provenancetypes "github.com/moby/buildkit/solver/llbsolver/provenance/types" + solverpb "github.com/moby/buildkit/solver/pb" + "github.com/stretchr/testify/require" +) + +func TestPredicateMethods(t *testing.T) { + raw := provenancetypes.ProvenancePredicateSLSA1{ + BuildDefinition: provenancetypes.ProvenanceBuildDefinitionSLSA1{ + ExternalParameters: provenancetypes.ProvenanceExternalParametersSLSA1{ + ConfigSource: provenancetypes.ProvenanceConfigSourceSLSA1{ + URI: "https://example.com/build", + Digest: slsa.DigestSet{"sha256": "deadbeef"}, + Path: "Dockerfile", + }, + Request: provenancetypes.Parameters{ + Frontend: "gateway.v0", + Args: map[string]string{ + "target": "release", + "build-arg:FOO": "bar", + "label:maintainer": "me", + "attest:sbom": "generator=scanner", + "attest:provenance": "mode=max", + }, + Secrets: []*provenancetypes.Secret{{ID: "github_token"}, {ID: "npm_token", Optional: true}}, + SSH: []*provenancetypes.SSH{{ID: "default"}}, + Locals: []*provenancetypes.LocalSource{{Name: "context"}}, + }, + }, + InternalParameters: provenancetypes.ProvenanceInternalParametersSLSA1{ + BuildConfig: &provenancetypes.BuildConfig{ + Definition: []provenancetypes.BuildStep{{ID: "step-0"}}, + }, + }, + ProvenanceBuildDefinition: slsa1.ProvenanceBuildDefinition{ + ResolvedDependencies: []slsa1.ResourceDescriptor{ + {URI: "docker-image://alpine:latest", Digest: slsa.DigestSet{"sha256": "aaaa"}}, + {URI: "https://example.com/pkg.tar.gz", Digest: slsa.DigestSet{"sha256": "bbbb"}}, + }, + }, + }, + } + pred := (*Predicate)(&raw) + + t.Run("Frontend returns recorded frontend", func(t *testing.T) { + require.Equal(t, "gateway.v0", pred.Frontend()) + }) + + t.Run("Frontend falls back to dockerfile.v0", func(t *testing.T) { + empty := &Predicate{} + require.Equal(t, defaultFrontend, empty.Frontend()) + }) + + t.Run("FrontendAttrs strips attestation attrs", func(t *testing.T) { + attrs := pred.FrontendAttrs() + require.Equal(t, "release", attrs["target"]) + require.Equal(t, "bar", attrs["build-arg:FOO"]) + require.Equal(t, "me", attrs["label:maintainer"]) + _, hasSBOM := attrs["attest:sbom"] + require.False(t, hasSBOM, "attest:sbom should be filtered out") + _, hasProv := attrs["attest:provenance"] + require.False(t, hasProv, "attest:provenance should be filtered out") + }) + + t.Run("FrontendAttrs returns fresh map", func(t *testing.T) { + attrs := pred.FrontendAttrs() + attrs["injected"] = "yes" + // The predicate's underlying map should be untouched. + require.NotContains(t, pred.BuildDefinition.ExternalParameters.Request.Args, "injected") + }) + + t.Run("ConfigSource", func(t *testing.T) { + cs := pred.ConfigSource() + require.Equal(t, "https://example.com/build", cs.URI) + require.Equal(t, "Dockerfile", cs.Path) + }) + + t.Run("Secrets", func(t *testing.T) { + secrets := pred.Secrets() + require.Len(t, secrets, 2) + require.Equal(t, "github_token", secrets[0].ID) + require.False(t, secrets[0].Optional) + require.True(t, secrets[1].Optional) + }) + + t.Run("SSH", func(t *testing.T) { + ssh := pred.SSH() + require.Len(t, ssh, 1) + require.Equal(t, "default", ssh[0].ID) + }) + + t.Run("Locals", func(t *testing.T) { + locals := pred.Locals() + require.Len(t, locals, 1) + require.Equal(t, "context", locals[0].Name) + }) + + t.Run("ResolvedDependencies", func(t *testing.T) { + deps := pred.ResolvedDependencies() + require.Len(t, deps, 2) + require.Equal(t, "docker-image://alpine:latest", deps[0].URI) + }) + + t.Run("IsMinMode false with build definition", func(t *testing.T) { + require.False(t, pred.IsMinMode()) + }) + + t.Run("IsMinMode true when empty", func(t *testing.T) { + empty := &Predicate{} + require.True(t, empty.IsMinMode()) + }) + + t.Run("IsMinMode true when BuildConfig has no steps", func(t *testing.T) { + p := &Predicate{} + p.BuildDefinition.InternalParameters.BuildConfig = &provenancetypes.BuildConfig{} + require.True(t, p.IsMinMode()) + }) +} + +func TestPredicateFallbackTargetPlatform(t *testing.T) { + execStep := func(env ...string) provenancetypes.BuildStep { + return provenancetypes.BuildStep{ + Op: &solverpb.Op{Op: &solverpb.Op_Exec{Exec: &solverpb.ExecOp{Meta: &solverpb.Meta{Env: env}}}}, + } + } + + pred := &Predicate{} + pred.BuildDefinition.InternalParameters.BuilderPlatform = "linux/amd64" + pred.BuildDefinition.InternalParameters.BuildConfig = &provenancetypes.BuildConfig{ + Definition: []provenancetypes.BuildStep{ + execStep("BUILDPLATFORM=linux/amd64", "TARGETPLATFORM=linux/arm64"), + execStep("TARGETPLATFORM=linux/arm64"), + }, + } + platform, ok := pred.FallbackTargetPlatform() + require.True(t, ok) + require.Equal(t, "linux/arm64", platforms.Format(*platform)) + + pred.BuildDefinition.InternalParameters.BuildConfig.Definition = append( + pred.BuildDefinition.InternalParameters.BuildConfig.Definition, + execStep("TARGETPLATFORM=linux/amd64"), + ) + _, ok = pred.FallbackTargetPlatform() + require.False(t, ok, "conflicting target platforms must not be guessed") +} diff --git a/replay/snapshot.go b/replay/snapshot.go new file mode 100644 index 000000000000..58c40be62704 --- /dev/null +++ b/replay/snapshot.go @@ -0,0 +1,989 @@ +package replay + +import ( + "archive/tar" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "io/fs" + "maps" + "net/url" + "os" + "path/filepath" + "slices" + "strings" + "sync" + + "github.com/containerd/containerd/v2/core/content" + "github.com/containerd/containerd/v2/core/images" + "github.com/containerd/containerd/v2/core/remotes" + contentlocal "github.com/containerd/containerd/v2/plugins/content/local" + "github.com/containerd/errdefs" + "github.com/containerd/platforms" + "github.com/distribution/reference" + "github.com/docker/buildx/builder" + "github.com/docker/buildx/util/buildflags" + "github.com/docker/buildx/util/imagetools" + "github.com/docker/buildx/util/progress" + "github.com/docker/cli/cli/command" + slsa1 "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v1" + "github.com/moby/buildkit/client" + "github.com/moby/buildkit/client/ociindex" + "github.com/moby/buildkit/util/contentutil" + "github.com/moby/buildkit/util/purl" + digest "github.com/opencontainers/go-digest" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/pkg/errors" +) + +// withMediaTypeKeyPrefix registers ref-key prefixes for non-standard media +// types that the snapshot chain walker will encounter. Without this, +// containerd's remotes.MakeRefKey falls to its default branch and logs +// "reference for unknown type: …" when copying the OCI 1.1 empty-config +// blob that sits inside buildx attestation manifests. +func withMediaTypeKeyPrefix(ctx context.Context) context.Context { + return remotes.WithMediaTypeKeyPrefix(ctx, "application/vnd.oci.empty.v1+json", "empty") +} + +// SnapshotRequest is the input to Snapshot. +type SnapshotRequest struct { + // Targets are the per-platform (subject, predicate) pairs to snapshot. + // Each subject must carry a non-empty AttestationManifest descriptor + // (image / oci-layout subjects only; attestation-file inputs are + // rejected upstream). + Targets []Target + // IncludeMaterials controls whether material content is copied and the + // materials artifact manifest is emitted. + IncludeMaterials bool + // Materials resolves image / http / container-blob materials to a local + // (descriptor, provider) pair. Required when IncludeMaterials is true. + Materials *MaterialsResolver + // Output is the parsed --output spec. Exactly one form is allowed + // (local / oci / registry). + Output *buildflags.ExportEntry + // Progress receives step events and non-fatal warnings. May be nil — + // in that case events are silently dropped. + Progress progress.Writer +} + +// Snapshot produces a replay snapshot for the supplied subjects/predicates and +// writes it through the configured --output target. This function does NOT +// invoke build.Build — snapshot is pure content movement plus manifest +// assembly. +// +// dockerCli + builderName are consumed only to construct a buildx image +// resolver so that image materials can be fetched from their recorded +// registries. They may be zero when all materials are resolvable purely via +// req.Materials (e.g. tests that pre-pin an --materials=oci-layout store). +func Snapshot(ctx context.Context, dockerCli command.Cli, builderName string, req *SnapshotRequest) error { + if req == nil { + return errors.New("nil snapshot request") + } + if req.Output == nil { + return errors.New("snapshot: --output is required") + } + stage, root, _, err := assembleSnapshot(ctx, dockerCli, builderName, req) + if err != nil { + return err + } + return writeSnapshotOutput(ctx, stage, root, req.Output) +} + +// assembleSnapshot runs the staging phase shared by real-run and dry-run: +// validates targets, stages every blob the snapshot would emit, and +// returns the root descriptor plus per-target staging stores (for dry-run +// consumers that want per-target artifact lists). The real run only cares +// about the first store and root. +func assembleSnapshot(ctx context.Context, dockerCli command.Cli, builderName string, req *SnapshotRequest) (*stagingStore, ocispecs.Descriptor, []*stagingStore, error) { + if req == nil { + return nil, ocispecs.Descriptor{}, nil, errors.New("nil snapshot request") + } + if len(req.Targets) == 0 { + return nil, ocispecs.Descriptor{}, nil, errors.New("no targets to snapshot") + } + + // Register ref-key prefixes for non-standard media types so + // containerd does not log spurious "reference for unknown type" + // warnings while copying the attestation chain. + ctx = withMediaTypeKeyPrefix(ctx) + + // progress logger — nop when the caller did not supply a Writer. + var pwlog progress.Logger = func(*client.SolveStatus) {} + if req.Progress != nil { + pwlog = req.Progress.Write + } + + // Shared warn-once ledger: each (category, key) pair prints at most + // one warning across the whole snapshot (e.g. the same git URI + // referenced from every platform should not flood output). + warn := newWarnOnce() + + // One staging store per target — gives dry-run a clean per-target + // descriptor list and keeps the real-run assembly deterministic. + stages := make([]*stagingStore, 0, len(req.Targets)) + // Merged stage for real-run output. Each target's stage is folded in + // after assembly so a single flush writes everything. + merged := newStagingStore() + + // Lazily-constructed registry resolver for image materials (reused across + // subjects). buildx resolver is nil-safe so we keep the builder/auth setup + // deferred until an image material is seen. + var registryResolver *imagetools.Resolver + lazyResolver := func() (*imagetools.Resolver, error) { + if registryResolver != nil { + return registryResolver, nil + } + if dockerCli == nil { + registryResolver = imagetools.New(imagetools.Opt{}) + return registryResolver, nil + } + b, err := builder.New(dockerCli, builder.WithName(builderName)) + if err != nil { + return nil, err + } + imgOpt, err := b.ImageOpt() + if err != nil { + return nil, err + } + registryResolver = imagetools.New(imgOpt) + return registryResolver, nil + } + + emptyConfigDesc := ocispecs.Descriptor{ + MediaType: ociEmptyConfigMediaType, + Digest: digest.Digest(ociEmptyConfigDigest), + Size: ociEmptyConfigSize, + } + + perPlatformDescs := make([]ocispecs.Descriptor, 0, len(req.Targets)) + + for ti, t := range req.Targets { + s, pred := t.Subject, t.Predicate + if s == nil || pred == nil { + return nil, ocispecs.Descriptor{}, nil, errors.New("target has nil subject or predicate") + } + if s.IsAttestationFile() { + return nil, ocispecs.Descriptor{}, nil, ErrUnsupportedSubject("snapshot requires an image or oci-layout subject") + } + if s.AttestationManifest().Digest == "" { + return nil, ocispecs.Descriptor{}, nil, ErrNoProvenance(s.InputRef()) + } + + stage := newStagingStore() + if err := stage.writeRaw(ctx, emptyConfigDesc, OCIEmptyConfigBytes()); err != nil { + return nil, ocispecs.Descriptor{}, nil, errors.Wrap(err, "write empty config") + } + + var ppDesc ocispecs.Descriptor + targetName := fmt.Sprintf("[%d/%d] snapshot %s", ti+1, len(req.Targets), snapshotTargetLabel(s)) + err := progress.Wrap(targetName, pwlog, func(sub progress.SubLogger) error { + d, err := snapshotOneTarget(ctx, stage, s, pred, req, lazyResolver, warn, sub) + if err != nil { + return err + } + ppDesc = d + return nil + }) + if err != nil { + return nil, ocispecs.Descriptor{}, nil, err + } + if s.Descriptor.Platform != nil { + p := *s.Descriptor.Platform + ppDesc.Platform = &p + } + perPlatformDescs = append(perPlatformDescs, ppDesc) + stages = append(stages, stage) + + if err := mergeStage(ctx, merged, stage); err != nil { + return nil, ocispecs.Descriptor{}, nil, err + } + } + + // Root descriptor that the output writer addresses. + var root ocispecs.Descriptor + if len(perPlatformDescs) == 1 { + root = perPlatformDescs[0] + } else { + _, rootDesc, rootData, err := MultiPlatformSnapshotIndex(perPlatformDescs) + if err != nil { + return nil, ocispecs.Descriptor{}, nil, err + } + if err := merged.writeRaw(ctx, ocispecs.Descriptor{ + MediaType: ocispecs.MediaTypeImageIndex, + Digest: rootDesc.Digest, + Size: rootDesc.Size, + }, rootData); err != nil { + return nil, ocispecs.Descriptor{}, nil, errors.Wrap(err, "write multi-platform snapshot index") + } + root = rootDesc + } + + return merged, root, stages, nil +} + +// mergeStage copies every blob from src into dst. Used to fold per-target +// stages into the merged stage that the real-run output writer flushes. +func mergeStage(ctx context.Context, dst, src *stagingStore) error { + src.mu.Lock() + order := slices.Clone(src.order) + descs := make(map[digest.Digest]ocispecs.Descriptor, len(src.descs)) + maps.Copy(descs, src.descs) + src.mu.Unlock() + for _, dgst := range order { + desc := descs[dgst] + ra, err := src.ReaderAt(ctx, desc) + if err != nil { + return errors.Wrapf(err, "read %s", dgst) + } + err = content.WriteBlob(ctx, dst, "snapshot-"+dgst.String(), content.NewReader(ra), desc) + ra.Close() + if err != nil && !errdefs.IsAlreadyExists(err) { + return errors.Wrapf(err, "merge %s", dgst) + } + dst.record(desc) + } + return nil +} + +// snapshotOneTarget copies the attestation chain and each material for a +// single (subject, predicate) target into the staging buffer and assembles +// the per-platform snapshot index. Each unit of work is wrapped in a +// progress sub-step so the caller's printer can render what is happening. +func snapshotOneTarget( + ctx context.Context, + stage *stagingStore, + s *Subject, + pred *Predicate, + req *SnapshotRequest, + lazyResolver func() (*imagetools.Resolver, error), + warn *warnOnce, + sub progress.SubLogger, +) (ocispecs.Descriptor, error) { + attestMfst := s.AttestationManifest() + if err := sub.Wrap(fmt.Sprintf("copy attestation manifest %s", attestMfst.Digest), func() error { + return contentutil.CopyChain(ctx, stage, s.Provider, attestMfst) + }); err != nil { + return ocispecs.Descriptor{}, errors.Wrapf(err, "copy attestation manifest chain for %s", s.InputRef()) + } + + var ( + materialsLayers []ocispecs.Descriptor + imageMfsts []ocispecs.Descriptor + ) + seenLayerDigest := map[digest.Digest]struct{}{} + seenImageMfst := map[digest.Digest]struct{}{} + + for _, m := range pred.ResolvedDependencies() { + switch classifyMaterial(m) { + case materialKindImage: + if !req.IncludeMaterials { + continue + } + rootDgst := resourceDigest(m) + if rootDgst == "" { + return ocispecs.Descriptor{}, errors.Errorf("image material %q has no sha256 digest", m.URI) + } + if err := sub.Wrap(fmt.Sprintf("image material %s", m.URI), func() error { + rootDesc, rootProvider, err := resolveImageMaterial(ctx, req.Materials, lazyResolver, m, rootDgst, WithPlatform(s.Descriptor.Platform), WithBuilderPlatform(pred.BuilderPlatform())) + if err != nil { + return err + } + if err := stage.copyBlob(ctx, rootProvider, rootDesc); err != nil { + return errors.Wrapf(err, "copy image root %s", rootDesc.Digest) + } + if _, ok := seenLayerDigest[rootDesc.Digest]; !ok { + seenLayerDigest[rootDesc.Digest] = struct{}{} + materialsLayers = append(materialsLayers, ocispecs.Descriptor{ + MediaType: rootDesc.MediaType, + Digest: rootDesc.Digest, + Size: rootDesc.Size, + }) + } + platDesc, err := pickPlatformChild(ctx, rootProvider, rootDesc, s.Descriptor.Platform, pred.BuilderPlatform()) + if err != nil { + return errors.Wrapf(err, "pick platform child for %s", m.URI) + } + if err := contentutil.CopyChain(ctx, stage, rootProvider, platDesc); err != nil { + return errors.Wrapf(err, "copy image material chain for %s", m.URI) + } + if _, ok := seenImageMfst[platDesc.Digest]; !ok { + seenImageMfst[platDesc.Digest] = struct{}{} + imageMfsts = append(imageMfsts, platDesc) + } + return nil + }); err != nil { + return ocispecs.Descriptor{}, err + } + + case materialKindHTTP: + if !req.IncludeMaterials { + continue + } + dgst := resourceDigest(m) + if dgst == "" { + return ocispecs.Descriptor{}, errors.Errorf("http material %q has no sha256 digest", m.URI) + } + if err := sub.Wrap(fmt.Sprintf("http material %s", m.URI), func() error { + desc, provider, err := req.Materials.Resolve(ctx, m.URI, dgst) + if err != nil { + return errors.Wrapf(err, "resolve http material %s", m.URI) + } + if provider == nil { + return ErrMaterialNotFound(m.URI, dgst.String()) + } + desc.MediaType = layerMediaTypeHTTP + if err := stage.copyBlob(ctx, provider, desc); err != nil { + return errors.Wrapf(err, "copy http material %s", desc.Digest) + } + if _, ok := seenLayerDigest[desc.Digest]; !ok { + seenLayerDigest[desc.Digest] = struct{}{} + materialsLayers = append(materialsLayers, desc) + } + return nil + }); err != nil { + return ocispecs.Descriptor{}, err + } + + case materialKindContainerBlob: + if !req.IncludeMaterials { + continue + } + dgst := resourceDigest(m) + if dgst == "" { + return ocispecs.Descriptor{}, errors.Errorf("container-blob material has no sha256 digest") + } + if err := sub.Wrap(fmt.Sprintf("container-blob material %s", dgst), func() error { + desc, provider, err := req.Materials.Resolve(ctx, m.URI, dgst) + if err != nil { + return errors.Wrapf(err, "resolve container-blob material %s", dgst) + } + if provider == nil { + return ErrMaterialNotFound(m.URI, dgst.String()) + } + if desc.MediaType == "" { + desc.MediaType = layerMediaTypeContainerBlob + } + if err := stage.copyBlob(ctx, provider, desc); err != nil { + return errors.Wrapf(err, "copy container-blob material %s", desc.Digest) + } + if _, ok := seenLayerDigest[desc.Digest]; !ok { + seenLayerDigest[desc.Digest] = struct{}{} + materialsLayers = append(materialsLayers, desc) + } + return nil + }); err != nil { + return ocispecs.Descriptor{}, err + } + + case materialKindGit: + // Git packfile snapshotting is not implemented. Dedup so a + // single URI referenced across many platforms warns once. + warn.Log(sub, "git:"+m.URI, fmt.Sprintf("git material %q is not included in the snapshot (not yet supported)", m.URI)) + + case materialKindUnknown: + warn.Log(sub, "unknown:"+m.URI, fmt.Sprintf("material with URI %q and no recognised scheme is ignored", m.URI)) + } + } + + var materialsManifestDesc ocispecs.Descriptor + if req.IncludeMaterials { + _, mDesc, mData, err := MaterialsManifest(materialsLayers) + if err != nil { + return ocispecs.Descriptor{}, err + } + if err := stage.writeRaw(ctx, ocispecs.Descriptor{ + MediaType: ocispecs.MediaTypeImageManifest, + Digest: mDesc.Digest, + Size: mDesc.Size, + }, mData); err != nil { + return ocispecs.Descriptor{}, errors.Wrap(err, "write materials manifest") + } + materialsManifestDesc = mDesc + } + + attestDesc := attestMfst + // The attestation manifest's descriptor in the original image index + // carries Docker reference annotations (vnd.docker.reference.*) that + // are meaningless for the snapshot-subject role — strip them. Preserve + // the manifest's own artifactType so consumers can tell this is a + // Docker attestation manifest without fetching the body. + attestDesc.Annotations = nil + attestMfstData, err := content.ReadBlob(ctx, stage, attestMfst) + if err != nil { + return ocispecs.Descriptor{}, errors.Wrapf(err, "read attestation manifest %s", attestMfst.Digest) + } + var attestMfstBody ocispecs.Manifest + if err := json.Unmarshal(attestMfstData, &attestMfstBody); err != nil { + return ocispecs.Descriptor{}, errors.Wrapf(err, "parse attestation manifest %s", attestMfst.Digest) + } + attestDesc.ArtifactType = attestMfstBody.ArtifactType + + _, ppDesc, ppData, err := PerPlatformSnapshotIndex(attestDesc, materialsManifestDesc, imageMfsts) + if err != nil { + return ocispecs.Descriptor{}, err + } + if err := stage.writeRaw(ctx, ocispecs.Descriptor{ + MediaType: ocispecs.MediaTypeImageIndex, + Digest: ppDesc.Digest, + Size: ppDesc.Size, + }, ppData); err != nil { + return ocispecs.Descriptor{}, errors.Wrap(err, "write per-platform snapshot index") + } + return ppDesc, nil +} + +// stagingStore wraps a contentutil.Buffer with a few helpers and also +// tracks every digest written into the buffer so the final flush can emit +// exactly that set (contentutil.Buffer.Walk is a stub, so we maintain our +// own digest list). Any ingester-facing code that bypasses the helpers +// (e.g. contentutil.CopyChain) must be invoked with stagingStore itself — +// it implements content.Ingester — so those writes are tracked too. +type stagingStore struct { + buffer contentutil.Buffer + mu sync.Mutex + order []digest.Digest + descs map[digest.Digest]ocispecs.Descriptor +} + +func newStagingStore() *stagingStore { + return &stagingStore{ + buffer: contentutil.NewBuffer(), + descs: map[digest.Digest]ocispecs.Descriptor{}, + } +} + +// record captures the full descriptor for each blob written into the stage. +// Later writes merge fields (e.g. a FetchHandler Writer has the MediaType +// from content.WithDescriptor; writeRaw and copyBlob pass full descriptors). +func (s *stagingStore) record(desc ocispecs.Descriptor) { + if desc.Digest == "" { + return + } + s.mu.Lock() + defer s.mu.Unlock() + if prev, ok := s.descs[desc.Digest]; ok { + if prev.MediaType == "" && desc.MediaType != "" { + prev.MediaType = desc.MediaType + } + if prev.Size == 0 && desc.Size > 0 { + prev.Size = desc.Size + } + s.descs[desc.Digest] = prev + return + } + s.descs[desc.Digest] = desc + s.order = append(s.order, desc.Digest) +} + +// ReaderAt satisfies content.Provider, delegating to the wrapped buffer. +func (s *stagingStore) ReaderAt(ctx context.Context, desc ocispecs.Descriptor) (content.ReaderAt, error) { + return s.buffer.ReaderAt(ctx, desc) +} + +// Writer satisfies content.Ingester; commits are tracked so the final dump +// can enumerate exactly what the snapshot added. +func (s *stagingStore) Writer(ctx context.Context, opts ...content.WriterOpt) (content.Writer, error) { + w, err := s.buffer.Writer(ctx, opts...) + if err != nil { + return nil, err + } + var desc ocispecs.Descriptor + var wOpts content.WriterOpts + for _, o := range opts { + _ = o(&wOpts) + } + desc = wOpts.Desc + return &stagingWriter{Writer: w, store: s, desc: desc}, nil +} + +type stagingWriter struct { + content.Writer + store *stagingStore + desc ocispecs.Descriptor +} + +func (w *stagingWriter) Commit(ctx context.Context, size int64, expected digest.Digest, opts ...content.Opt) error { + if err := w.Writer.Commit(ctx, size, expected, opts...); err != nil { + return err + } + d := expected + if d == "" { + d = w.Digest() + } + recorded := w.desc + recorded.Digest = d + if recorded.Size == 0 { + recorded.Size = size + } + w.store.record(recorded) + return nil +} + +// writeRaw writes the bytes dt under desc.Digest. Already-present content is +// treated as a no-op (the staging buffer is content-addressable). +func (s *stagingStore) writeRaw(ctx context.Context, desc ocispecs.Descriptor, dt []byte) error { + if desc.Digest == "" { + return errors.New("writeRaw: empty digest") + } + if desc.Size == 0 { + desc.Size = int64(len(dt)) + } + ref := "snapshot-" + desc.Digest.String() + err := content.WriteBlob(ctx, s, ref, bytes.NewReader(dt), desc) + if err != nil && !errdefs.IsAlreadyExists(err) { + return errors.WithStack(err) + } + s.record(desc) + return nil +} + +// copyBlob copies one blob from src through into the staging buffer. This is +// a content-only copy — no children / manifest walk. +func (s *stagingStore) copyBlob(ctx context.Context, src content.Provider, desc ocispecs.Descriptor) error { + if desc.Digest == "" { + return errors.New("copyBlob: empty digest") + } + if err := contentutil.Copy(ctx, s, src, desc, "snapshot-"+desc.Digest.String(), nil); err != nil { + return err + } + s.record(desc) + return nil +} + +// resolveImageMaterial looks up a provenance image material by its recorded +// URI + root digest. Prefers a locally-configured MaterialsResolver (which +// may be snapshot-backed) and falls back to fetching from the registry +// derived from the pkg:docker purl. +func resolveImageMaterial( + ctx context.Context, + resolver *MaterialsResolver, + lazyResolver func() (*imagetools.Resolver, error), + m slsa1.ResourceDescriptor, + rootDgst digest.Digest, + opts ...ResolveOption, +) (ocispecs.Descriptor, content.Provider, error) { + if resolver != nil { + desc, provider, err := resolver.Resolve(ctx, m.URI, rootDgst, opts...) + if err == nil && provider != nil { + if desc.MediaType == "" { + desc.MediaType = ocispecs.MediaTypeImageIndex + } + return desc, provider, nil + } + if err != nil { + var mnf *MaterialNotFoundError + if !errors.As(err, &mnf) { + return ocispecs.Descriptor{}, nil, err + } + if !resolver.Sentinel() { + return ocispecs.Descriptor{}, nil, err + } + } + } + + // Fall back to a registry fetch driven off the pkg:docker purl. + ref, _, err := purl.PURLToRef(m.URI) + if err != nil { + return ocispecs.Descriptor{}, nil, errors.Wrapf(err, "invalid image material URI %q", m.URI) + } + named, err := reference.ParseNormalizedNamed(ref) + if err != nil { + return ocispecs.Descriptor{}, nil, errors.Wrapf(err, "invalid image material reference %q", ref) + } + // A digest embedded in the purl must agree with the separately recorded + // material digest, otherwise the snapshot would package a different + // material from the one the provenance describes. + if d, ok := named.(reference.Digested); ok && d.Digest() != rootDgst { + return ocispecs.Descriptor{}, nil, errors.Errorf("image material %s digest %s does not match recorded digest %s", m.URI, d.Digest(), rootDgst) + } + // Address the root directly by digest so we fetch the exact recorded + // index regardless of tag mutations since the original build. + fetchRef := reference.TrimNamed(named).String() + "@" + rootDgst.String() + imgResolver, err := lazyResolver() + if err != nil { + return ocispecs.Descriptor{}, nil, err + } + // Resolve the fetchRef first so we learn the root descriptor's size + + // mediaType. Without a size, contentutil.FromFetcher's ReaderAt reports + // size=0 and ReadBlob returns an empty payload (silently succeeding with + // a JSON decode error downstream). + _, rootDesc, err := imgResolver.Resolve(ctx, fetchRef) + if err != nil { + return ocispecs.Descriptor{}, nil, errors.Wrapf(err, "resolve image material %s", m.URI) + } + fetcher, err := imgResolver.Fetcher(ctx, fetchRef) + if err != nil { + return ocispecs.Descriptor{}, nil, errors.Wrapf(err, "fetch image material %s", m.URI) + } + provider := contentutil.FromFetcher(fetcher) + if rootDesc.Digest == "" { + rootDesc.Digest = rootDgst + } + if rootDesc.MediaType == "" { + // If the resolver left the mediaType empty, probe it by + // reading the manifest payload. + dt, err := content.ReadBlob(ctx, provider, rootDesc) + if err != nil { + return ocispecs.Descriptor{}, nil, errors.Wrapf(err, "read image material root %s", rootDgst) + } + mt, err := detectManifestMediaType(dt) + if err != nil { + return ocispecs.Descriptor{}, nil, err + } + rootDesc.MediaType = mt + if rootDesc.Size == 0 { + rootDesc.Size = int64(len(dt)) + } + } + return rootDesc, provider, nil +} + +// pickPlatformChild turns a "root" descriptor for an image material into the +// platform-specific manifest descriptor to store in the snapshot. Provenance +// only records the root index digest, so replay has to guess which child +// BuildKit actually resolved. The matcher prefers the subject's platform +// (FROM-style base images resolve at TARGETPLATFORM) and falls back to the +// builder platform recorded on the predicate (frontend images and +// cross-compile toolchains run on the build host). An index with a single +// child is always returned as-is. +func pickPlatformChild(ctx context.Context, provider content.Provider, root ocispecs.Descriptor, subjectPlat *ocispecs.Platform, builderPlat ocispecs.Platform) (ocispecs.Descriptor, error) { + switch root.MediaType { + case ocispecs.MediaTypeImageManifest, images.MediaTypeDockerSchema2Manifest: + return root, nil + case ocispecs.MediaTypeImageIndex, images.MediaTypeDockerSchema2ManifestList: + dt, err := content.ReadBlob(ctx, provider, root) + if err != nil { + return ocispecs.Descriptor{}, errors.WithStack(err) + } + var idx ocispecs.Index + if err := json.Unmarshal(dt, &idx); err != nil { + return ocispecs.Descriptor{}, errors.WithStack(err) + } + + matcher := replayPlatformMatcher(subjectPlat, builderPlat) + var best *ocispecs.Descriptor + for i := range idx.Manifests { + c := idx.Manifests[i] + if c.Platform == nil || !matcher.Match(*c.Platform) { + continue + } + if best == nil || matcher.Less(*c.Platform, *best.Platform) { + best = &c + } + } + if best != nil { + return *best, nil + } + if len(idx.Manifests) == 1 { + return idx.Manifests[0], nil + } + return ocispecs.Descriptor{}, errors.Errorf("image material index %s has no child matching subject platform %s or builder %s", root.Digest, formatPlatformPtr(subjectPlat), platforms.Format(builderPlat)) + default: + return ocispecs.Descriptor{}, errors.Errorf("unsupported image material root media type %q", root.MediaType) + } +} + +// replayPlatformMatcher returns a MatchComparer that prefers the subject's +// platform then falls back to the builder platform recorded on the +// predicate. When the subject has no platform, the builder platform is used +// directly (buildx records builderPlatform as the effective default for any +// subject produced by that build). +func replayPlatformMatcher(subjectPlat *ocispecs.Platform, builderPlat ocispecs.Platform) platforms.MatchComparer { + if subjectPlat == nil || platforms.Only(*subjectPlat).Match(builderPlat) { + return platforms.Only(builderPlat) + } + return platforms.Ordered(*subjectPlat, builderPlat) +} + +func formatPlatformPtr(p *ocispecs.Platform) string { + if p == nil { + return "" + } + return platforms.Format(*p) +} + +// snapshotTargetLabel produces a human-readable label for progress output: +// " ()" when the subject has a platform, otherwise just +// the digest. +func snapshotTargetLabel(s *Subject) string { + d := s.Descriptor.Digest.String() + if s.Descriptor.Platform != nil { + return platforms.Format(*s.Descriptor.Platform) + " (" + d + ")" + } + return d +} + +// detectManifestMediaType inspects the first bytes of a manifest blob and +// returns the OCI media type — shared with util/imagetools but re-implemented +// here to avoid importing a single helper. +func detectManifestMediaType(dt []byte) (string, error) { + var probe struct { + MediaType string `json:"mediaType"` + Manifests []json.RawMessage `json:"manifests,omitempty"` + Config json.RawMessage `json:"config,omitempty"` + } + if err := json.Unmarshal(dt, &probe); err != nil { + return "", errors.WithStack(err) + } + if probe.MediaType != "" { + return probe.MediaType, nil + } + if len(probe.Manifests) > 0 { + return ocispecs.MediaTypeImageIndex, nil + } + if len(probe.Config) > 0 { + return ocispecs.MediaTypeImageManifest, nil + } + return "", errors.Errorf("cannot detect media type from manifest payload") +} + +// materialKind classifies a provenance material by its URI scheme. +type materialKind int + +const ( + materialKindUnknown materialKind = iota + materialKindImage + materialKindHTTP + materialKindContainerBlob + materialKindGit +) + +// classifyMaterial picks a materialKind from a ResourceDescriptor's URI. +func classifyMaterial(m slsa1.ResourceDescriptor) materialKind { + switch { + case strings.HasPrefix(m.URI, "pkg:docker/"): + return materialKindImage + case strings.HasPrefix(m.URI, "https://") || strings.HasPrefix(m.URI, "http://"): + if looksLikeGitURL(m.URI) { + return materialKindGit + } + return materialKindHTTP + case strings.HasPrefix(m.URI, "git+") || + strings.HasPrefix(m.URI, "git://") || + strings.HasPrefix(m.URI, "ssh://"): + return materialKindGit + case m.URI == "" && resourceDigest(m) != "": + // No URI but a digest: treat as a container-blob material. Layers + // pulled directly by digest during a build show up this way. + return materialKindContainerBlob + } + return materialKindUnknown +} + +// looksLikeGitURL returns true when an http(s) URL appears to name a git repo +// (the provenance recorder sometimes uses a bare https url for a git remote). +func looksLikeGitURL(raw string) bool { + u, err := url.Parse(raw) + if err != nil { + return false + } + return strings.HasSuffix(strings.ToLower(u.Path), ".git") +} + +// resourceDigest extracts the preferred digest from a SLSA ResourceDescriptor +// (sha256 first, then any other algorithm). Mirrors policy.preferredDigest. +func resourceDigest(m slsa1.ResourceDescriptor) digest.Digest { + if m.Digest == nil { + return "" + } + if v, ok := m.Digest["sha256"]; ok && v != "" { + return digest.NewDigestFromEncoded(digest.SHA256, v) + } + for alg, v := range m.Digest { + if v == "" { + continue + } + return digest.NewDigestFromEncoded(digest.Algorithm(alg), v) + } + return "" +} + +// writeSnapshotOutput materialises the staged snapshot content through the +// selected --output target. oci / registry are supported. +func writeSnapshotOutput( + ctx context.Context, + stage *stagingStore, + root ocispecs.Descriptor, + exp *buildflags.ExportEntry, +) error { + switch exp.Type { + case "oci": + if exp.Destination == "" { + return errors.New("snapshot: type=oci requires dest=") + } + // tar defaults to true — "tar=false" selects the oci-layout + // directory form. The TTY refusal for dest=- lives in the + // command layer; this function trusts its caller. + if exp.Attrs["tar"] == "false" { + return writeOCILayoutDir(ctx, stage, root, exp.Destination) + } + return writeOCILayoutTar(ctx, stage, root, exp.Destination) + case "registry": + return ErrNotImplemented("snapshot registry output") + } + return errors.Errorf("snapshot: unsupported --output type %q (want oci | registry)", exp.Type) +} + +// writeOCILayoutDir writes the snapshot as an OCI layout tree at dest. +// The staging store already contains exactly the content that belongs in +// the snapshot (each target loop staged precisely what was needed), so we +// flush every blob verbatim — no graph walk is required. +func writeOCILayoutDir(ctx context.Context, stage *stagingStore, root ocispecs.Descriptor, dest string) error { + if err := os.MkdirAll(dest, 0o755); err != nil { + return errors.WithStack(err) + } + store, err := contentlocal.NewStore(dest) + if err != nil { + return errors.Wrapf(err, "open store at %s", dest) + } + if err := flushStage(ctx, store, stage); err != nil { + return errors.Wrap(err, "copy snapshot into oci-layout") + } + idx := ociindex.NewStoreIndex(dest) + if err := idx.Put(root, ociindex.Tag("latest")); err != nil { + return errors.Wrapf(err, "update oci-layout index at %s", dest) + } + return nil +} + +// flushStage copies every tracked blob from stage into ingester using the +// complete descriptor recorded when the blob was staged. Registry pushers +// require the media type to distinguish manifests from ordinary blobs. +func flushStage(ctx context.Context, ingester content.Ingester, stage *stagingStore) error { + stage.mu.Lock() + order := slices.Clone(stage.order) + descs := make(map[digest.Digest]ocispecs.Descriptor, len(stage.descs)) + maps.Copy(descs, stage.descs) + stage.mu.Unlock() + + // Registries validate references when manifests and indexes are uploaded. + // Preserve staging order within each tier, but always send blobs first, + // manifests second, and indexes last. + ordered := make([]digest.Digest, 0, len(order)) + for priority := range 3 { + for _, dgst := range order { + if descriptorPushPriority(descs[dgst]) == priority { + ordered = append(ordered, dgst) + } + } + } + for _, dgst := range ordered { + info, err := stage.buffer.Info(ctx, dgst) + if err != nil { + return errors.Wrapf(err, "lookup %s", dgst) + } + desc := descs[dgst] + desc.Digest = info.Digest + desc.Size = info.Size + ra, err := stage.ReaderAt(ctx, desc) + if err != nil { + return errors.Wrapf(err, "read %s", dgst) + } + err = content.WriteBlob(ctx, ingester, "snapshot-"+dgst.String(), content.NewReader(ra), desc) + ra.Close() + if err != nil && !errdefs.IsAlreadyExists(err) { + return errors.Wrapf(err, "write %s (%s)", dgst, desc.MediaType) + } + } + return nil +} + +func descriptorPushPriority(desc ocispecs.Descriptor) int { + if images.IsManifestType(desc.MediaType) { + return 1 + } + if images.IsIndexType(desc.MediaType) { + return 2 + } + return 0 +} + +// writeOCILayoutTar writes the snapshot as an OCI layout tar at dest. +// +// containerd's imgarchive.Export walks the tree via images.Children, which +// returns only config+layers for manifests and manifests[] for indexes — +// it never follows `subject`. Our per-platform snapshot index reaches the +// attestation manifest only through `subject`, so an imgarchive-driven +// export would leave the attestation chain out of the tar. We instead +// materialise the snapshot into a temp oci-layout directory using our +// own walker (which does follow `subject`) and tar that directory. +func writeOCILayoutTar(ctx context.Context, stage *stagingStore, root ocispecs.Descriptor, dest string) error { + tmp, err := os.MkdirTemp("", "buildx-snapshot-tar-") + if err != nil { + return errors.WithStack(err) + } + defer os.RemoveAll(tmp) + if err := writeOCILayoutDir(ctx, stage, root, tmp); err != nil { + return err + } + + var w io.Writer = os.Stdout + if dest != "-" { + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return errors.WithStack(err) + } + f, err := os.Create(dest) + if err != nil { + return errors.WithStack(err) + } + defer f.Close() + w = f + } + return tarDirectory(tmp, w) +} + +// tarDirectory writes the contents of srcDir into w as a tar archive. Entries +// are rooted at the directory's contents (not the directory name itself) so +// the resulting tar matches the OCI image-layout v1 spec: index.json, +// oci-layout and blobs/ at the archive root. +// +// The walk is confined by os.Root so that symlinks or relative components +// inside srcDir cannot escape it and leak unrelated files into the tar. +func tarDirectory(srcDir string, w io.Writer) error { + root, err := os.OpenRoot(srcDir) + if err != nil { + return errors.WithStack(err) + } + defer root.Close() + + tw := tar.NewWriter(w) + defer tw.Close() + + return fs.WalkDir(root.FS(), ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if path == "." { + return nil + } + info, err := d.Info() + if err != nil { + return errors.WithStack(err) + } + hdr, err := tar.FileInfoHeader(info, "") + if err != nil { + return errors.WithStack(err) + } + hdr.Name = filepath.ToSlash(path) + if info.IsDir() { + hdr.Name += "/" + } + if err := tw.WriteHeader(hdr); err != nil { + return errors.WithStack(err) + } + if !info.Mode().IsRegular() { + return nil + } + rf, err := root.Open(path) + if err != nil { + return errors.WithStack(err) + } + defer rf.Close() + if _, err := io.Copy(tw, rf); err != nil { + return errors.WithStack(err) + } + return nil + }) +} diff --git a/replay/snapshot_format.go b/replay/snapshot_format.go new file mode 100644 index 000000000000..338714d113c6 --- /dev/null +++ b/replay/snapshot_format.go @@ -0,0 +1,176 @@ +package replay + +import ( + "encoding/json" + + "github.com/opencontainers/go-digest" + ocispecsgo "github.com/opencontainers/image-spec/specs-go" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/pkg/errors" +) + +// Media types and artifact types that identify buildx snapshots and the +// materials manifest child. +const ( + // ArtifactTypeSnapshot is the artifactType for both the per-platform + // snapshot index and the top-level multi-platform snapshot index. + ArtifactTypeSnapshot = "application/vnd.docker.buildx.snapshots.v1+json" + // ArtifactTypeMaterials is the artifactType for the materials artifact + // manifest nested inside a per-platform snapshot index. + ArtifactTypeMaterials = "application/vnd.docker.buildx.snapshots.materials.v1+json" + + // ociEmptyConfigMediaType is the OCI 1.1 empty descriptor for use as the + // `config:` of an artifact manifest that carries no image configuration. + // See the image-spec manifest guidance for the empty descriptor. + ociEmptyConfigMediaType = "application/vnd.oci.empty.v1+json" + // ociEmptyConfigDigest is the canonical sha256 of the two-byte `{}` + // OCI empty config. + ociEmptyConfigDigest = "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a" + // ociEmptyConfigSize is the size in bytes of the OCI empty config (`{}`). + ociEmptyConfigSize = 2 + + // layerMediaTypeHTTP is the mediaType written for http material blob + // layers (raw bytes served by an http/https material). + layerMediaTypeHTTP = "application/octet-stream" + // layerMediaTypeContainerBlob is the default mediaType used for a + // container-blob layer material copied into the snapshot. + layerMediaTypeContainerBlob = "application/vnd.oci.image.layer.v1.tar+gzip" +) + +// OCIEmptyConfigDescriptor returns the descriptor buildx uses for the empty +// config on the materials artifact manifest. The two bytes of the empty +// config are inlined via the descriptor's `data` field (OCI 1.1) so a +// consumer never has to fetch the empty-config blob separately. +func OCIEmptyConfigDescriptor() ocispecs.Descriptor { + return ocispecs.Descriptor{ + MediaType: ociEmptyConfigMediaType, + Digest: digest.Digest(ociEmptyConfigDigest), + Size: ociEmptyConfigSize, + Data: OCIEmptyConfigBytes(), + } +} + +// OCIEmptyConfigBytes returns the raw bytes of the OCI empty config (`{}`) +// that callers must write into the snapshot content store so the materials +// artifact manifest has a valid content-addressable config blob. +func OCIEmptyConfigBytes() []byte { + return []byte("{}") +} + +// MaterialsManifest builds the materials artifact manifest descriptor (the +// image-manifest document plus its serialized bytes and descriptor) from the +// ordered list of layer descriptors. The caller owns the task of copying the +// referenced layer bytes (and the empty config) into the snapshot store; this +// function produces only the manifest document and its addressable +// descriptor. +// +// The layers parameter is used verbatim and must already include (in order): +// +// 1. http material layers (mediaType application/octet-stream) +// 2. container-blob layers (mediaType vnd.oci.image.layer.v1.tar+gzip or +// the recorded equivalent) +// 3. image-material root-index blobs kept opaque +// (mediaType vnd.oci.image.index.v1+json) +func MaterialsManifest(layers []ocispecs.Descriptor) (ocispecs.Manifest, ocispecs.Descriptor, []byte, error) { + if layers == nil { + layers = []ocispecs.Descriptor{} + } + mfst := ocispecs.Manifest{ + Versioned: ocispecsgo.Versioned{SchemaVersion: 2}, + MediaType: ocispecs.MediaTypeImageManifest, + ArtifactType: ArtifactTypeMaterials, + Config: OCIEmptyConfigDescriptor(), + Layers: layers, + } + dt, err := json.Marshal(mfst) + if err != nil { + return ocispecs.Manifest{}, ocispecs.Descriptor{}, nil, errors.Wrap(err, "marshal materials manifest") + } + desc := ocispecs.Descriptor{ + MediaType: ocispecs.MediaTypeImageManifest, + ArtifactType: ArtifactTypeMaterials, + Digest: digest.FromBytes(dt), + Size: int64(len(dt)), + } + return mfst, desc, dt, nil +} + +// PerPlatformSnapshotIndex builds a per-platform snapshot index. +// `attestManifest` is the ORIGINAL provenance attestation manifest +// descriptor; it becomes the index's `subject` and its blob must be copied +// into the snapshot content store by the caller. +// +// `materialsManifestDesc` — descriptor of the materials artifact manifest — +// is included as the first entry in `manifests[]` when non-zero. Passing a +// zero descriptor omits the materials manifest entirely (used when +// `--include-materials=false`). +// +// `imageMaterialManifests` — platform-specific image manifests for each image +// material — follow the materials manifest. Each descriptor's `Digest` +// addresses the platform-specific manifest that the original build actually +// used; its chain (manifest + config + layers) is expected to be present in +// the snapshot store. +func PerPlatformSnapshotIndex( + attestManifest ocispecs.Descriptor, + materialsManifestDesc ocispecs.Descriptor, + imageMaterialManifests []ocispecs.Descriptor, +) (ocispecs.Index, ocispecs.Descriptor, []byte, error) { + manifests := make([]ocispecs.Descriptor, 0, 1+len(imageMaterialManifests)) + if materialsManifestDesc.Digest != "" { + manifests = append(manifests, materialsManifestDesc) + } + manifests = append(manifests, imageMaterialManifests...) + + idx := ocispecs.Index{ + Versioned: ocispecsgo.Versioned{SchemaVersion: 2}, + MediaType: ocispecs.MediaTypeImageIndex, + ArtifactType: ArtifactTypeSnapshot, + Subject: descriptorPtr(attestManifest), + Manifests: manifests, + } + dt, err := json.Marshal(idx) + if err != nil { + return ocispecs.Index{}, ocispecs.Descriptor{}, nil, errors.Wrap(err, "marshal per-platform snapshot index") + } + desc := ocispecs.Descriptor{ + MediaType: ocispecs.MediaTypeImageIndex, + ArtifactType: ArtifactTypeSnapshot, + Digest: digest.FromBytes(dt), + Size: int64(len(dt)), + } + return idx, desc, dt, nil +} + +// MultiPlatformSnapshotIndex wraps N per-platform snapshot index descriptors +// into a top-level index. Each input descriptor must already carry its +// `Platform` field so consumers can pick the right child. +func MultiPlatformSnapshotIndex(perPlatform []ocispecs.Descriptor) (ocispecs.Index, ocispecs.Descriptor, []byte, error) { + idx := ocispecs.Index{ + Versioned: ocispecsgo.Versioned{SchemaVersion: 2}, + MediaType: ocispecs.MediaTypeImageIndex, + ArtifactType: ArtifactTypeSnapshot, + Manifests: perPlatform, + } + dt, err := json.Marshal(idx) + if err != nil { + return ocispecs.Index{}, ocispecs.Descriptor{}, nil, errors.Wrap(err, "marshal multi-platform snapshot index") + } + desc := ocispecs.Descriptor{ + MediaType: ocispecs.MediaTypeImageIndex, + ArtifactType: ArtifactTypeSnapshot, + Digest: digest.FromBytes(dt), + Size: int64(len(dt)), + } + return idx, desc, dt, nil +} + +// descriptorPtr returns a pointer to desc, or nil when desc is the zero +// descriptor (so callers that pass no subject do not end up with an empty +// stub). +func descriptorPtr(desc ocispecs.Descriptor) *ocispecs.Descriptor { + if desc.Digest == "" { + return nil + } + out := desc + return &out +} diff --git a/replay/snapshot_format_test.go b/replay/snapshot_format_test.go new file mode 100644 index 000000000000..ce872356b5a6 --- /dev/null +++ b/replay/snapshot_format_test.go @@ -0,0 +1,170 @@ +package replay + +import ( + "encoding/json" + "testing" + + digest "github.com/opencontainers/go-digest" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/stretchr/testify/require" +) + +// Round-trip tests for the snapshot-format writers. Each test builds a +// concrete document through the writer, serializes it back through json, +// and asserts the expected shape. + +func TestPerPlatformSnapshotIndex_WithMaterials(t *testing.T) { + // Fake attestation manifest descriptor (subject chain into the snapshot). + attest := ocispecs.Descriptor{ + MediaType: ocispecs.MediaTypeImageManifest, + ArtifactType: "application/vnd.in-toto+json", + Digest: digest.FromBytes([]byte("attest")), + Size: 12, + } + + // Two materials layers: one http blob, one image-material root index. + httpBytes := []byte("http-material-bytes") + rootBytes := []byte("root-index-bytes") + layers := []ocispecs.Descriptor{ + { + MediaType: "application/octet-stream", + Digest: digest.FromBytes(httpBytes), + Size: int64(len(httpBytes)), + }, + { + MediaType: ocispecs.MediaTypeImageIndex, + Digest: digest.FromBytes(rootBytes), + Size: int64(len(rootBytes)), + }, + } + _, matDesc, matData, err := MaterialsManifest(layers) + require.NoError(t, err) + require.Equal(t, ArtifactTypeMaterials, matDesc.ArtifactType) + require.Equal(t, ocispecs.MediaTypeImageManifest, matDesc.MediaType) + require.NotZero(t, matDesc.Size) + require.Equal(t, digest.FromBytes(matData), matDesc.Digest) + + // Parse the materials manifest and assert its structure. + var mm ocispecs.Manifest + require.NoError(t, json.Unmarshal(matData, &mm)) + require.Equal(t, 2, mm.SchemaVersion) + require.Equal(t, ocispecs.MediaTypeImageManifest, mm.MediaType) + require.Equal(t, ArtifactTypeMaterials, mm.ArtifactType) + require.Equal(t, OCIEmptyConfigDescriptor().Digest, mm.Config.Digest) + require.Equal(t, OCIEmptyConfigDescriptor().MediaType, mm.Config.MediaType) + require.Equal(t, int64(2), mm.Config.Size) + require.Len(t, mm.Layers, 2) + require.Equal(t, layers[0].Digest, mm.Layers[0].Digest) + require.Equal(t, layers[1].Digest, mm.Layers[1].Digest) + require.Nil(t, mm.Subject, "materials manifest MUST NOT carry a subject") + + // Build the per-platform snapshot index on top. + imgMfst := ocispecs.Descriptor{ + MediaType: ocispecs.MediaTypeImageManifest, + Digest: digest.FromBytes([]byte("platform-manifest")), + Size: 42, + Platform: &ocispecs.Platform{Architecture: "amd64", OS: "linux"}, + } + _, ppDesc, ppData, err := PerPlatformSnapshotIndex(attest, matDesc, []ocispecs.Descriptor{imgMfst}) + require.NoError(t, err) + require.Equal(t, ArtifactTypeSnapshot, ppDesc.ArtifactType) + require.Equal(t, ocispecs.MediaTypeImageIndex, ppDesc.MediaType) + require.Equal(t, digest.FromBytes(ppData), ppDesc.Digest) + + var pp ocispecs.Index + require.NoError(t, json.Unmarshal(ppData, &pp)) + require.Equal(t, 2, pp.SchemaVersion) + require.Equal(t, ocispecs.MediaTypeImageIndex, pp.MediaType) + require.Equal(t, ArtifactTypeSnapshot, pp.ArtifactType) + + require.NotNil(t, pp.Subject, "per-platform snapshot index MUST carry a subject") + require.Equal(t, attest.Digest, pp.Subject.Digest) + require.Equal(t, attest.MediaType, pp.Subject.MediaType) + require.Equal(t, attest.Size, pp.Subject.Size) + + require.Len(t, pp.Manifests, 2) + // First entry MUST be the materials manifest when it is present. + require.Equal(t, matDesc.Digest, pp.Manifests[0].Digest) + require.Equal(t, ArtifactTypeMaterials, pp.Manifests[0].ArtifactType) + // Remaining entries are the per-material platform manifests. + require.Equal(t, imgMfst.Digest, pp.Manifests[1].Digest) + require.NotNil(t, pp.Manifests[1].Platform) + require.Equal(t, "amd64", pp.Manifests[1].Platform.Architecture) + + // Snapshot indexes are deterministic — no creation timestamp annotation. + require.Empty(t, pp.Annotations[ocispecs.AnnotationCreated]) +} + +func TestPerPlatformSnapshotIndex_WithoutMaterials(t *testing.T) { + // When --include-materials=false the materials manifest is omitted: + // the caller passes a zero descriptor. + attest := ocispecs.Descriptor{ + MediaType: ocispecs.MediaTypeImageManifest, + Digest: digest.FromBytes([]byte("attest-noop")), + Size: 10, + } + imgMfst := ocispecs.Descriptor{ + MediaType: ocispecs.MediaTypeImageManifest, + Digest: digest.FromBytes([]byte("platform-manifest-noop")), + Size: 11, + } + + _, _, ppData, err := PerPlatformSnapshotIndex(attest, ocispecs.Descriptor{}, []ocispecs.Descriptor{imgMfst}) + require.NoError(t, err) + + var pp ocispecs.Index + require.NoError(t, json.Unmarshal(ppData, &pp)) + require.Len(t, pp.Manifests, 1, "zero materials descriptor should be dropped") + require.Equal(t, imgMfst.Digest, pp.Manifests[0].Digest) + require.NotEqual(t, ArtifactTypeMaterials, pp.Manifests[0].ArtifactType) +} + +func TestMultiPlatformSnapshotIndex(t *testing.T) { + // Build two dummy per-platform snapshot descriptors with populated + // Platform fields; wrap via MultiPlatformSnapshotIndex. + amd := ocispecs.Descriptor{ + MediaType: ocispecs.MediaTypeImageIndex, + ArtifactType: ArtifactTypeSnapshot, + Digest: digest.FromBytes([]byte("pp-amd64")), + Size: 100, + Platform: &ocispecs.Platform{Architecture: "amd64", OS: "linux"}, + } + arm := ocispecs.Descriptor{ + MediaType: ocispecs.MediaTypeImageIndex, + ArtifactType: ArtifactTypeSnapshot, + Digest: digest.FromBytes([]byte("pp-arm64")), + Size: 110, + Platform: &ocispecs.Platform{Architecture: "arm64", OS: "linux"}, + } + _, desc, data, err := MultiPlatformSnapshotIndex([]ocispecs.Descriptor{amd, arm}) + require.NoError(t, err) + require.Equal(t, ArtifactTypeSnapshot, desc.ArtifactType) + require.Equal(t, ocispecs.MediaTypeImageIndex, desc.MediaType) + require.Equal(t, digest.FromBytes(data), desc.Digest) + + var idx ocispecs.Index + require.NoError(t, json.Unmarshal(data, &idx)) + require.Equal(t, 2, idx.SchemaVersion) + require.Equal(t, ocispecs.MediaTypeImageIndex, idx.MediaType) + require.Equal(t, ArtifactTypeSnapshot, idx.ArtifactType) + require.Nil(t, idx.Subject, "top-level multi-platform snapshot index MUST NOT carry a subject") + require.Len(t, idx.Manifests, 2) + + for i, child := range idx.Manifests { + require.Equal(t, ArtifactTypeSnapshot, child.ArtifactType, "child %d", i) + require.Equal(t, ocispecs.MediaTypeImageIndex, child.MediaType, "child %d", i) + require.NotNil(t, child.Platform, "child %d must carry platform", i) + } + require.Equal(t, "amd64", idx.Manifests[0].Platform.Architecture) + require.Equal(t, "arm64", idx.Manifests[1].Platform.Architecture) + require.Empty(t, idx.Annotations[ocispecs.AnnotationCreated]) +} + +func TestOCIEmptyConfig(t *testing.T) { + // Round-trip the empty config constants: digest of the bytes must equal + // the exported descriptor digest. + bytes := OCIEmptyConfigBytes() + require.Equal(t, "{}", string(bytes)) + require.Equal(t, digest.FromBytes(bytes).String(), string(OCIEmptyConfigDescriptor().Digest)) + require.Equal(t, int64(len(bytes)), OCIEmptyConfigDescriptor().Size) +} diff --git a/replay/snapshot_test.go b/replay/snapshot_test.go new file mode 100644 index 000000000000..6206ab949b9a --- /dev/null +++ b/replay/snapshot_test.go @@ -0,0 +1,418 @@ +package replay + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/containerd/containerd/v2/core/content" + "github.com/docker/buildx/util/buildflags" + "github.com/docker/buildx/util/imagetools" + slsa1 "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v1" + "github.com/moby/buildkit/client/ociindex" + provenancetypes "github.com/moby/buildkit/solver/llbsolver/provenance/types" + "github.com/moby/buildkit/util/contentutil" + "github.com/moby/buildkit/util/purl" + digest "github.com/opencontainers/go-digest" + ocispecsgo "github.com/opencontainers/image-spec/specs-go" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/pkg/errors" + "github.com/stretchr/testify/require" +) + +// snapshotFixture produces a Subject + Predicate pair backed by an in-memory +// content provider. The subject carries a synthetic image manifest; the +// attestation manifest references an in-toto SLSA v1 provenance statement +// whose ResolvedDependencies name one http material. +// +// Returned slice of material bytes gives tests a handle on the http material +// payload so they can be pinned via a MaterialsResolver override. +type snapshotFixture struct { + subject *Subject + predicate *Predicate + httpBytes []byte + httpDigest digest.Digest + httpURI string + attestDigest digest.Digest + subjectDesc ocispecs.Descriptor + attestDesc ocispecs.Descriptor + provider contentutil.Buffer +} + +type descriptorRecordingIngester struct { + content.Ingester + descriptors []ocispecs.Descriptor +} + +func (i *descriptorRecordingIngester) Writer(ctx context.Context, opts ...content.WriterOpt) (content.Writer, error) { + var wopts content.WriterOpts + for _, opt := range opts { + if err := opt(&wopts); err != nil { + return nil, err + } + } + i.descriptors = append(i.descriptors, wopts.Desc) + return i.Ingester.Writer(ctx, opts...) +} + +func makeSnapshotFixture(t *testing.T) *snapshotFixture { + t.Helper() + ctx := context.Background() + + buf := contentutil.NewBuffer() + + // 1. Synthetic subject image: config + single layer + manifest. + configBytes := []byte(`{"architecture":"amd64","os":"linux","rootfs":{"type":"layers","diff_ids":[]}}`) + configDesc := writeBlobBuf(ctx, t, buf, ocispecs.MediaTypeImageConfig, configBytes) + layerBytes := []byte("fake-image-layer") + layerDesc := writeBlobBuf(ctx, t, buf, ocispecs.MediaTypeImageLayerGzip, layerBytes) + + subjectManifest := ocispecs.Manifest{ + Versioned: ocispecsgo.Versioned{SchemaVersion: 2}, + MediaType: ocispecs.MediaTypeImageManifest, + Config: configDesc, + Layers: []ocispecs.Descriptor{layerDesc}, + } + subjectBytes, err := json.Marshal(subjectManifest) + require.NoError(t, err) + subjectDesc := writeBlobBuf(ctx, t, buf, ocispecs.MediaTypeImageManifest, subjectBytes) + subjectDesc.Platform = &ocispecs.Platform{OS: "linux", Architecture: "amd64"} + + // 2. http material (reachable later by digest). + httpBytes := []byte("hello-from-http-material") + httpDigest := digest.FromBytes(httpBytes) + _ = writeBlobBuf(ctx, t, buf, "application/octet-stream", httpBytes) + + httpURI := "https://example.com/payload.tgz" + + // 3. SLSA v1 provenance predicate. + pred := provenancetypes.ProvenancePredicateSLSA1{} + pred.BuildDefinition.ExternalParameters.Request.Frontend = "dockerfile.v0" + pred.BuildDefinition.ResolvedDependencies = []slsa1.ResourceDescriptor{ + { + URI: httpURI, + Digest: map[string]string{ + "sha256": httpDigest.Encoded(), + }, + }, + } + + // 4. in-toto Statement wrapping the predicate. + predBytes, err := json.Marshal(pred) + require.NoError(t, err) + stmt := map[string]any{ + "_type": "https://in-toto.io/Statement/v1", + "predicateType": "https://slsa.dev/provenance/v1", + "subject": []map[string]any{ + { + "name": "synthetic", + "digest": map[string]string{"sha256": subjectDesc.Digest.Encoded()}, + }, + }, + "predicate": json.RawMessage(predBytes), + } + stmtBytes, err := json.Marshal(stmt) + require.NoError(t, err) + + stmtDesc := writeBlobBufWithAnnotations( + ctx, t, buf, + "application/vnd.in-toto+json", + stmtBytes, + map[string]string{"in-toto.io/predicate-type": "https://slsa.dev/provenance/v1"}, + ) + + // 5. Attestation manifest referencing the in-toto statement layer. + attestManifest := ocispecs.Manifest{ + Versioned: ocispecsgo.Versioned{SchemaVersion: 2}, + MediaType: ocispecs.MediaTypeImageManifest, + ArtifactType: "application/vnd.in-toto+json", + Config: OCIEmptyConfigDescriptor(), + Layers: []ocispecs.Descriptor{stmtDesc}, + } + // Write empty config (required by the attestation manifest chain). + _ = writeBlobBufRaw(ctx, t, buf, OCIEmptyConfigDescriptor(), OCIEmptyConfigBytes()) + + attestBytes, err := json.Marshal(attestManifest) + require.NoError(t, err) + attestDesc := writeBlobBuf(ctx, t, buf, ocispecs.MediaTypeImageManifest, attestBytes) + attestDesc.ArtifactType = "application/vnd.in-toto+json" + + // 6. Build the Subject pointing at the image manifest. + s := &Subject{ + Descriptor: subjectDesc, + Provider: buf, + inputRef: "synthetic://subject", + kind: subjectKindImage, + attestManifest: attestDesc, + } + p := Predicate(pred) + + return &snapshotFixture{ + subject: s, + predicate: &p, + httpBytes: httpBytes, + httpDigest: httpDigest, + httpURI: httpURI, + attestDigest: attestDesc.Digest, + subjectDesc: subjectDesc, + attestDesc: attestDesc, + provider: buf, + } +} + +// writeBlobBuf stores dt in buf under a mediaType and returns the descriptor. +func writeBlobBuf(ctx context.Context, t *testing.T, buf contentutil.Buffer, mediaType string, dt []byte) ocispecs.Descriptor { + t.Helper() + d := digest.FromBytes(dt) + desc := ocispecs.Descriptor{MediaType: mediaType, Digest: d, Size: int64(len(dt))} + require.NoError(t, content.WriteBlob(ctx, buf, d.String(), bytes.NewReader(dt), desc)) + return desc +} + +func writeBlobBufWithAnnotations(ctx context.Context, t *testing.T, buf contentutil.Buffer, mediaType string, dt []byte, ann map[string]string) ocispecs.Descriptor { + desc := writeBlobBuf(ctx, t, buf, mediaType, dt) + desc.Annotations = ann + return desc +} + +func writeBlobBufRaw(ctx context.Context, t *testing.T, buf contentutil.Buffer, desc ocispecs.Descriptor, dt []byte) ocispecs.Descriptor { + t.Helper() + require.NoError(t, content.WriteBlob(ctx, buf, desc.Digest.String(), bytes.NewReader(dt), desc)) + return desc +} + +// snapshotOverrideResolver builds a MaterialsResolver whose single override +// points at a temp file carrying the material bytes. That lets Snapshot +// resolve the material without any network or registry access. +func snapshotOverrideResolver(t *testing.T, uri string, dt []byte) *MaterialsResolver { + t.Helper() + tmp := filepath.Join(t.TempDir(), "override.bin") + require.NoError(t, os.WriteFile(tmp, dt, 0o644)) + r, err := NewMaterialsResolver([]string{uri + "=" + tmp}) + require.NoError(t, err) + return r +} + +func TestSnapshotWritesOCILayout(t *testing.T) { + fx := makeSnapshotFixture(t) + + dest := t.TempDir() + req := &SnapshotRequest{ + Targets: []Target{{Subject: fx.subject, Predicate: fx.predicate}}, + IncludeMaterials: true, + Materials: snapshotOverrideResolver(t, fx.httpURI, fx.httpBytes), + Output: &buildflags.ExportEntry{ + Type: "oci", + Destination: dest, + Attrs: map[string]string{"tar": "false"}, + }, + } + require.NoError(t, Snapshot(context.Background(), nil, "", req)) + + // OCI layout skeleton present. + _, err := os.Stat(filepath.Join(dest, "oci-layout")) + require.NoError(t, err) + _, err = os.Stat(filepath.Join(dest, "index.json")) + require.NoError(t, err) + _, err = os.Stat(filepath.Join(dest, "blobs", "sha256")) + require.NoError(t, err) + + // Index.json points at the per-platform snapshot index (single-platform + // case — Snapshot does not wrap in a multi-platform index). + idx, err := ociindex.NewStoreIndex(dest).Read() + require.NoError(t, err) + require.Len(t, idx.Manifests, 1) + root := idx.Manifests[0] + require.Equal(t, ArtifactTypeSnapshot, root.ArtifactType) + + // Walk the root and assert its subject is the attestation manifest. + rootBlobPath := filepath.Join(dest, "blobs", root.Digest.Algorithm().String(), root.Digest.Encoded()) + rootData, err := os.ReadFile(rootBlobPath) + require.NoError(t, err) + var ppIdx ocispecs.Index + require.NoError(t, json.Unmarshal(rootData, &ppIdx)) + require.Equal(t, ArtifactTypeSnapshot, ppIdx.ArtifactType) + require.NotNil(t, ppIdx.Subject) + require.Equal(t, fx.attestDigest, ppIdx.Subject.Digest) + + // First manifests[] entry must be the materials artifact manifest. + require.NotEmpty(t, ppIdx.Manifests) + matDesc := ppIdx.Manifests[0] + require.Equal(t, ArtifactTypeMaterials, matDesc.ArtifactType) + + // The http material layer lives inside the materials manifest. + matPath := filepath.Join(dest, "blobs", matDesc.Digest.Algorithm().String(), matDesc.Digest.Encoded()) + matRaw, err := os.ReadFile(matPath) + require.NoError(t, err) + var mm ocispecs.Manifest + require.NoError(t, json.Unmarshal(matRaw, &mm)) + require.Equal(t, ArtifactTypeMaterials, mm.ArtifactType) + + var gotHTTPLayer bool + for _, l := range mm.Layers { + if l.Digest == fx.httpDigest { + gotHTTPLayer = true + // Blob on disk must equal the pinned material bytes. + lp := filepath.Join(dest, "blobs", l.Digest.Algorithm().String(), l.Digest.Encoded()) + got, err := os.ReadFile(lp) + require.NoError(t, err) + require.Equal(t, fx.httpBytes, got) + } + } + require.True(t, gotHTTPLayer, "materials manifest must include the http material layer") + + // Round-trip: a MaterialsResolver pointed at oci-layout:// must + // find the http material through the snapshot-backed lookup path. + layout := fmt.Sprintf("oci-layout://%s", dest) + rr, err := NewMaterialsResolver([]string{layout}) + require.NoError(t, err) + desc, provider, err := rr.Resolve(context.Background(), fx.httpURI, fx.httpDigest) + require.NoError(t, err) + require.NotNil(t, provider) + require.Equal(t, fx.httpDigest, desc.Digest) + + gotBytes, err := content.ReadBlob(context.Background(), provider, desc) + require.NoError(t, err) + require.Equal(t, fx.httpBytes, gotBytes) +} + +func TestFlushStagePreservesDescriptorMediaType(t *testing.T) { + ctx := context.Background() + stage := newStagingStore() + dt := []byte(`{"schemaVersion":2}`) + desc := ocispecs.Descriptor{ + MediaType: ocispecs.MediaTypeImageManifest, + Digest: digest.FromBytes(dt), + Size: int64(len(dt)), + } + require.NoError(t, stage.writeRaw(ctx, desc, dt)) + + dest := &descriptorRecordingIngester{Ingester: contentutil.NewBuffer()} + require.NoError(t, flushStage(ctx, dest, stage)) + require.Equal(t, []ocispecs.Descriptor{desc}, dest.descriptors) +} + +func TestResolveImageMaterialDoesNotFallbackWithoutProvenanceSentinel(t *testing.T) { + resolver, err := NewMaterialsResolver([]string{t.TempDir()}) + require.NoError(t, err) + + registryCalled := false + _, _, err = resolveImageMaterial( + context.Background(), + resolver, + func() (*imagetools.Resolver, error) { + registryCalled = true + return nil, errors.New("unexpected registry fallback") + }, + slsa1.ResourceDescriptor{URI: imageURIAlpine}, + digest.Digest(imageSHA), + ) + require.Error(t, err) + var notFound *MaterialNotFoundError + require.ErrorAs(t, err, ¬Found) + require.False(t, registryCalled) +} + +func TestResolveImageMaterialRejectsPURLDigestMismatch(t *testing.T) { + purlDgst := digest.FromString("purl") + recordedDgst := digest.FromString("recorded") + uri, err := purl.RefToPURL("docker", "docker.io/library/alpine@"+purlDgst.String(), nil) + require.NoError(t, err) + + registryCalled := false + _, _, err = resolveImageMaterial( + context.Background(), + nil, + func() (*imagetools.Resolver, error) { + registryCalled = true + return nil, errors.New("unexpected registry fallback") + }, + slsa1.ResourceDescriptor{URI: uri}, + recordedDgst, + ) + require.ErrorContains(t, err, "does not match recorded digest") + require.False(t, registryCalled) +} + +func TestPickPlatformChildPrefersSubjectPlatform(t *testing.T) { + ctx := context.Background() + subjectPlat := ocispecs.Platform{OS: "linux", Architecture: "arm64"} + builderPlat := ocispecs.Platform{OS: "linux", Architecture: "amd64"} + subjectChild := ocispecs.Descriptor{ + MediaType: ocispecs.MediaTypeImageManifest, + Digest: digest.FromString("subject"), + Size: 1, + Platform: &subjectPlat, + } + builderChild := ocispecs.Descriptor{ + MediaType: ocispecs.MediaTypeImageManifest, + Digest: digest.FromString("builder"), + Size: 1, + Platform: &builderPlat, + } + + for _, tc := range []struct { + name string + manifests []ocispecs.Descriptor + }{ + {name: "builder-first", manifests: []ocispecs.Descriptor{builderChild, subjectChild}}, + {name: "subject-first", manifests: []ocispecs.Descriptor{subjectChild, builderChild}}, + } { + t.Run(tc.name, func(t *testing.T) { + dt, err := json.Marshal(ocispecs.Index{ + Versioned: ocispecsgo.Versioned{SchemaVersion: 2}, + MediaType: ocispecs.MediaTypeImageIndex, + Manifests: tc.manifests, + }) + require.NoError(t, err) + root := ocispecs.Descriptor{ + MediaType: ocispecs.MediaTypeImageIndex, + Digest: digest.FromBytes(dt), + Size: int64(len(dt)), + } + buf := contentutil.NewBuffer() + require.NoError(t, content.WriteBlob(ctx, buf, "index", bytes.NewReader(dt), root)) + + got, err := pickPlatformChild(ctx, buf, root, &subjectPlat, builderPlat) + require.NoError(t, err) + require.Equal(t, subjectChild.Digest, got.Digest) + + // Without the subject platform in the index, the builder platform + // is the fallback. + got, err = pickPlatformChild(ctx, buf, root, &ocispecs.Platform{OS: "linux", Architecture: "s390x"}, builderPlat) + require.NoError(t, err) + require.Equal(t, builderChild.Digest, got.Digest) + }) + } +} + +func TestSnapshotRejectsAttestationFileSubject(t *testing.T) { + fx := makeSnapshotFixture(t) + fx.subject.kind = subjectKindAttestationFile + + req := &SnapshotRequest{ + Targets: []Target{{Subject: fx.subject, Predicate: fx.predicate}}, + Output: &buildflags.ExportEntry{ + Type: "oci", + Destination: t.TempDir(), + Attrs: map[string]string{"tar": "false"}, + }, + } + err := Snapshot(context.Background(), nil, "", req) + require.Error(t, err) + var unsup *UnsupportedSubjectError + require.ErrorAs(t, err, &unsup) +} + +func TestSnapshotMissingOutputRejected(t *testing.T) { + fx := makeSnapshotFixture(t) + + err := Snapshot(context.Background(), nil, "", &SnapshotRequest{ + Targets: []Target{{Subject: fx.subject, Predicate: fx.predicate}}, + }) + require.Error(t, err) +} diff --git a/replay/subject.go b/replay/subject.go new file mode 100644 index 000000000000..0bfb8bdd66c5 --- /dev/null +++ b/replay/subject.go @@ -0,0 +1,737 @@ +package replay + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "os" + "path/filepath" + "strings" + "time" + + "github.com/containerd/containerd/v2/core/content" + "github.com/containerd/containerd/v2/core/images" + "github.com/containerd/containerd/v2/core/remotes" + "github.com/distribution/reference" + "github.com/docker/buildx/builder" + "github.com/docker/buildx/policy" + "github.com/docker/buildx/util/confutil" + "github.com/docker/buildx/util/imagetools" + "github.com/docker/cli/cli/command" + slsa02 "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v0.2" + slsa1 "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v1" + provenancetypes "github.com/moby/buildkit/solver/llbsolver/provenance/types" + "github.com/moby/buildkit/util/attestation" + "github.com/moby/buildkit/util/contentutil" + policyverifier "github.com/moby/policy-helpers" + policyimage "github.com/moby/policy-helpers/image" + policytypes "github.com/moby/policy-helpers/types" + "github.com/opencontainers/go-digest" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/pkg/errors" +) + +const ( + dockerImagePrefix = "docker-image://" + ociLayoutPrefix = "oci-layout://" +) + +// subjectKind classifies how a Subject was produced; downstream code only +// reads the exported fields, but the kind guides error messages. +type subjectKind int + +const ( + // subjectKindImage covers both remote image refs and OCI-layout + // inputs — both resolve to a (descriptor, content.Provider) tuple + // with identical semantics. + subjectKindImage subjectKind = iota + subjectKindAttestationFile +) + +// Subject is one replayable unit: a single manifest-level descriptor plus a +// content.Provider that serves that descriptor, its referrers, and the +// predicate blob. +// +// For image and oci-layout inputs, Descriptor is the produced artifact's +// manifest descriptor. For an attestation-file input, Descriptor points at +// the predicate blob in an in-memory content.Provider and there is no +// produced artifact. +type Subject struct { + Descriptor ocispecs.Descriptor + Provider content.Provider + + // inputRef is the user-visible string for error messages. + inputRef string + kind subjectKind + // attestManifest is the in-toto attestation manifest descriptor that + // referrers this subject (image / oci-layout kinds). Empty for + // attestation-file subjects, whose predicate blob is directly at + // Descriptor. + attestManifest ocispecs.Descriptor + // predicateType caches the predicate type URI for attestation-file + // subjects so Predicate() can reject non-SLSA predicates without re-reading. + predicateType string + // rootDescriptor is the image index from which this manifest subject was + // selected. Image signature verification binds the index, selected + // platform, attestation manifest, and its signature referrer together. + rootDescriptor ocispecs.Descriptor + // signature is populated only after a Sigstore bundle has passed + // certificate, transparency-log, timestamp, and payload verification. + signature *SignatureVerification +} + +// SignatureVerification describes a cryptographically verified Sigstore +// bundle. Identity is informational: replay accepts unsigned provenance too, +// so verification does not imply that a separate authorization policy has +// approved this signer. +type SignatureVerification struct { + Verified bool `json:"verified"` + Type string `json:"type"` + Identity string `json:"identity"` + CertificateIssuer string `json:"certificateIssuer,omitempty"` + SubjectAlternativeName string `json:"subjectAlternativeName,omitempty"` + Issuer string `json:"issuer,omitempty"` + SourceRepositoryURI string `json:"sourceRepositoryURI,omitempty"` + SourceRepositoryRef string `json:"sourceRepositoryRef,omitempty"` + BuildSignerURI string `json:"buildSignerURI,omitempty"` + RunnerEnvironment string `json:"runnerEnvironment,omitempty"` + Timestamps []SignatureTimestamp `json:"timestamps,omitempty"` + TrustRootLastUpdated *time.Time `json:"trustRootLastUpdated,omitempty"` + TrustRootWarning string `json:"trustRootWarning,omitempty"` +} + +// SignatureTimestamp is one verified observer timestamp from a Sigstore +// bundle, typically a transparency-log or timestamp-authority observation. +type SignatureTimestamp struct { + Type string `json:"type"` + URI string `json:"uri,omitempty"` + Timestamp time.Time `json:"timestamp"` +} + +// IsAttestationFile reports whether this subject was loaded from a local +// attestation file (no produced artifact is available). +func (s *Subject) IsAttestationFile() bool { return s != nil && s.kind == subjectKindAttestationFile } + +// Signature returns verified signature metadata, or nil for unsigned +// provenance and images. +func (s *Subject) Signature() *SignatureVerification { + if s == nil { + return nil + } + return s.signature +} + +// VerifySignatures verifies signatures attached to the selected image +// subjects' provenance attestations. One verifier provider is shared across +// all platforms so its trust-root state is initialized only once. Unsigned +// images remain valid replay inputs; a published but invalid signature fails +// closed. Standalone Sigstore bundles are already verified while loading. +func VerifySignatures(ctx context.Context, dockerCli command.Cli, subjects []*Subject) error { + var candidates []*Subject + for _, s := range subjects { + if s != nil && s.kind == subjectKindImage && s.rootDescriptor.MediaType == ocispecs.MediaTypeImageIndex && s.attestManifest.Digest != "" { + candidates = append(candidates, s) + } + } + if len(candidates) == 0 { + return nil + } + if dockerCli == nil { + return errors.New("docker CLI is required to verify image signatures") + } + getVerifier := policy.SignatureVerifier(confutil.NewConfig(dockerCli)) + return verifySubjectSignatures(ctx, candidates, func(ctx context.Context, provider policyimage.ReferrersProvider, root ocispecs.Descriptor, platform *ocispecs.Platform) (*policytypes.SignatureInfo, error) { + verifier, err := getVerifier() + if err != nil { + return nil, err + } + return verifier.VerifyImage(ctx, provider, root, platform) + }) +} + +type imageSignatureVerifier func(context.Context, policyimage.ReferrersProvider, ocispecs.Descriptor, *ocispecs.Platform) (*policytypes.SignatureInfo, error) + +func verifySubjectSignatures(ctx context.Context, subjects []*Subject, verify imageSignatureVerifier) error { + for _, s := range subjects { + if err := s.verifyImageSignature(ctx, verify); err != nil { + return err + } + } + return nil +} + +func (s *Subject) verifyImageSignature(ctx context.Context, verify imageSignatureVerifier) error { + provider, ok := s.Provider.(policyimage.ReferrersProvider) + if !ok { + return errors.Errorf("image provider for %s does not support referrers", s.inputRef) + } + si, err := verify(ctx, provider, s.rootDescriptor, s.Descriptor.Platform) + if err != nil { + var noSignature *policyverifier.NoSigChainError + if errors.As(err, &noSignature) { + return nil + } + return errors.Wrapf(err, "verify image signature for %s", s.inputRef) + } + if si == nil { + return errors.Errorf("signature verifier returned no verification result for %s", s.inputRef) + } + s.signature = signatureVerification(si) + return nil +} + +// InputRef returns the user-supplied input string that produced this +// subject. Used for diagnostics. +func (s *Subject) InputRef() string { + if s == nil { + return "" + } + return s.inputRef +} + +// AttestationManifest returns the attestation manifest descriptor associated +// with this subject, or the zero descriptor if none was found (or the subject +// was loaded from a local attestation file). +func (s *Subject) AttestationManifest() ocispecs.Descriptor { + if s == nil { + return ocispecs.Descriptor{} + } + return s.attestManifest +} + +// LoadSubjects parses a user-supplied input and returns one Subject per +// manifest to replay. An image index expands into N subjects (one per child +// manifest); a single image manifest or attestation file returns 1. +// +// Input forms: +// - docker-image:// — explicit remote reference. +// - oci-layout://[:] — explicit OCI layout directory. +// - — local attestation file (in-toto / DSSE). +// - — treated as an OCI layout. +// - — valid image reference (docker-image). +func LoadSubjects(ctx context.Context, dockerCli command.Cli, builderName, input string) ([]*Subject, error) { + trimmed := strings.TrimSpace(input) + if trimmed == "" { + return nil, errors.New("empty subject input") + } + + // Already-prefixed inputs go straight through. + if strings.HasPrefix(trimmed, dockerImagePrefix) || strings.HasPrefix(trimmed, ociLayoutPrefix) { + return loadImageSubjects(ctx, dockerCli, builderName, trimmed) + } + + // A local filesystem path: regular file = attestation, directory = + // OCI layout. + fi, err := os.Stat(trimmed) + if err == nil { + if fi.IsDir() { + return loadImageSubjects(ctx, dockerCli, builderName, ociLayoutPrefix+trimmed) + } + return loadAttestationFileSubject(ctx, dockerCli, trimmed) + } + if isPathLike(trimmed) { + return nil, errors.WithStack(err) + } + + // Fall through: treat as a remote image reference. Validation happens + // inside loadImageSubjects. + return loadImageSubjects(ctx, dockerCli, builderName, trimmed) +} + +// loadAttestationFileSubject reads a local attestation file (in-toto +// Statement JSON, DSSE envelope, or an intoto.jsonl line-per-envelope file) +// and synthesizes a Subject whose Descriptor points at the predicate blob +// inside an in-memory content.Provider. +func loadAttestationFileSubject(ctx context.Context, dockerCli command.Cli, path string) ([]*Subject, error) { + dt, err := os.ReadFile(path) + if err != nil { + return nil, errors.WithStack(err) + } + dt = bytes.TrimSpace(dt) + if len(dt) == 0 { + return nil, errors.Errorf("attestation file %s is empty", path) + } + + var verify artifactBundleVerifier + if dockerCli != nil { + getVerifier := policy.SignatureVerifier(confutil.NewConfig(dockerCli)) + verify = func(ctx context.Context, dgst digest.Digest, bundle []byte) (*policytypes.SignatureInfo, error) { + verifier, err := getVerifier() + if err != nil { + return nil, err + } + return verifier.VerifyArtifact(ctx, dgst, bundle) + } + } + dt, signature, isBundle, err := verifySigstoreBundle(ctx, dt, path, verify) + if err != nil { + return nil, err + } + + // Heuristic: .intoto.jsonl is line-delimited JSON Statements. Pick the + // first line that carries a provenance predicateType. + if strings.HasSuffix(path, ".intoto.jsonl") { + for line := range bytes.SplitSeq(dt, []byte("\n")) { + line = bytes.TrimSpace(line) + if len(line) == 0 { + continue + } + s, err := subjectFromAttestationBytes(line, path) + if err == nil { + s.signature = signature + return []*Subject{s}, nil + } + } + return nil, errors.Errorf("no SLSA provenance statement found in %s", path) + } + + s, err := subjectFromAttestationBytes(dt, path) + if err != nil { + return nil, err + } + if isBundle { + s.signature = signature + } + return []*Subject{s}, nil +} + +type artifactBundleVerifier func(context.Context, digest.Digest, []byte) (*policytypes.SignatureInfo, error) + +func verifySigstoreBundle(ctx context.Context, dt []byte, inputRef string, verify artifactBundleVerifier) ([]byte, *SignatureVerification, bool, error) { + var bundle struct { + MediaType string `json:"mediaType"` + VerificationMaterial json.RawMessage `json:"verificationMaterial,omitempty"` + DSSEEnvelope struct { + Payload string `json:"payload"` + } `json:"dsseEnvelope"` + MessageSignature json.RawMessage `json:"messageSignature,omitempty"` + } + if err := json.Unmarshal(dt, &bundle); err != nil { + return dt, nil, false, nil + } + isBundle := strings.HasPrefix(bundle.MediaType, "application/vnd.dev.sigstore.bundle.") || + (len(bundle.VerificationMaterial) > 0 && (bundle.DSSEEnvelope.Payload != "" || len(bundle.MessageSignature) > 0)) + if !isBundle { + return dt, nil, false, nil + } + if verify == nil { + return nil, nil, true, ErrSignatureVerificationRequired(inputRef, "sigstore-bundle") + } + if bundle.DSSEEnvelope.Payload == "" { + return nil, nil, true, errors.Errorf("sigstore bundle %s does not contain a DSSE provenance envelope", inputRef) + } + payload, err := decodeDSSEPayload(bundle.DSSEEnvelope.Payload) + if err != nil { + return nil, nil, true, errors.Wrap(err, "decode sigstore DSSE payload") + } + var stmt struct { + Subject []struct { + Digest map[string]string `json:"digest"` + } `json:"subject"` + } + if err := json.Unmarshal(payload, &stmt); err != nil { + return nil, nil, true, errors.Wrap(err, "parse sigstore in-toto statement") + } + var lastErr error + for _, subject := range stmt.Subject { + dgst := preferredDigest(subject.Digest) + if dgst == "" { + continue + } + if err := dgst.Validate(); err != nil { + lastErr = errors.WithStack(err) + continue + } + si, err := verify(ctx, dgst, dt) + if err != nil { + lastErr = err + continue + } + if si == nil { + lastErr = errors.New("signature verifier returned no verification result") + continue + } + return payload, signatureVerification(si), true, nil + } + if lastErr != nil { + return nil, nil, true, errors.Wrap(lastErr, "verify sigstore bundle") + } + return nil, nil, true, errors.Errorf("sigstore bundle %s has no verifiable subject digest", inputRef) +} + +func signatureVerification(si *policytypes.SignatureInfo) *SignatureVerification { + if si == nil { + return nil + } + out := &SignatureVerification{ + Verified: true, + Type: si.SignatureType.String(), + Identity: si.Name(), + TrustRootLastUpdated: si.TrustRootStatus.LastUpdated, + TrustRootWarning: si.TrustRootStatus.Error, + } + if si.Signer != nil { + out.CertificateIssuer = si.Signer.CertificateIssuer + out.SubjectAlternativeName = si.Signer.SubjectAlternativeName + out.Issuer = si.Signer.Issuer + out.SourceRepositoryURI = si.Signer.SourceRepositoryURI + out.SourceRepositoryRef = si.Signer.SourceRepositoryRef + out.BuildSignerURI = si.Signer.BuildSignerURI + out.RunnerEnvironment = si.Signer.RunnerEnvironment + } + for _, ts := range si.Timestamps { + out.Timestamps = append(out.Timestamps, SignatureTimestamp{ + Type: ts.Type, + URI: ts.URI, + Timestamp: ts.Timestamp, + }) + } + return out +} + +// subjectFromAttestationBytes parses a single in-toto Statement (or DSSE +// envelope around one) and returns a Subject whose Descriptor addresses the +// predicate bytes inside an in-memory content.Provider. Signed DSSE +// envelopes or Sigstore bundles are rejected with +// SignatureVerificationRequiredError — replay never silently accepts a +// signed attestation without a trust anchor. Standalone Sigstore bundles are +// verified and unwrapped by loadAttestationFileSubject before reaching here. +func subjectFromAttestationBytes(dt []byte, inputRef string) (*Subject, error) { + // Sigstore bundle detection: a bundle carries mediaType + // "application/vnd.dev.sigstore.bundle.v0.3+json" (or a v0.X variant) + // and a non-empty verificationMaterial / dsseEnvelope pair. Detect + // conservatively before the DSSE probe. + var bundleProbe struct { + MediaType string `json:"mediaType"` + VerificationMaterial json.RawMessage `json:"verificationMaterial,omitempty"` + DSSEEnvelope json.RawMessage `json:"dsseEnvelope,omitempty"` + MessageSignature json.RawMessage `json:"messageSignature,omitempty"` + } + if err := json.Unmarshal(dt, &bundleProbe); err == nil { + if strings.HasPrefix(bundleProbe.MediaType, "application/vnd.dev.sigstore.bundle.") || + (len(bundleProbe.VerificationMaterial) > 0 && (len(bundleProbe.DSSEEnvelope) > 0 || len(bundleProbe.MessageSignature) > 0)) { + return nil, ErrSignatureVerificationRequired(inputRef, "sigstore-bundle") + } + } + + // DSSE envelopes have a "payload" field with base64 content plus an + // optional "signatures" array. Detect by shape rather than media type + // (files carry no MIME). + var env struct { + Payload string `json:"payload"` + PayloadType string `json:"payloadType"` + Signatures []struct { + Sig string `json:"sig"` + KeyID string `json:"keyid,omitempty"` + } `json:"signatures"` + } + if err := json.Unmarshal(dt, &env); err == nil && env.Payload != "" { + // A signed DSSE envelope carries at least one non-empty signature. + // Reject a bare signed envelope: unlike a Sigstore bundle, it carries + // no verification material from which replay can establish trust. + for _, sig := range env.Signatures { + if sig.Sig != "" { + return nil, ErrSignatureVerificationRequired(inputRef, "dsse") + } + } + decoded, err := decodeDSSEPayload(env.Payload) + if err != nil { + return nil, errors.Wrap(err, "failed to decode DSSE payload") + } + dt = decoded + } + + var stmt struct { + PredicateType string `json:"predicateType"` + Predicate json.RawMessage `json:"predicate"` + } + if err := json.Unmarshal(dt, &stmt); err != nil { + return nil, errors.Errorf("%s is not an in-toto statement, DSSE envelope or Sigstore bundle", inputRef) + } + if stmt.PredicateType == "" { + // A bare provenance predicate, as printed by + // `imagetools inspect --format '{{json .Provenance.SLSA}}'`. + predType, ok := bareProvenancePredicateType(dt) + if !ok { + return nil, errors.Errorf("attestation file %s has no predicateType", inputRef) + } + stmt.PredicateType = predType + stmt.Predicate = dt + } + + predBytes := []byte(stmt.Predicate) + dgst := digest.FromBytes(predBytes) + buf := contentutil.NewBuffer() + if err := content.WriteBlob(context.Background(), buf, dgst.String(), bytes.NewReader(predBytes), ocispecs.Descriptor{Digest: dgst, Size: int64(len(predBytes))}); err != nil { + return nil, errors.WithStack(err) + } + + desc := ocispecs.Descriptor{ + MediaType: "application/json", + Digest: dgst, + Size: int64(len(predBytes)), + } + return &Subject{ + Descriptor: desc, + Provider: buf, + inputRef: inputRef, + kind: subjectKindAttestationFile, + predicateType: stmt.PredicateType, + }, nil +} + +// decodeDSSEPayload base64-decodes the DSSE payload. Tries the standard +// Base64 alphabet first, then URL alphabet (some implementations use the URL +// variant for JSON-in-JSON safety). +func decodeDSSEPayload(payload string) ([]byte, error) { + if dt, err := base64.StdEncoding.DecodeString(payload); err == nil { + return dt, nil + } + if dt, err := base64.URLEncoding.DecodeString(payload); err == nil { + return dt, nil + } + return nil, errors.New("payload is neither std nor url base64") +} + +// loadImageSubjects resolves a remote ref or an oci-layout://[:] +// input into one Subject per child manifest, fanning out only if the +// resolved descriptor is itself an index. Both shapes use the same +// util/imagetools.Resolver path as `imagetools inspect`; the resolver +// internally dispatches on the location shape (see +// util/imagetools/inspect.go:80). +func loadImageSubjects(ctx context.Context, dockerCli command.Cli, builderName, input string) ([]*Subject, error) { + var ( + resolver *imagetools.Resolver + ) + + if strings.HasPrefix(input, ociLayoutPrefix) { + // oci-layout:// does not need a builder / auth provider. Drive + // Resolve + Fetcher through a default resolver backed purely by + // the local layout store. + resolver = imagetools.New(imagetools.Opt{}) + } else { + trimmed := strings.TrimPrefix(input, dockerImagePrefix) + if _, err := reference.ParseNormalizedNamed(trimmed); err != nil { + return nil, errors.Wrapf(err, "invalid image reference %q", trimmed) + } + input = trimmed + + if dockerCli == nil { + return nil, errors.New("docker CLI is required to resolve remote image subjects") + } + b, err := builder.New(dockerCli, builder.WithName(builderName)) + if err != nil { + return nil, err + } + imageOpt, err := b.ImageOpt() + if err != nil { + return nil, err + } + resolver = imagetools.New(imageOpt) + } + + _, desc, err := resolver.Resolve(ctx, input) + if err != nil { + return nil, errors.Wrapf(err, "failed to resolve %s", input) + } + + fetcher, err := resolver.Fetcher(ctx, input) + if err != nil { + return nil, err + } + loc, err := imagetools.ParseLocation(input) + if err != nil { + return nil, err + } + provider := &imageSubjectProvider{ + Provider: contentutil.FromFetcher(fetcher), + resolver: resolver, + location: loc, + } + + return fanOutSubjects(ctx, provider, desc, input) +} + +// imageSubjectProvider combines the ordinary content provider used to read +// manifests and blobs with imagetools' registry/OCI-layout referrer lookup. +// policy-helpers can therefore verify the exact signature chain without a +// second resolver implementation in replay. +type imageSubjectProvider struct { + content.Provider + resolver *imagetools.Resolver + location *imagetools.Location +} + +func (p *imageSubjectProvider) FetchReferrers(ctx context.Context, dgst digest.Digest, opts ...remotes.FetchReferrersOpt) ([]ocispecs.Descriptor, error) { + return p.resolver.FetchReferrers(ctx, p.location, dgst, opts...) +} + +// fanOutSubjects walks an OCI index (if the root descriptor is an index) and +// returns one Subject per non-attestation child manifest. When the root is +// itself a manifest, a single subject is returned. +func fanOutSubjects(ctx context.Context, provider content.Provider, root ocispecs.Descriptor, inputRef string) ([]*Subject, error) { + switch root.MediaType { + case ocispecs.MediaTypeImageIndex, images.MediaTypeDockerSchema2ManifestList: + dt, err := content.ReadBlob(ctx, provider, root) + if err != nil { + return nil, errors.WithStack(err) + } + var idx ocispecs.Index + if err := json.Unmarshal(dt, &idx); err != nil { + return nil, errors.WithStack(err) + } + + // Buildx snapshot layouts carry their own artifactType and reach + // the attestation chain via the index's `subject`, not through a + // sibling attestation manifest. Loading them as subjects requires + // a different walk that is not yet implemented. + if idx.ArtifactType == ArtifactTypeSnapshot { + return nil, errors.Errorf("%s is a buildx snapshot layout; loading snapshots as subjects is not yet supported", inputRef) + } + + // Partition entries: attestation manifests (via the Docker ref + // annotation) vs. real image manifests. The attestation manifest + // for a given subject carries `vnd.docker.reference.digest` / + // `com.docker.reference.digest` pointing at the subject's digest. + attestFor := map[digest.Digest]ocispecs.Descriptor{} + var imageManifests []ocispecs.Descriptor + for _, m := range idx.Manifests { + if ref := attestationReferenceDigest(m); ref != "" { + if d, err := digest.Parse(ref); err == nil { + if _, ok := attestFor[d]; !ok { + attestFor[d] = m + } + continue + } + } + imageManifests = append(imageManifests, m) + } + + out := make([]*Subject, 0, len(imageManifests)) + for _, m := range imageManifests { + s := &Subject{ + Descriptor: m, + Provider: provider, + inputRef: inputRef, + kind: subjectKindImage, + rootDescriptor: root, + } + if att, ok := attestFor[m.Digest]; ok { + s.attestManifest = att + } + out = append(out, s) + } + if len(out) == 0 { + return nil, errors.Errorf("index %s has no image manifests", root.Digest) + } + return out, nil + case ocispecs.MediaTypeImageManifest, images.MediaTypeDockerSchema2Manifest: + return []*Subject{{ + Descriptor: root, + Provider: provider, + inputRef: inputRef, + kind: subjectKindImage, + rootDescriptor: root, + }}, nil + case images.MediaTypeDockerSchema1Manifest: + return nil, errors.Errorf("%s is a legacy Docker schema1 image, which cannot carry provenance", inputRef) + default: + return nil, errors.Errorf("unsupported root media type %q", root.MediaType) + } +} + +// isPathLike reports whether a subject input is clearly meant as a local file +// rather than an image reference. +func isPathLike(input string) bool { + return filepath.IsAbs(input) || strings.HasPrefix(input, "./") || strings.HasPrefix(input, "../") || + strings.HasSuffix(input, ".json") || strings.HasSuffix(input, ".jsonl") +} + +// bareProvenancePredicateType detects a SLSA provenance predicate that is not +// wrapped in an in-toto statement. +func bareProvenancePredicateType(dt []byte) (string, bool) { + var probe struct { + BuildDefinition json.RawMessage `json:"buildDefinition"` + BuildType string `json:"buildType"` + Invocation json.RawMessage `json:"invocation"` + } + if err := json.Unmarshal(dt, &probe); err != nil { + return "", false + } + switch { + case len(probe.BuildDefinition) > 0: + return slsa1.PredicateSLSAProvenance, true + case probe.BuildType != "" && len(probe.Invocation) > 0: + return slsa02.PredicateSLSAProvenance, true + } + return "", false +} + +// attestationReferenceDigest returns the subject digest recorded on a BuildKit +// attestation manifest, or "" for an unrelated descriptor. Selection exactly +// matches policy-helpers' signature-chain resolver: correct reference type, +// correct digest, and first matching descriptor wins in index order. +func attestationReferenceDigest(d ocispecs.Descriptor) string { + if d.Annotations == nil || d.Annotations[attestation.DockerAnnotationReferenceType] != attestation.DockerAnnotationReferenceTypeDefault { + return "" + } + return d.Annotations[attestation.DockerAnnotationReferenceDigest] +} + +// Predicate locates and parses the SLSA v1 provenance predicate attached to +// Descriptor via Provider. Returns UnsupportedPredicateError on a non-v1 +// predicateType and NoProvenanceError when none is found. +func (s *Subject) Predicate(ctx context.Context) (*Predicate, error) { + if s == nil { + return nil, errors.New("nil subject") + } + + switch s.kind { + case subjectKindAttestationFile: + dt, err := content.ReadBlob(ctx, s.Provider, s.Descriptor) + if err != nil { + return nil, errors.WithStack(err) + } + return decodeProvenancePredicate(dt, s.predicateType) + + case subjectKindImage: + if s.attestManifest.Digest == "" { + return nil, ErrNoProvenance(s.inputRef) + } + predDt, predType, err := imagetools.ReadProvenancePredicate(ctx, s.Provider, s.attestManifest) + if err != nil { + return nil, err + } + if predType == "" { + return nil, ErrNoProvenance(s.inputRef) + } + return decodeProvenancePredicate(predDt, predType) + } + + return nil, ErrUnsupportedSubject("unknown") +} + +// decodeProvenancePredicate unmarshals a provenance predicate in its +// native form. SLSA v1 is used as-is; SLSA v0.2 is converted to v1 via +// provenancetypes.ProvenancePredicateSLSA02.ConvertToSLSA1 so the rest of +// the replay code only has to understand one shape. +func decodeProvenancePredicate(dt []byte, predType string) (*Predicate, error) { + switch predType { + case slsa1.PredicateSLSAProvenance: + var pred Predicate + if err := json.Unmarshal(dt, &pred); err != nil { + return nil, errors.Wrap(err, "failed to unmarshal SLSA v1 predicate") + } + return &pred, nil + case slsa02.PredicateSLSAProvenance: + var old provenancetypes.ProvenancePredicateSLSA02 + if err := json.Unmarshal(dt, &old); err != nil { + return nil, errors.Wrap(err, "failed to unmarshal SLSA v0.2 predicate") + } + converted := old.ConvertToSLSA1() + pred := Predicate(*converted) + return &pred, nil + } + return nil, ErrUnsupportedPredicate(predType) +} diff --git a/replay/subject_test.go b/replay/subject_test.go new file mode 100644 index 000000000000..243a30091812 --- /dev/null +++ b/replay/subject_test.go @@ -0,0 +1,624 @@ +package replay + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "os" + "path/filepath" + "sort" + "testing" + "time" + + "github.com/containerd/containerd/v2/core/content" + contentlocal "github.com/containerd/containerd/v2/plugins/content/local" + "github.com/moby/buildkit/client/ociindex" + "github.com/moby/buildkit/util/attestation" + "github.com/moby/buildkit/util/contentutil" + policyverifier "github.com/moby/policy-helpers" + policyimage "github.com/moby/policy-helpers/image" + policytypes "github.com/moby/policy-helpers/types" + "github.com/opencontainers/go-digest" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/pkg/errors" + "github.com/sigstore/sigstore-go/pkg/fulcio/certificate" + "github.com/stretchr/testify/require" +) + +// TestSubjectPredicateAcceptsSLSA02 asserts that a SLSA v0.2 attestation +// file is accepted and converted to the v1 shape used internally. +func TestSubjectPredicateAcceptsSLSA02(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "provenance.intoto.json") + + stmt := map[string]any{ + "_type": "https://in-toto.io/Statement/v0.1", + "predicateType": "https://slsa.dev/provenance/v0.2", + "subject": []any{}, + "predicate": map[string]any{ + "builder": map[string]string{"id": "buildkit"}, + "buildType": "https://mobyproject.org/buildkit@v1", + "invocation": map[string]any{ + "configSource": map[string]any{"uri": "https://example.com/dockerfile"}, + "parameters": map[string]any{}, + "environment": map[string]any{"platform": "linux/amd64"}, + }, + }, + } + dt, err := json.Marshal(stmt) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, dt, 0644)) + + subjects, err := LoadSubjects(context.Background(), nil, "", path) + require.NoError(t, err) + require.Len(t, subjects, 1) + require.True(t, subjects[0].IsAttestationFile()) + + _, err = subjects[0].Predicate(context.Background()) + require.NoError(t, err) +} + +// TestSubjectPredicateRejectsUnknown asserts that a predicateType outside +// the SLSA v1 / v0.2 set is rejected with UnsupportedPredicateError. +// TestSubjectPredicateAcceptsBarePredicate asserts that a provenance +// predicate that is not wrapped in an in-toto statement, as printed by +// `imagetools inspect --format '{{json .Provenance.SLSA}}'`, is accepted. +func TestSubjectPredicateAcceptsBarePredicate(t *testing.T) { + for _, tc := range []struct { + name string + pred map[string]any + }{ + { + name: "slsa-v1", + pred: map[string]any{ + "buildDefinition": map[string]any{ + "buildType": "https://github.com/moby/buildkit/blob/master/docs/attestations/slsa-definitions.md", + "externalParameters": map[string]any{"configSource": map[string]any{"uri": "https://example.com/repo.git"}}, + }, + "runDetails": map[string]any{"builder": map[string]any{"id": ""}}, + }, + }, + { + name: "slsa-v0.2", + pred: map[string]any{ + "builder": map[string]string{"id": "buildkit"}, + "buildType": "https://mobyproject.org/buildkit@v1", + "invocation": map[string]any{ + "configSource": map[string]any{"uri": "https://example.com/repo.git"}, + }, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "provenance.json") + dt, err := json.Marshal(tc.pred) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, dt, 0o644)) + + subjects, err := LoadSubjects(context.Background(), nil, "", path) + require.NoError(t, err) + require.Len(t, subjects, 1) + pred, err := subjects[0].Predicate(context.Background()) + require.NoError(t, err) + require.Equal(t, "https://example.com/repo.git", pred.ConfigSource().URI) + }) + } +} + +func TestLoadSubjectsInputErrors(t *testing.T) { + dir := t.TempDir() + + _, err := LoadSubjects(context.Background(), nil, "", filepath.Join(dir, "missing.json")) + require.ErrorIs(t, err, os.ErrNotExist) + + notStatement := filepath.Join(dir, "array.json") + require.NoError(t, os.WriteFile(notStatement, []byte(`[1, 2]`), 0o644)) + _, err = LoadSubjects(context.Background(), nil, "", notStatement) + require.EqualError(t, err, notStatement+" is not an in-toto statement, DSSE envelope or Sigstore bundle") + + noType := filepath.Join(dir, "object.json") + require.NoError(t, os.WriteFile(noType, []byte(`{"foo": "bar"}`), 0o644)) + _, err = LoadSubjects(context.Background(), nil, "", noType) + require.ErrorContains(t, err, "has no predicateType") +} + +func TestSubjectPredicateRejectsUnknown(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "provenance.intoto.json") + + stmt := map[string]any{ + "_type": "https://in-toto.io/Statement/v0.1", + "predicateType": "https://example.com/custom/v1", + "subject": []any{}, + "predicate": map[string]any{}, + } + dt, err := json.Marshal(stmt) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, dt, 0644)) + + subjects, err := LoadSubjects(context.Background(), nil, "", path) + require.NoError(t, err) + _, err = subjects[0].Predicate(context.Background()) + require.Error(t, err) + var unsup *UnsupportedPredicateError + require.ErrorAs(t, err, &unsup) + require.Equal(t, "https://example.com/custom/v1", unsup.PredicateType) +} + +// TestSubjectPredicateAttestationFileSLSA1 asserts that an unsigned DSSE-less +// in-toto Statement carrying a SLSA v1 predicate round-trips through +// LoadSubjects + Predicate without error. +func TestSubjectPredicateAttestationFileSLSA1(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "provenance.intoto.json") + stmt := map[string]any{ + "_type": "https://in-toto.io/Statement/v0.1", + "predicateType": "https://slsa.dev/provenance/v1", + "subject": []any{}, + "predicate": map[string]any{ + "buildDefinition": map[string]any{ + "externalParameters": map[string]any{ + "request": map[string]any{ + "frontend": "dockerfile.v0", + }, + }, + }, + }, + } + dt, err := json.Marshal(stmt) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, dt, 0644)) + + subjects, err := LoadSubjects(context.Background(), nil, "", path) + require.NoError(t, err) + require.Len(t, subjects, 1) + + pred, err := subjects[0].Predicate(context.Background()) + require.NoError(t, err) + require.Equal(t, "dockerfile.v0", pred.Frontend()) +} + +// TestSubjectPredicateRejectsSignedDSSE asserts that a DSSE envelope with +// non-empty signatures is rejected with SignatureVerificationRequiredError — +// a bare DSSE envelope carries no verification material, so replay never +// silently accepts it. +func TestSubjectPredicateRejectsSignedDSSE(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "signed.dsse.json") + + inner := map[string]any{ + "_type": "https://in-toto.io/Statement/v0.1", + "predicateType": "https://slsa.dev/provenance/v1", + "subject": []any{}, + "predicate": map[string]any{}, + } + innerDt, err := json.Marshal(inner) + require.NoError(t, err) + + env := map[string]any{ + "payload": base64.StdEncoding.EncodeToString(innerDt), + "payloadType": "application/vnd.in-toto+json", + "signatures": []map[string]string{ + {"sig": "MEUCIQDstubbedsignaturebytes==", "keyid": "test-key"}, + }, + } + dt, err := json.Marshal(env) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, dt, 0644)) + + _, err = LoadSubjects(context.Background(), nil, "", path) + require.Error(t, err) + var sig *SignatureVerificationRequiredError + require.ErrorAs(t, err, &sig) + require.Equal(t, path, sig.Source) + require.Equal(t, "dsse", sig.Envelope) +} + +// TestSubjectPredicateRejectsSigstoreBundle asserts that a Sigstore bundle +// shape is rejected without signature verification support. +func TestSubjectPredicateRejectsSigstoreBundle(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "bundle.sigstore.json") + + bundle := map[string]any{ + "mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json", + "verificationMaterial": map[string]any{ + "tlogEntries": []any{}, + }, + "dsseEnvelope": map[string]any{ + "payload": base64.StdEncoding.EncodeToString([]byte(`{}`)), + "payloadType": "application/vnd.in-toto+json", + "signatures": []any{}, + }, + } + dt, err := json.Marshal(bundle) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, dt, 0644)) + + _, err = LoadSubjects(context.Background(), nil, "", path) + require.Error(t, err) + var sig *SignatureVerificationRequiredError + require.ErrorAs(t, err, &sig) + require.Equal(t, "sigstore-bundle", sig.Envelope) +} + +func TestVerifySigstoreBundle(t *testing.T) { + artifactDigest := digest.FromString("signed artifact") + statement := map[string]any{ + "_type": "https://in-toto.io/Statement/v1", + "predicateType": "https://slsa.dev/provenance/v1", + "subject": []any{map[string]any{ + "name": "buildx", + "digest": map[string]string{ + artifactDigest.Algorithm().String(): artifactDigest.Encoded(), + }, + }}, + "predicate": map[string]any{}, + } + statementBytes, err := json.Marshal(statement) + require.NoError(t, err) + bundle := map[string]any{ + "mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json", + "verificationMaterial": map[string]any{"tlogEntries": []any{}}, + "dsseEnvelope": map[string]any{ + "payload": base64.StdEncoding.EncodeToString(statementBytes), + "payloadType": "application/vnd.in-toto+json", + "signatures": []any{map[string]string{"sig": "verified-by-test-double"}}, + }, + } + bundleBytes, err := json.Marshal(bundle) + require.NoError(t, err) + + verifiedAt := time.Date(2026, time.September, 18, 12, 0, 0, 0, time.UTC) + trustRootUpdatedAt := verifiedAt.Add(-time.Hour) + var verifierCalled bool + payload, signature, isBundle, err := verifySigstoreBundle(context.Background(), bundleBytes, "buildx.sigstore.json", func(ctx context.Context, gotDigest digest.Digest, gotBundle []byte) (*policytypes.SignatureInfo, error) { + verifierCalled = true + require.Equal(t, artifactDigest, gotDigest) + require.Equal(t, bundleBytes, gotBundle) + return &policytypes.SignatureInfo{ + Kind: policytypes.KindSelfSignedGithubRepo, + SignatureType: policytypes.SignatureBundleV03, + Signer: &certificate.Summary{ + CertificateIssuer: "CN=sigstore-intermediate,O=sigstore.dev", + SubjectAlternativeName: "https://github.com/docker/buildx/.github/workflows/release.yml@refs/tags/v0.37.1", + Extensions: certificate.Extensions{ + Issuer: "https://token.actions.githubusercontent.com", + SourceRepositoryURI: "https://github.com/docker/buildx", + SourceRepositoryRef: "refs/tags/v0.37.1", + BuildSignerURI: "https://github.com/docker/buildx/.github/workflows/release.yml", + RunnerEnvironment: "github-hosted", + }, + }, + Timestamps: []policytypes.TimestampVerificationResult{{ + Type: "Tlog", + URI: "https://rekor.sigstore.dev", + Timestamp: verifiedAt, + }}, + TrustRootStatus: policytypes.TrustRootStatus{LastUpdated: &trustRootUpdatedAt}, + }, nil + }) + require.NoError(t, err) + require.True(t, verifierCalled) + require.True(t, isBundle) + require.JSONEq(t, string(statementBytes), string(payload)) + require.NotNil(t, signature) + require.True(t, signature.Verified) + require.Equal(t, "Sigstore Bundle", signature.Type) + require.Equal(t, "GitHub Self-Signed (docker/buildx)", signature.Identity) + require.Equal(t, "refs/tags/v0.37.1", signature.SourceRepositoryRef) + require.Equal(t, verifiedAt, signature.Timestamps[0].Timestamp) + require.Equal(t, trustRootUpdatedAt, *signature.TrustRootLastUpdated) + + payload, signature, isBundle, err = verifySigstoreBundle(context.Background(), bundleBytes, "buildx.sigstore.json", func(context.Context, digest.Digest, []byte) (*policytypes.SignatureInfo, error) { + return nil, errors.New("invalid signature") + }) + require.ErrorContains(t, err, "verify sigstore bundle") + require.ErrorContains(t, err, "invalid signature") + require.True(t, isBundle) + require.Nil(t, payload) + require.Nil(t, signature) + + payload, signature, isBundle, err = verifySigstoreBundle(context.Background(), bundleBytes, "buildx.sigstore.json", func(context.Context, digest.Digest, []byte) (*policytypes.SignatureInfo, error) { + return nil, nil + }) + require.ErrorContains(t, err, "signature verifier returned no verification result") + require.True(t, isBundle) + require.Nil(t, payload) + require.Nil(t, signature) + + missingSubjectStatement, err := json.Marshal(map[string]any{ + "_type": "https://in-toto.io/Statement/v1", + "predicateType": "https://slsa.dev/provenance/v1", + "subject": []any{}, + "predicate": map[string]any{}, + }) + require.NoError(t, err) + bundle["dsseEnvelope"].(map[string]any)["payload"] = base64.StdEncoding.EncodeToString(missingSubjectStatement) + missingSubjectBundle, err := json.Marshal(bundle) + require.NoError(t, err) + payload, signature, isBundle, err = verifySigstoreBundle(context.Background(), missingSubjectBundle, "buildx.sigstore.json", func(context.Context, digest.Digest, []byte) (*policytypes.SignatureInfo, error) { + require.Fail(t, "verifier must not be called without a subject digest") + return nil, nil + }) + require.ErrorContains(t, err, "no verifiable subject digest") + require.True(t, isBundle) + require.Nil(t, payload) + require.Nil(t, signature) +} + +func TestVerifyImageSignature(t *testing.T) { + platform := &ocispecs.Platform{OS: "linux", Architecture: "amd64"} + root := ocispecs.Descriptor{ + MediaType: ocispecs.MediaTypeImageIndex, + Digest: digest.FromString("image index"), + } + subject := &Subject{ + Descriptor: ocispecs.Descriptor{ + MediaType: ocispecs.MediaTypeImageManifest, + Digest: digest.FromString("image manifest"), + Platform: platform, + }, + Provider: &imageSubjectProvider{Provider: contentutil.NewBuffer()}, + inputRef: "docker/buildx-bin:0.37.1", + kind: subjectKindImage, + attestManifest: ocispecs.Descriptor{ + Digest: digest.FromString("attestation manifest"), + }, + rootDescriptor: root, + } + verifiedAt := time.Date(2026, time.September, 18, 12, 0, 0, 0, time.UTC) + err := subject.verifyImageSignature(context.Background(), func(_ context.Context, provider policyimage.ReferrersProvider, gotRoot ocispecs.Descriptor, gotPlatform *ocispecs.Platform) (*policytypes.SignatureInfo, error) { + require.Same(t, subject.Provider, provider) + require.Equal(t, root, gotRoot) + require.Equal(t, platform, gotPlatform) + return &policytypes.SignatureInfo{ + Kind: policytypes.KindDockerGithubBuilder, + SignatureType: policytypes.SignatureBundleV03, + Signer: &certificate.Summary{ + CertificateIssuer: "CN=sigstore-intermediate,O=sigstore.dev", + Extensions: certificate.Extensions{ + SourceRepositoryURI: "https://github.com/docker/buildx", + SourceRepositoryRef: "refs/tags/v0.37.1", + }, + }, + Timestamps: []policytypes.TimestampVerificationResult{{ + Type: "Tlog", + Timestamp: verifiedAt, + }}, + }, nil + }) + require.NoError(t, err) + require.NotNil(t, subject.Signature()) + require.True(t, subject.Signature().Verified) + require.Equal(t, "Sigstore Bundle", subject.Signature().Type) + require.Equal(t, "refs/tags/v0.37.1", subject.Signature().SourceRepositoryRef) + require.Equal(t, verifiedAt, subject.Signature().Timestamps[0].Timestamp) + + unsigned := *subject + unsigned.signature = nil + err = unsigned.verifyImageSignature(context.Background(), func(context.Context, policyimage.ReferrersProvider, ocispecs.Descriptor, *ocispecs.Platform) (*policytypes.SignatureInfo, error) { + return nil, errors.WithStack(&policyverifier.NoSigChainError{Target: root.Digest, HasAttestation: true}) + }) + require.NoError(t, err) + require.Nil(t, unsigned.Signature()) + + invalid := *subject + invalid.signature = nil + err = invalid.verifyImageSignature(context.Background(), func(context.Context, policyimage.ReferrersProvider, ocispecs.Descriptor, *ocispecs.Platform) (*policytypes.SignatureInfo, error) { + return nil, errors.New("invalid image signature") + }) + require.ErrorContains(t, err, "verify image signature") + require.ErrorContains(t, err, "invalid image signature") + require.Nil(t, invalid.Signature()) + + second := *subject + second.signature = nil + second.Descriptor = subject.Descriptor + second.Descriptor.Platform = &ocispecs.Platform{OS: "linux", Architecture: "arm64"} + var verifiedPlatforms []string + err = verifySubjectSignatures(context.Background(), []*Subject{subject, &second}, func(_ context.Context, _ policyimage.ReferrersProvider, _ ocispecs.Descriptor, platform *ocispecs.Platform) (*policytypes.SignatureInfo, error) { + verifiedPlatforms = append(verifiedPlatforms, platform.Architecture) + return &policytypes.SignatureInfo{Kind: policytypes.KindUntrusted, SignatureType: policytypes.SignatureBundleV03}, nil + }) + require.NoError(t, err) + require.Equal(t, []string{"amd64", "arm64"}, verifiedPlatforms) + require.NotNil(t, second.Signature()) +} + +// TestSubjectPredicateAcceptsUnsignedDSSE asserts that a DSSE envelope with +// an empty (or missing) signatures array is still accepted — the rejection +// is gated on actual signatures being present. +func TestSubjectPredicateAcceptsUnsignedDSSE(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "unsigned.dsse.json") + + inner := map[string]any{ + "_type": "https://in-toto.io/Statement/v0.1", + "predicateType": "https://slsa.dev/provenance/v1", + "subject": []any{}, + "predicate": map[string]any{}, + } + innerDt, err := json.Marshal(inner) + require.NoError(t, err) + + env := map[string]any{ + "payload": base64.StdEncoding.EncodeToString(innerDt), + "payloadType": "application/vnd.in-toto+json", + "signatures": []any{}, + } + dt, err := json.Marshal(env) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, dt, 0644)) + + subjects, err := LoadSubjects(context.Background(), nil, "", path) + require.NoError(t, err) + require.Len(t, subjects, 1) + require.True(t, subjects[0].IsAttestationFile()) +} + +// TestLoadSubjectsIndexFanout builds an OCI layout with a two-platform +// image index (amd64 + arm64) and asserts LoadSubjects returns two subjects +// with distinct Descriptor.Platform. +func TestLoadSubjectsIndexFanout(t *testing.T) { + dir := t.TempDir() + + store, err := contentlocal.NewStore(dir) + require.NoError(t, err) + + ctx := context.Background() + + cfgAmd64 := []byte(`{"architecture":"amd64","os":"linux"}`) + cfgAmd64Dgst, cfgAmd64Sz := putBlob(ctx, t, store, cfgAmd64, "application/vnd.oci.image.config.v1+json") + cfgArm64 := []byte(`{"architecture":"arm64","os":"linux"}`) + cfgArm64Dgst, cfgArm64Sz := putBlob(ctx, t, store, cfgArm64, "application/vnd.oci.image.config.v1+json") + + amd64Desc := putManifest(ctx, t, store, ocispecs.Manifest{ + MediaType: ocispecs.MediaTypeImageManifest, + Config: ocispecs.Descriptor{ + MediaType: "application/vnd.oci.image.config.v1+json", + Digest: cfgAmd64Dgst, + Size: cfgAmd64Sz, + }, + }, &ocispecs.Platform{Architecture: "amd64", OS: "linux"}) + + arm64Desc := putManifest(ctx, t, store, ocispecs.Manifest{ + MediaType: ocispecs.MediaTypeImageManifest, + Config: ocispecs.Descriptor{ + MediaType: "application/vnd.oci.image.config.v1+json", + Digest: cfgArm64Dgst, + Size: cfgArm64Sz, + }, + }, &ocispecs.Platform{Architecture: "arm64", OS: "linux"}) + + idx := ocispecs.Index{ + MediaType: ocispecs.MediaTypeImageIndex, + Manifests: []ocispecs.Descriptor{amd64Desc, arm64Desc}, + } + idx.SchemaVersion = 2 + + idxDt, err := json.Marshal(idx) + require.NoError(t, err) + idxDgst, idxSize := putBlob(ctx, t, store, idxDt, ocispecs.MediaTypeImageIndex) + + storeIdx := ociindex.NewStoreIndex(dir) + require.NoError(t, storeIdx.Put(ocispecs.Descriptor{ + MediaType: ocispecs.MediaTypeImageIndex, + Digest: idxDgst, + Size: idxSize, + }, ociindex.Tag("latest"))) + + subjects, err := LoadSubjects(ctx, nil, "", "oci-layout://"+dir+":latest") + require.NoError(t, err) + require.Len(t, subjects, 2, "expected two fan-out subjects") + + sort.Slice(subjects, func(i, j int) bool { + return subjects[i].Descriptor.Platform.Architecture < subjects[j].Descriptor.Platform.Architecture + }) + require.Equal(t, "amd64", subjects[0].Descriptor.Platform.Architecture) + require.Equal(t, "arm64", subjects[1].Descriptor.Platform.Architecture) + require.NotEqual(t, subjects[0].Descriptor.Digest, subjects[1].Descriptor.Digest) +} + +// TestLoadSubjectsFanoutSkipsAttestation exercises the attestation-manifest +// filtering path: an index with an attestation manifest annotated via +// vnd.docker.reference.digest must not produce a bonus subject for the +// attestation. +func TestLoadSubjectsFanoutSkipsAttestation(t *testing.T) { + dir := t.TempDir() + store, err := contentlocal.NewStore(dir) + require.NoError(t, err) + ctx := context.Background() + + cfgDt := []byte(`{"architecture":"amd64","os":"linux"}`) + cfgDgst, cfgSize := putBlob(ctx, t, store, cfgDt, "application/vnd.oci.image.config.v1+json") + imgDesc := putManifest(ctx, t, store, ocispecs.Manifest{ + MediaType: ocispecs.MediaTypeImageManifest, + Config: ocispecs.Descriptor{ + MediaType: "application/vnd.oci.image.config.v1+json", + Digest: cfgDgst, + Size: cfgSize, + }, + }, &ocispecs.Platform{Architecture: "amd64", OS: "linux"}) + + // Synthesize a bare "attestation manifest" that references imgDesc. + attestManifest := ocispecs.Manifest{ + MediaType: ocispecs.MediaTypeImageManifest, + Config: ocispecs.Descriptor{ + MediaType: "application/vnd.oci.image.config.v1+json", + Digest: cfgDgst, + Size: cfgSize, + }, + } + attestDt, err := json.Marshal(attestManifest) + require.NoError(t, err) + attestDgst, attestSize := putBlob(ctx, t, store, attestDt, ocispecs.MediaTypeImageManifest) + + attestDesc := ocispecs.Descriptor{ + MediaType: ocispecs.MediaTypeImageManifest, + Digest: attestDgst, + Size: attestSize, + Annotations: map[string]string{ + attestation.DockerAnnotationReferenceType: attestation.DockerAnnotationReferenceTypeDefault, + attestation.DockerAnnotationReferenceDigest: imgDesc.Digest.String(), + }, + } + duplicateAttestDesc := attestDesc + duplicateAttestDesc.Digest = digest.FromString("later duplicate attestation") + duplicateAttestDesc.Size = 1 + + idx := ocispecs.Index{ + MediaType: ocispecs.MediaTypeImageIndex, + Manifests: []ocispecs.Descriptor{imgDesc, attestDesc, duplicateAttestDesc}, + } + idx.SchemaVersion = 2 + idxDt, err := json.Marshal(idx) + require.NoError(t, err) + idxDgst, idxSize := putBlob(ctx, t, store, idxDt, ocispecs.MediaTypeImageIndex) + + storeIdx := ociindex.NewStoreIndex(dir) + require.NoError(t, storeIdx.Put(ocispecs.Descriptor{ + MediaType: ocispecs.MediaTypeImageIndex, + Digest: idxDgst, + Size: idxSize, + }, ociindex.Tag("latest"))) + + subjects, err := LoadSubjects(ctx, nil, "", "oci-layout://"+dir+":latest") + require.NoError(t, err) + require.Len(t, subjects, 1, "attestation manifest should not expand to a subject") + require.Equal(t, imgDesc.Digest, subjects[0].Descriptor.Digest) + require.Equal(t, attestDgst, subjects[0].AttestationManifest().Digest, "subject should record its attestation manifest") + + untyped := attestDesc + untyped.Annotations = map[string]string{ + attestation.DockerAnnotationReferenceDigest: imgDesc.Digest.String(), + } + require.Empty(t, attestationReferenceDigest(untyped), "a digest annotation alone must not select an attestation manifest") +} + +// putBlob writes raw bytes to the content store and returns the digest/size. +func putBlob(ctx context.Context, t *testing.T, store content.Ingester, dt []byte, mediaType string) (digest.Digest, int64) { + t.Helper() + dgst := digest.FromBytes(dt) + desc := ocispecs.Descriptor{MediaType: mediaType, Digest: dgst, Size: int64(len(dt))} + err := content.WriteBlob(ctx, store, dgst.String(), bytes.NewReader(dt), desc) + require.NoError(t, err) + return dgst, int64(len(dt)) +} + +// putManifest marshals an OCI manifest and writes it to the store. Returns +// the descriptor (with optional platform). +func putManifest(ctx context.Context, t *testing.T, store content.Ingester, mfst ocispecs.Manifest, plat *ocispecs.Platform) ocispecs.Descriptor { + t.Helper() + mfst.SchemaVersion = 2 + dt, err := json.Marshal(mfst) + require.NoError(t, err) + dgst, sz := putBlob(ctx, t, store, dt, ocispecs.MediaTypeImageManifest) + return ocispecs.Descriptor{ + MediaType: ocispecs.MediaTypeImageManifest, + Digest: dgst, + Size: sz, + Platform: plat, + } +} diff --git a/replay/verify.go b/replay/verify.go new file mode 100644 index 000000000000..052d5a6e4000 --- /dev/null +++ b/replay/verify.go @@ -0,0 +1,285 @@ +package replay + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/containerd/containerd/v2/core/content" + contentlocal "github.com/containerd/containerd/v2/plugins/content/local" + "github.com/docker/buildx/util/buildflags" + "github.com/docker/cli/cli/command" + "github.com/moby/buildkit/client/ociindex" + "github.com/moby/buildkit/util/progress/progressui" + digest "github.com/opencontainers/go-digest" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/pkg/errors" +) + +// Compare modes accepted by Verify. +const ( + CompareModeDigest = "digest" + CompareModeArtifact = "artifact" +) + +// VerifyVSAPredicateType is the in-toto predicate type for a SLSA +// Verification Summary Attestation. +const VerifyVSAPredicateType = "https://slsa.dev/verification_summary/v1" + +// VerifyRequest is the library-level input to Verify. +type VerifyRequest struct { + // Subject is the loaded subject (exactly one — multi-platform subjects + // are verified one at a time by the caller). + Subject *Subject + // Predicate is the subject's provenance predicate. + Predicate *Predicate + // Mode selects the comparison strategy: "digest" (default) or + // "artifact" (descriptor tree walk). + Mode string + // Materials resolver, same semantics as BuildRequest.Materials. + Materials *MaterialsResolver + // Network controls the replayed build's RUN-network mode. + Network string + // Secrets / SSH mirror the BuildRequest shape for secret pass-through. + Secrets buildflags.Secrets + SSH []*buildflags.SSH + // Output is an optional type=local --output spec for the VSA and diff + // report. + Output *buildflags.ExportEntry + // Progress is the progress display mode for the replayed build. + Progress progressui.DisplayMode +} + +// VerifyResult is the library-level result of a verification. +type VerifyResult struct { + Matched bool + DiffReport *CompareReport + // VSABytes is the in-toto Statement bytes written when req.Output is + // set; empty otherwise. + VSABytes []byte +} + +// Verify replays the subject to an ephemeral OCI layout, compares, and +// optionally writes a VSA + diff report to req.Output. +// +// On a mismatch the returned error is a typed CompareMismatchError wrapping +// the diff report; callers should not attempt to interpret Matched=false with +// nil error. +func Verify(ctx context.Context, dockerCli command.Cli, builderName string, req *VerifyRequest) (_ *VerifyResult, retErr error) { + if req == nil { + return nil, errors.New("nil verify request") + } + if req.Subject == nil { + return nil, errors.New("nil subject") + } + if req.Predicate == nil { + return nil, errors.New("nil predicate") + } + + mode := req.Mode + if mode == "" { + mode = CompareModeDigest + } + switch mode { + case CompareModeDigest, CompareModeArtifact: + // ok + default: + return nil, errors.Errorf("unknown --compare mode %q", mode) + } + if req.Output != nil && req.Output.Type != "local" { + return nil, errors.Errorf("unsupported verify --output type %q (want local)", req.Output.Type) + } + + // Attestation-file subjects have no produced artifact to verify against. + if req.Subject.IsAttestationFile() { + return nil, ErrUnsupportedSubject("verify requires an image or oci-layout subject") + } + if err := checkReplayable(req.Predicate, BuildModeMaterials, req.Secrets, req.SSH); err != nil { + return nil, err + } + + // Prepare ephemeral OCI layout for the replay output. + tmpDir, err := os.MkdirTemp("", "buildx-replay-verify-") + if err != nil { + return nil, errors.WithStack(err) + } + defer os.RemoveAll(tmpDir) + + layoutDir := filepath.Join(tmpDir, "replay-oci") + if err := os.MkdirAll(layoutDir, 0o755); err != nil { + return nil, errors.WithStack(err) + } + + // Run the replay build into the layout. + if err := verifyReplay(ctx, dockerCli, builderName, req, layoutDir); err != nil { + return nil, errors.Wrap(err, "replay for verify") + } + + replayDesc, replayProvider, err := openVerifyReplayLayout(layoutDir) + if err != nil { + return nil, errors.Wrap(err, "open replay layout") + } + + result := &VerifyResult{} + switch mode { + case CompareModeDigest: + result.Matched = CompareDigest(req.Subject.Descriptor, replayDesc) + case CompareModeArtifact: + replaySubj := &Subject{Descriptor: replayDesc, Provider: replayProvider} + rep, err := CompareArtifact(ctx, req.Subject, replaySubj) + if err != nil { + return nil, err + } + result.DiffReport = rep + result.Matched = ReportMatched(rep) + } + + // VSA + output. + vsa, err := buildVSA(req, replayDesc, result, mode) + if err != nil { + return nil, err + } + result.VSABytes = vsa + + if req.Output != nil { + if err := writeVerifyOutput(req, result, vsa); err != nil { + return nil, err + } + } + + if !result.Matched { + reason := fmt.Sprintf("verify --compare=%s failed", mode) + return result, ErrCompareMismatch(reason, result.DiffReport) + } + return result, nil +} + +// verifyReplay replays the subject into an OCI layout directory. +func verifyReplay(ctx context.Context, dockerCli command.Cli, builderName string, req *VerifyRequest, layoutDir string) error { + exportEntry := &buildflags.ExportEntry{ + Type: "oci", + Destination: layoutDir, + Attrs: map[string]string{ + // tar=false forces the oci exporter to emit an OCI layout tree + // (blobs/, index.json) which we can then open with + // contentlocal.NewStore. + "tar": "false", + }, + } + exportSpecs := []*buildflags.ExportEntry{exportEntry} + + breq := &BuildRequest{ + Targets: []Target{{Subject: req.Subject, Predicate: req.Predicate}}, + Mode: BuildModeMaterials, + Materials: req.Materials, + NetworkMode: req.Network, + Secrets: req.Secrets, + SSH: req.SSH, + Exports: exportSpecs, + Progress: req.Progress, + } + + return Build(ctx, dockerCli, builderName, breq) +} + +// openVerifyReplayLayout reads the root descriptor from an OCI-layout +// directory that buildx just populated via type=oci export. +func openVerifyReplayLayout(dir string) (ocispecs.Descriptor, content.Provider, error) { + store, err := contentlocal.NewStore(dir) + if err != nil { + return ocispecs.Descriptor{}, nil, errors.Wrap(err, "open layout store") + } + idx, err := ociindex.NewStoreIndex(dir).Read() + if err != nil { + return ocispecs.Descriptor{}, nil, errors.Wrap(err, "read layout index") + } + if len(idx.Manifests) == 0 { + return ocispecs.Descriptor{}, nil, errors.New("empty layout index") + } + return idx.Manifests[0], store, nil +} + +// buildVSA returns an in-toto Statement containing a SLSA VSA predicate as a +// single-line JSON document. +func buildVSA(req *VerifyRequest, replayDesc ocispecs.Descriptor, result *VerifyResult, mode string) ([]byte, error) { + status := "PASSED" + if !result.Matched { + status = "FAILED" + } + subjectName := req.Subject.InputRef() + if subjectName == "" { + subjectName = req.Subject.Descriptor.Digest.String() + } + statement := map[string]any{ + "_type": "https://in-toto.io/Statement/v1", + "predicateType": VerifyVSAPredicateType, + "subject": []map[string]any{ + { + "name": subjectName, + "digest": digestToDigestSet(req.Subject.Descriptor.Digest), + }, + }, + "predicate": map[string]any{ + "verifier": map[string]any{ + "id": "https://github.com/docker/buildx", + }, + "timeVerified": time.Now().UTC().Format(time.RFC3339), + "resourceUri": subjectName, + "policy": map[string]any{"uri": ""}, + "verificationResult": status, + "verifiedLevels": []string{}, + "dependencyLevels": map[string]any{}, + "inputAttestations": []map[string]any{ + { + "uri": subjectName, + "digest": digestToDigestSet(req.Subject.AttestationManifest().Digest), + }, + }, + // Buildx-specific fields that have no VSA equivalent. + "buildx": map[string]any{ + "replayMode": string(BuildModeMaterials), + "compareMode": mode, + "replayDigest": replayDesc.Digest.String(), + "subjectDigest": req.Subject.Descriptor.Digest.String(), + }, + }, + } + dt, err := json.Marshal(statement) + return dt, errors.WithStack(err) +} + +// digestToDigestSet turns an OCI digest into the in-toto DigestSet shape. +func digestToDigestSet(d digest.Digest) map[string]string { + if d == "" { + return map[string]string{} + } + return map[string]string{d.Algorithm().String(): d.Encoded()} +} + +// writeVerifyOutput writes the VSA and diff report to a type=local +// destination directory. +func writeVerifyOutput(req *VerifyRequest, result *VerifyResult, vsa []byte) error { + dest := req.Output.Destination + if dest == "" { + return errors.New("verify output type=local requires dest=

") + } + if err := os.MkdirAll(dest, 0o755); err != nil { + return errors.WithStack(err) + } + if err := os.WriteFile(filepath.Join(dest, "vsa.intoto.jsonl"), append(vsa, '\n'), 0o644); err != nil { + return errors.WithStack(err) + } + if result.DiffReport != nil { + dt, err := ReportJSON(result.DiffReport) + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(dest, "diff.json"), dt, 0o644); err != nil { + return errors.WithStack(err) + } + } + return nil +} diff --git a/replay/verify_compare.go b/replay/verify_compare.go new file mode 100644 index 000000000000..261f2ef73c1b --- /dev/null +++ b/replay/verify_compare.go @@ -0,0 +1,257 @@ +package replay + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/containerd/containerd/v2/core/content" + "github.com/containerd/containerd/v2/core/images" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/pkg/errors" +) + +const ( + EventTypeNone = "" + EventTypeDescriptorMismatch = "DescriptorMismatch" + EventTypeIndexBlobMismatch = "IndexBlobMismatch" + EventTypeConfigBlobMismatch = "ConfigBlobMismatch" + EventTypeLayerBlobMismatch = "LayerBlobMismatch" +) + +// CompareEventInput carries the relevant object for one side of a mismatch. +type CompareEventInput struct { + Descriptor *ocispecs.Descriptor `json:"descriptor,omitempty"` + Index *ocispecs.Index `json:"index,omitempty"` + Manifest *ocispecs.Manifest `json:"manifest,omitempty"` +} + +// CompareEvent records a single divergence at one tree node. +type CompareEvent struct { + Type string `json:"type,omitempty"` + Inputs [2]CompareEventInput `json:"inputs,omitempty"` + Diff string `json:"diff,omitempty"` +} + +// CompareReport is the basic per-node event tree emitted by replay verify. +type CompareReport struct { + CompareEvent + Context string `json:"context,omitempty"` + Children []*CompareReport `json:"children,omitempty"` +} + +// CompareDigest returns whether subject and replay descriptors share the same +// manifest digest. This is the fastest check and is the default for +// `replay verify`. +func CompareDigest(subject, replay ocispecs.Descriptor) bool { + return subject.Digest != "" && subject.Digest == replay.Digest +} + +// CompareArtifact walks both subject and replay stores and returns a +// CompareReport describing any divergence. +// +// The comparison is content-addressed: it reports which descriptors differ, +// not how their contents differ. A manifest-digest match short-circuits the +// walk and returns an empty report (no divergence). A mismatch at any level +// surfaces as an event node populated with inputs referring to the two sides. +func CompareArtifact(ctx context.Context, subject, replay *Subject) (*CompareReport, error) { + if subject == nil || replay == nil { + return nil, errors.New("compare: nil subject or replay") + } + if subject.Provider == nil || replay.Provider == nil { + return nil, errors.New("compare: nil content provider") + } + + // Short-circuit when both descriptors already have the same digest. + if CompareDigest(subject.Descriptor, replay.Descriptor) { + return &CompareReport{Context: "root"}, nil + } + + root := &CompareReport{Context: "root"} + if err := compareDescriptor(ctx, root, subject.Provider, subject.Descriptor, replay.Provider, replay.Descriptor); err != nil { + return root, err + } + return root, nil +} + +// compareDescriptor does a recursive content compare between two descriptors. +// A changed container descriptor is reported and then inspected further when +// both sides have the same index or manifest media type. Leaf changes and +// incompatible media types stop at the descriptor event. +func compareDescriptor(ctx context.Context, parent *CompareReport, pa content.Provider, da ocispecs.Descriptor, pb content.Provider, db ocispecs.Descriptor) error { + if da.Digest == db.Digest { + return nil + } + + descA, descB := da, db + parent.Children = append(parent.Children, &CompareReport{ + Context: fmt.Sprintf("descriptor %s", displayMediaType(da.MediaType)), + CompareEvent: CompareEvent{ + Type: EventTypeDescriptorMismatch, + Inputs: [2]CompareEventInput{ + {Descriptor: &descA}, + {Descriptor: &descB}, + }, + Diff: fmt.Sprintf("digest mismatch: %s vs %s", da.Digest, db.Digest), + }, + }) + + if da.MediaType != db.MediaType { + return nil + } + + switch da.MediaType { + case ocispecs.MediaTypeImageIndex, images.MediaTypeDockerSchema2ManifestList: + return compareIndex(ctx, parent, pa, da, pb, db) + case ocispecs.MediaTypeImageManifest, images.MediaTypeDockerSchema2Manifest: + return compareManifest(ctx, parent, pa, da, pb, db) + } + return nil +} + +func compareIndex(ctx context.Context, parent *CompareReport, pa content.Provider, da ocispecs.Descriptor, pb content.Provider, db ocispecs.Descriptor) error { + ia, err := readIndex(ctx, pa, da) + if err != nil { + return err + } + ib, err := readIndex(ctx, pb, db) + if err != nil { + return err + } + if len(ia.Manifests) != len(ib.Manifests) { + parent.Children = append(parent.Children, &CompareReport{ + Context: "index", + CompareEvent: CompareEvent{ + Type: EventTypeIndexBlobMismatch, + Inputs: [2]CompareEventInput{ + {Index: ia}, + {Index: ib}, + }, + Diff: fmt.Sprintf("child count mismatch: %d vs %d", len(ia.Manifests), len(ib.Manifests)), + }, + }) + return nil + } + for i := range ia.Manifests { + node := &CompareReport{Context: fmt.Sprintf("index/manifests[%d]", i)} + if err := compareDescriptor(ctx, node, pa, ia.Manifests[i], pb, ib.Manifests[i]); err != nil { + return err + } + if len(node.Children) > 0 || node.Type != EventTypeNone { + parent.Children = append(parent.Children, node) + } + } + return nil +} + +func compareManifest(ctx context.Context, parent *CompareReport, pa content.Provider, da ocispecs.Descriptor, pb content.Provider, db ocispecs.Descriptor) error { + ma, err := readManifest(ctx, pa, da) + if err != nil { + return err + } + mb, err := readManifest(ctx, pb, db) + if err != nil { + return err + } + if ma.Config.Digest != mb.Config.Digest { + parent.Children = append(parent.Children, &CompareReport{ + Context: "manifest/config", + CompareEvent: CompareEvent{ + Type: EventTypeConfigBlobMismatch, + Inputs: [2]CompareEventInput{ + {Manifest: ma}, + {Manifest: mb}, + }, + Diff: fmt.Sprintf("config digest mismatch: %s vs %s", ma.Config.Digest, mb.Config.Digest), + }, + }) + } + if len(ma.Layers) != len(mb.Layers) { + parent.Children = append(parent.Children, &CompareReport{ + Context: "manifest/layers", + CompareEvent: CompareEvent{ + Type: EventTypeLayerBlobMismatch, + Inputs: [2]CompareEventInput{ + {Manifest: ma}, + {Manifest: mb}, + }, + Diff: fmt.Sprintf("layer count mismatch: %d vs %d", len(ma.Layers), len(mb.Layers)), + }, + }) + return nil + } + for i := range ma.Layers { + if ma.Layers[i].Digest != mb.Layers[i].Digest { + la, lb := ma.Layers[i], mb.Layers[i] + parent.Children = append(parent.Children, &CompareReport{ + Context: fmt.Sprintf("manifest/layers[%d]", i), + CompareEvent: CompareEvent{ + Type: EventTypeLayerBlobMismatch, + Inputs: [2]CompareEventInput{ + {Descriptor: &la}, + {Descriptor: &lb}, + }, + Diff: fmt.Sprintf("layer[%d] digest mismatch: %s vs %s", i, la.Digest, lb.Digest), + }, + }) + } + } + return nil +} + +func readIndex(ctx context.Context, p content.Provider, desc ocispecs.Descriptor) (*ocispecs.Index, error) { + dt, err := content.ReadBlob(ctx, p, desc) + if err != nil { + return nil, errors.Wrapf(err, "read index %s", desc.Digest) + } + var idx ocispecs.Index + if err := json.Unmarshal(dt, &idx); err != nil { + return nil, errors.Wrapf(err, "unmarshal index %s", desc.Digest) + } + return &idx, nil +} + +func readManifest(ctx context.Context, p content.Provider, desc ocispecs.Descriptor) (*ocispecs.Manifest, error) { + dt, err := content.ReadBlob(ctx, p, desc) + if err != nil { + return nil, errors.Wrapf(err, "read manifest %s", desc.Digest) + } + var mfst ocispecs.Manifest + if err := json.Unmarshal(dt, &mfst); err != nil { + return nil, errors.Wrapf(err, "unmarshal manifest %s", desc.Digest) + } + return &mfst, nil +} + +func displayMediaType(mt string) string { + if mt == "" { + return "" + } + return mt +} + +// ReportMatched reports whether a CompareReport represents a successful +// compare (no divergence events). An empty tree counts as matched. +func ReportMatched(r *CompareReport) bool { + if r == nil { + return true + } + if r.Type != EventTypeNone { + return false + } + for _, c := range r.Children { + if !ReportMatched(c) { + return false + } + } + return true +} + +// ReportJSON serializes a CompareReport to JSON. +// A nil report is emitted as an empty object. +func ReportJSON(r *CompareReport) ([]byte, error) { + if r == nil { + return []byte("{}"), nil + } + return json.MarshalIndent(r, "", " ") +} diff --git a/replay/verify_compare_test.go b/replay/verify_compare_test.go new file mode 100644 index 000000000000..a1d9db3bc563 --- /dev/null +++ b/replay/verify_compare_test.go @@ -0,0 +1,150 @@ +package replay + +import ( + "bytes" + "context" + "encoding/json" + "testing" + + "github.com/containerd/containerd/v2/core/content" + "github.com/moby/buildkit/util/contentutil" + digest "github.com/opencontainers/go-digest" + ocispecsgo "github.com/opencontainers/image-spec/specs-go" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/stretchr/testify/require" +) + +func TestCompareDigestMatch(t *testing.T) { + d := digest.FromBytes([]byte("hello")) + subject := ocispecs.Descriptor{Digest: d, MediaType: ocispecs.MediaTypeImageManifest} + replay := ocispecs.Descriptor{Digest: d, MediaType: ocispecs.MediaTypeImageManifest} + require.True(t, CompareDigest(subject, replay)) +} + +func TestCompareDigestMismatch(t *testing.T) { + subject := ocispecs.Descriptor{Digest: digest.FromBytes([]byte("a")), MediaType: ocispecs.MediaTypeImageManifest} + replay := ocispecs.Descriptor{Digest: digest.FromBytes([]byte("b")), MediaType: ocispecs.MediaTypeImageManifest} + require.False(t, CompareDigest(subject, replay)) + // Empty subject digest is never considered a match. + require.False(t, CompareDigest(ocispecs.Descriptor{}, replay)) +} + +// writeManifestTree writes a minimal OCI manifest with the given config + +// layer blob contents into buf and returns the manifest descriptor. +func writeManifestTree(t *testing.T, buf contentutil.Buffer, configBytes []byte, layerBytes []byte) ocispecs.Descriptor { + t.Helper() + ctx := context.Background() + + configDesc := ocispecs.Descriptor{ + MediaType: ocispecs.MediaTypeImageConfig, + Digest: digest.FromBytes(configBytes), + Size: int64(len(configBytes)), + } + require.NoError(t, content.WriteBlob(ctx, buf, configDesc.Digest.String(), bytes.NewReader(configBytes), configDesc)) + + layerDesc := ocispecs.Descriptor{ + MediaType: ocispecs.MediaTypeImageLayerGzip, + Digest: digest.FromBytes(layerBytes), + Size: int64(len(layerBytes)), + } + require.NoError(t, content.WriteBlob(ctx, buf, layerDesc.Digest.String(), bytes.NewReader(layerBytes), layerDesc)) + + mfst := ocispecs.Manifest{ + Versioned: ocispecsgo.Versioned{SchemaVersion: 2}, + MediaType: ocispecs.MediaTypeImageManifest, + Config: configDesc, + Layers: []ocispecs.Descriptor{layerDesc}, + } + dt, err := json.Marshal(mfst) + require.NoError(t, err) + desc := ocispecs.Descriptor{ + MediaType: ocispecs.MediaTypeImageManifest, + Digest: digest.FromBytes(dt), + Size: int64(len(dt)), + } + require.NoError(t, content.WriteBlob(ctx, buf, desc.Digest.String(), bytes.NewReader(dt), desc)) + return desc +} + +func TestCompareArtifactIdenticalTrees(t *testing.T) { + // Two identical in-memory content stores (structurally identical + // manifest trees). CompareArtifact should return without error and + // the resulting report should indicate no divergence. + bufA := contentutil.NewBuffer() + bufB := contentutil.NewBuffer() + + configBytes := []byte(`{"architecture":"amd64","os":"linux"}`) + layerBytes := []byte("dummy-layer-content") + + descA := writeManifestTree(t, bufA, configBytes, layerBytes) + descB := writeManifestTree(t, bufB, configBytes, layerBytes) + + // Both trees have the same bytes → same digests. + require.Equal(t, descA.Digest, descB.Digest) + + subject := &Subject{Descriptor: descA, Provider: bufA} + replay := &Subject{Descriptor: descB, Provider: bufB} + + report, err := CompareArtifact(context.Background(), subject, replay) + require.NoError(t, err) + require.NotNil(t, report) + require.True(t, ReportMatched(report), "identical stores should report no divergence") +} + +func TestCompareArtifactMismatch(t *testing.T) { + bufA := contentutil.NewBuffer() + bufB := contentutil.NewBuffer() + + descA := writeManifestTree(t, bufA, []byte(`{"os":"linux"}`), []byte("a")) + descB := writeManifestTree(t, bufB, []byte(`{"os":"linux"}`), []byte("b")) + require.NotEqual(t, descA.Digest, descB.Digest) + + subject := &Subject{Descriptor: descA, Provider: bufA} + replay := &Subject{Descriptor: descB, Provider: bufB} + + report, err := CompareArtifact(context.Background(), subject, replay) + require.NoError(t, err) + require.NotNil(t, report) + require.False(t, ReportMatched(report), "mismatching stores should produce divergence events") + require.True(t, reportHasEvent(report, EventTypeDescriptorMismatch)) + require.True(t, reportHasEvent(report, EventTypeLayerBlobMismatch), "artifact comparison should descend into changed manifests") + + // JSON should round-trip. + raw, err := ReportJSON(report) + require.NoError(t, err) + require.NotEmpty(t, raw) + var parsed CompareReport + require.NoError(t, json.Unmarshal(raw, &parsed)) +} + +func TestCompareArtifactConfigMismatch(t *testing.T) { + bufA := contentutil.NewBuffer() + bufB := contentutil.NewBuffer() + + descA := writeManifestTree(t, bufA, []byte(`{"os":"linux","architecture":"amd64"}`), []byte("same")) + descB := writeManifestTree(t, bufB, []byte(`{"os":"linux","architecture":"arm64"}`), []byte("same")) + + report, err := CompareArtifact(context.Background(), + &Subject{Descriptor: descA, Provider: bufA}, + &Subject{Descriptor: descB, Provider: bufB}, + ) + require.NoError(t, err) + require.False(t, ReportMatched(report)) + require.True(t, reportHasEvent(report, EventTypeConfigBlobMismatch), "artifact comparison should identify the changed config") + require.False(t, reportHasEvent(report, EventTypeLayerBlobMismatch), "identical layers should not be reported") +} + +func reportHasEvent(report *CompareReport, eventType string) bool { + if report == nil { + return false + } + if report.Type == eventType { + return true + } + for _, child := range report.Children { + if reportHasEvent(child, eventType) { + return true + } + } + return false +} diff --git a/replay/verify_test.go b/replay/verify_test.go new file mode 100644 index 000000000000..26b4e05b2971 --- /dev/null +++ b/replay/verify_test.go @@ -0,0 +1,92 @@ +package replay + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestVerifyRejectsUnknownMode(t *testing.T) { + req := &VerifyRequest{ + Subject: &Subject{}, + Predicate: &Predicate{}, + Mode: "bogus", + } + _, err := Verify(context.Background(), nil, "", req) + require.Error(t, err) +} + +func TestVerifyRejectsAttestationFileSubject(t *testing.T) { + s := &Subject{kind: subjectKindAttestationFile} + req := &VerifyRequest{ + Subject: s, + Predicate: &Predicate{}, + Mode: CompareModeDigest, + } + _, err := Verify(context.Background(), nil, "", req) + require.Error(t, err) + var us *UnsupportedSubjectError + require.ErrorAs(t, err, &us) +} + +func TestVerifyRejectsLocalContext(t *testing.T) { + pred := testPredicate(nil, []string{"ctx"}) + req := &VerifyRequest{ + Subject: testSubject(t), + Predicate: pred, + Mode: CompareModeDigest, + } + _, err := Verify(context.Background(), nil, "", req) + require.Error(t, err) + var ulc *UnreplayableLocalContextError + require.ErrorAs(t, err, &ulc) +} + +func TestBuildVSASchema(t *testing.T) { + // Exercise the VSA builder directly — it's deterministic given inputs + // and does not require a live daemon. + s := testSubject(t) + s.inputRef = "docker-image://example.test/app:v1" + + pred := testPredicate(nil, nil) + pred.BuildDefinition.ExternalParameters.ConfigSource.Digest = map[string]string{"sha256": "cafef00d"} + + req := &VerifyRequest{ + Subject: s, + Predicate: pred, + } + result := &VerifyResult{Matched: true} + dt, err := buildVSA(req, s.Descriptor, result, CompareModeDigest) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal(dt, &parsed)) + + require.Equal(t, "https://in-toto.io/Statement/v1", parsed["_type"]) + require.Equal(t, VerifyVSAPredicateType, parsed["predicateType"]) + subjects := parsed["subject"].([]any) + require.Len(t, subjects, 1) + require.Equal(t, "docker-image://example.test/app:v1", subjects[0].(map[string]any)["name"]) + + pred0 := parsed["predicate"].(map[string]any) + require.Equal(t, "PASSED", pred0["verificationResult"]) + verifier := pred0["verifier"].(map[string]any) + require.Equal(t, "https://github.com/docker/buildx", verifier["id"]) + buildxSide := pred0["buildx"].(map[string]any) + require.Equal(t, string(CompareModeDigest), buildxSide["compareMode"]) +} + +func TestBuildVSAFailedStatus(t *testing.T) { + s := testSubject(t) + pred := testPredicate(nil, nil) + req := &VerifyRequest{Subject: s, Predicate: pred} + result := &VerifyResult{Matched: false} + dt, err := buildVSA(req, s.Descriptor, result, CompareModeArtifact) + require.NoError(t, err) + var parsed map[string]any + require.NoError(t, json.Unmarshal(dt, &parsed)) + pred0 := parsed["predicate"].(map[string]any) + require.Equal(t, "FAILED", pred0["verificationResult"]) +} diff --git a/replay/warn.go b/replay/warn.go new file mode 100644 index 000000000000..ca8ca0c75f72 --- /dev/null +++ b/replay/warn.go @@ -0,0 +1,40 @@ +package replay + +import ( + "sync" + + "github.com/docker/buildx/util/progress" +) + +// warnOnce routes non-fatal warnings through a progress sub-logger +// and drops duplicates by key. It is shared across snapshot targets so a +// predicate that references the same material from every platform warns +// at most once. +type warnOnce struct { + mu sync.Mutex + visited map[string]struct{} +} + +func newWarnOnce() *warnOnce { + return &warnOnce{visited: map[string]struct{}{}} +} + +// Log emits msg (prefixed with "warning: ") on sub's stderr stream the +// first time key is seen. Subsequent calls with the same key are silent. +// sub may be nil, in which case the message is dropped entirely. +func (w *warnOnce) Log(sub progress.SubLogger, key, msg string) { + if w == nil { + return + } + w.mu.Lock() + if _, ok := w.visited[key]; ok { + w.mu.Unlock() + return + } + w.visited[key] = struct{}{} + w.mu.Unlock() + if sub == nil { + return + } + sub.Log(2, []byte("warning: "+msg+"\n")) +} diff --git a/tests/integration_test.go b/tests/integration_test.go index d2bcfec2c000..c38d4a774802 100644 --- a/tests/integration_test.go +++ b/tests/integration_test.go @@ -31,6 +31,7 @@ func TestIntegration(t *testing.T) { tests = append(tests, policyTestTests...) tests = append(tests, bakeTests...) tests = append(tests, historyTests...) + tests = append(tests, replayTests...) tests = append(tests, inspectTests...) tests = append(tests, lsTests...) tests = append(tests, imagetoolsTests...) diff --git a/tests/replay.go b/tests/replay.go new file mode 100644 index 000000000000..247b104da23a --- /dev/null +++ b/tests/replay.go @@ -0,0 +1,462 @@ +package tests + +import ( + "archive/tar" + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/containerd/platforms" + "github.com/moby/buildkit/identity" + "github.com/moby/buildkit/util/testutil/integration" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/stretchr/testify/require" +) + +// replayTests exercises the buildx replay subcommands. +// +// All tests require a registry sandbox for the build-to-replay round-trip +// because replay resolves a subject through the registry resolver. Tests +// that need a writable registry skip when `sb.RegistryAddress()` is empty. +var replayTests = []func(t *testing.T, sb integration.Sandbox){ + testReplayBuildRoundTrip, + testReplayRejectsChangedHTTPContext, + testReplaySnapshotExportAndRejectsOfflineReplay, + testReplayVerifyDigest, + testReplayRejectsLocalContext, + testReplayRejectsIncompleteProvenance, + testReplaySecretRoundTrip, + testReplayMultiPlatformRoundTrip, + testReplayDefaultPlatformUsesWorkerDefault, +} + +// replayRegistry skips workers that cannot produce or replay provenance and +// returns a registry for the round-trip. +func replayRegistry(t *testing.T, sb integration.Sandbox) string { + t.Helper() + if isMobyWorker(sb) { + t.Skip("attestations are not supported by the docker worker") + } + skipNoCompatBuildKit(t, sb, ">= 0.27.0-0", "replay requires session source policy") + registry, err := sb.NewRegistry() + if err != nil { + t.Skipf("skipping: registry not available: %v", err) + } + return registry +} + +func replayTestTag(t *testing.T) string { + return strings.Map(func(r rune) rune { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '.', r == '-': + return r + default: + return '-' + } + }, t.Name()) +} + +// replayTestDockerfile is a minimal Dockerfile that COPYs from a named +// context so the build's provenance records no local filesystem context — +// the default buildx build always records a local context which replay +// correctly refuses. +const replayTestDockerfile = `# syntax=docker/dockerfile:1 +FROM scratch +COPY --from=ctx /etc/hosts /hosts +` + +func buildReplayContext(t *testing.T, dockerfile string) (string, string) { + t.Helper() + dt := replayContextArchive(t, dockerfile) + contextPath := filepath.Join(t.TempDir(), "context.tar") + require.NoError(t, os.WriteFile(contextPath, dt, 0o600)) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/x-tar") + _, _ = w.Write(dt) + })) + t.Cleanup(server.Close) + return server.URL + "/context.tar", contextPath +} + +func replayContextArchive(t *testing.T, dockerfile string) []byte { + t.Helper() + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + require.NoError(t, tw.WriteHeader(&tar.Header{Name: "Dockerfile", Mode: 0o600, Size: int64(len(dockerfile))})) + _, err := tw.Write([]byte(dockerfile)) + require.NoError(t, err) + require.NoError(t, tw.Close()) + return buf.Bytes() +} + +// buildReplayableImage does a `buildx build` against a registry using a +// remotely served context, so the resulting image has valid SLSA v1 +// provenance without a local context. It returns the registry reference and +// both forms of the remote context needed by snapshot tests. +func buildReplayableImage(t *testing.T, sb integration.Sandbox, extra ...string) (string, string, string) { + t.Helper() + registry := replayRegistry(t, sb) + ref := registry + "/buildx-replay:" + replayTestTag(t) + contextRef, contextPath := buildReplayContext(t, replayTestDockerfile) + + args := []string{ + "--output=type=registry,name=" + ref, + "--build-context=ctx=docker-image://alpine:latest", + "--attest=type=provenance,mode=max", + contextRef, + } + args = append(args, extra...) + out, err := buildCmd(sb, withArgs(args...)) + require.NoError(t, err, out) + return ref, contextRef, contextPath +} + +func testReplayBuildRoundTrip(t *testing.T, sb integration.Sandbox) { + ref, _, _ := buildReplayableImage(t, sb) + + dest := filepath.Join(t.TempDir(), "replay-out") + cmd := buildxCmd(sb, withArgs( + "replay", "build", + "docker-image://"+ref, + "--output=type=oci,dest="+filepath.Join(dest, "replay.oci.tar"), + )) + out, err := cmd.CombinedOutput() + require.NoError(t, err, string(out)) + require.FileExists(t, filepath.Join(dest, "replay.oci.tar")) +} + +func testReplayRejectsChangedHTTPContext(t *testing.T, sb integration.Sandbox) { + registry := replayRegistry(t, sb) + ref := registry + "/buildx-replay:" + replayTestTag(t) + + var mu sync.RWMutex + contextBytes := replayContextArchive(t, replayTestDockerfile) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + mu.RLock() + dt := bytes.Clone(contextBytes) + mu.RUnlock() + w.Header().Set("Content-Type", "application/x-tar") + _, _ = w.Write(dt) + })) + t.Cleanup(server.Close) + contextRef := server.URL + "/context.tar" + + out, err := buildCmd(sb, withArgs( + "--output=type=registry,name="+ref, + "--build-context=ctx=docker-image://alpine:latest", + "--attest=type=provenance,mode=max", + contextRef, + )) + require.NoError(t, err, out) + prune := buildxCmd(sb, withArgs("prune", "--all", "--force")) + pruneOut, err := prune.CombinedOutput() + require.NoError(t, err, string(pruneOut)) + + changedContext := replayContextArchive(t, replayTestDockerfile+"# changed after provenance was recorded\n") + mu.Lock() + contextBytes = changedContext + mu.Unlock() + + cmd := buildxCmd(sb, withArgs( + "replay", "build", + "docker-image://"+ref, + "--output=type=oci,dest="+filepath.Join(t.TempDir(), "replay.oci.tar"), + )) + outBytes, err := cmd.CombinedOutput() + require.Error(t, err, string(outBytes)) + require.Contains(t, string(outBytes), "digest mismatch") +} + +func testReplaySnapshotExportAndRejectsOfflineReplay(t *testing.T, sb integration.Sandbox) { + ref, contextRef, contextPath := buildReplayableImage(t, sb) + + dest := filepath.Join(t.TempDir(), "snap") + cmd := buildxCmd(sb, withArgs( + "replay", "snapshot", + "docker-image://"+ref, + "--materials=provenance", + "--materials="+contextRef+"="+contextPath, + "--output=type=local,dest="+dest, + )) + out, err := cmd.CombinedOutput() + require.NoError(t, err, string(out)) + require.FileExists(t, filepath.Join(dest, "oci-layout")) + + // Snapshot-backed input injection is not implemented yet. Reject the + // explicit store rather than silently replaying from the network. + outDir := filepath.Join(t.TempDir(), "replay-from-snapshot") + cmd = buildxCmd(sb, withArgs( + "replay", "build", + "docker-image://"+ref, + "--materials=oci-layout://"+dest, + "--output=type=oci,dest="+filepath.Join(outDir, "replay.oci.tar"), + )) + out, err = cmd.CombinedOutput() + require.Error(t, err, string(out)) + require.Contains(t, string(out), "not implemented") +} + +func testReplayVerifyDigest(t *testing.T, sb integration.Sandbox) { + // The replay runs on the builder that produced the image, so cached + // steps reproduce the original digest. + ref, _, _ := buildReplayableImage(t, sb) + + cmd := buildxCmd(sb, withArgs( + "replay", "verify", + "docker-image://"+ref, + "--compare=digest", + "--progress=rawjson", + )) + out, err := cmd.CombinedOutput() + require.NoError(t, err, string(out)) + require.Contains(t, string(out), `"vertexes"`, "--progress must reach the verify build printer") +} + +func testReplayRejectsLocalContext(t *testing.T, sb integration.Sandbox) { + // A default `buildx build` records a local filesystem context. Replay + // must refuse this case. + registry := replayRegistry(t, sb) + ref := registry + "/buildx-replay-local:" + replayTestTag(t) + + dir := createTestProject(t) + out, err := buildCmd(sb, withArgs( + "--output=type=registry,name="+ref, + "--attest=type=provenance,mode=max", + dir, + )) + require.NoError(t, err, out) + + cmd := buildxCmd(sb, withArgs( + "replay", "build", + "docker-image://"+ref, + )) + bout, err := cmd.CombinedOutput() + require.Error(t, err, string(bout)) + require.Contains(t, string(bout), "image was built from local files") +} + +// testReplayRejectsIncompleteProvenance checks that images without provenance +// or with mode=min provenance fail in dry-run with an explanation. +func testReplayRejectsIncompleteProvenance(t *testing.T, sb integration.Sandbox) { + registry := replayRegistry(t, sb) + contextRef, _ := buildReplayContext(t, replayTestDockerfile) + + for _, tc := range []struct { + name string + attrs []string + err string + }{ + {name: "no-provenance", attrs: []string{"--provenance=false"}, err: "no SLSA provenance attestation found"}, + {name: "min-mode", attrs: []string{"--attest=type=provenance,mode=min"}, err: "provenance was recorded with mode=min"}, + } { + t.Run(tc.name, func(t *testing.T) { + ref := registry + "/buildx-replay-" + tc.name + ":" + replayTestTag(t) + args := append([]string{ + "--output=type=registry,name=" + ref, + "--build-context=ctx=docker-image://alpine:latest", + contextRef, + }, tc.attrs...) + out, err := buildCmd(sb, withArgs(args...)) + require.NoError(t, err, out) + + cmd := buildxCmd(sb, withArgs("replay", "build", "docker-image://"+ref, "--dry-run")) + bout, err := cmd.CombinedOutput() + require.Error(t, err, string(bout)) + require.Contains(t, string(bout), tc.err) + require.Contains(t, string(bout), "--provenance=mode=max") + }) + } +} + +func testReplaySecretRoundTrip(t *testing.T, sb integration.Sandbox) { + // Build with a declared secret so the provenance records a required + // secret ID. Replay without --secret must fail; with --secret passes. + registry := replayRegistry(t, sb) + ref := registry + "/buildx-replay-secret:" + replayTestTag(t) + + secretFile := filepath.Join(t.TempDir(), "secret.txt") + require.NoError(t, os.WriteFile(secretFile, []byte("hunter2"), 0o600)) + + dockerfile := `# syntax=docker/dockerfile:1 +FROM alpine:latest +RUN --mount=type=secret,id=api,required=true cp /run/secrets/api /secret +` + contextRef, _ := buildReplayContext(t, dockerfile) + + out, err := buildCmd(sb, withArgs( + "--output=type=registry,name="+ref, + "--secret=id=api,src="+secretFile, + "--attest=type=provenance,mode=max", + contextRef, + )) + require.NoError(t, err, out) + + // Without --secret: fail with missing-secret exit code. + cmd := buildxCmd(sb, withArgs( + "replay", "build", + "docker-image://"+ref, + "--output=type=oci,dest="+filepath.Join(t.TempDir(), "out.oci.tar"), + )) + bout, err := cmd.CombinedOutput() + require.Error(t, err, string(bout)) + require.Contains(t, string(bout), "missing required secrets", string(bout)) + + // With --secret: succeed. + cmd = buildxCmd(sb, withArgs( + "replay", "build", + "docker-image://"+ref, + "--secret=id=api,src="+secretFile, + "--output=type=oci,dest="+filepath.Join(t.TempDir(), "out.oci.tar"), + )) + bout, err = cmd.CombinedOutput() + require.NoError(t, err, string(bout)) +} + +func testReplayMultiPlatformRoundTrip(t *testing.T, sb integration.Sandbox) { + if !isRemoteMultiNodeWorker(sb) { + t.Skip("only testing with remote multi-node worker") + } + registry := replayRegistry(t, sb) + ref := registry + "/buildx-replay-mp:" + replayTestTag(t) + + contextRef, _ := buildReplayContext(t, replayTestDockerfile) + + out, err := buildCmd(sb, withArgs( + "--output=type=registry,name="+ref, + "--build-context=ctx=docker-image://alpine:latest", + "--attest=type=provenance,mode=max", + "--platform=linux/amd64,linux/arm64", + contextRef, + )) + require.NoError(t, err, out) + + // Each platform of the index can be selected for replay. + for _, platform := range []string{"linux/amd64", "linux/arm64"} { + cmd := buildxCmd(sb, withArgs( + "replay", "build", + "docker-image://"+ref, + "--platform="+platform, + "--dry-run", + "--format=json", + )) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + require.NoError(t, cmd.Run(), stderr.String()) + var plan struct { + Subjects []struct { + Descriptor ocispecs.Descriptor `json:"descriptor"` + } `json:"subjects"` + } + require.NoError(t, json.Unmarshal(stdout.Bytes(), &plan), "dry-run must emit JSON plan") + require.Len(t, plan.Subjects, 1) + require.NotNil(t, plan.Subjects[0].Descriptor.Platform) + require.Equal(t, platform, platforms.Format(*plan.Subjects[0].Descriptor.Platform)) + } + + // Replaying every platform at once is rejected up front, including in + // dry-run. + cmd := buildxCmd(sb, withArgs( + "replay", "build", + "docker-image://"+ref, + "--platform=all", + "--dry-run", + )) + bout, err := cmd.CombinedOutput() + require.Error(t, err, string(bout)) + require.Contains(t, string(bout), "select a single platform with --platform") +} + +// testReplayDefaultPlatformUsesWorkerDefault checks that replay without +// --platform selects the subject for the platform BuildKit builds by default, +// not the first platform configured on the builder node. +func testReplayDefaultPlatformUsesWorkerDefault(t *testing.T, sb integration.Sandbox) { + if !isRemoteWorker(sb) { + t.Skip("only testing with remote workers") + } + native := platforms.Format(platforms.Normalize(platforms.DefaultSpec())) + var other string + switch native { + case "linux/amd64": + other = "linux/arm64" + case "linux/arm64": + other = "linux/amd64" + default: + t.Skipf("unsupported host platform %s", native) + } + registry := replayRegistry(t, sb) + ref := registry + "/buildx-replay-default-platform:" + replayTestTag(t) + + contextRef, _ := buildReplayContext(t, replayTestDockerfile) + out, err := buildCmd(sb, withArgs( + "--output=type=registry,name="+ref, + "--build-context=ctx=docker-image://alpine:latest", + "--attest=type=provenance,mode=max", + "--platform=linux/amd64,linux/arm64", + contextRef, + )) + require.NoError(t, err, out) + + out, err = inspectCmd(sb) + require.NoError(t, err, out) + var endpoint string + for line := range strings.Lines(out) { + if v, ok := strings.CutPrefix(line, "Endpoint:"); ok { + endpoint = strings.TrimSpace(v) + break + } + } + require.NotEmpty(t, endpoint, out) + + // Configure the same worker with the non-native platform listed first. + // BuildKit still builds for the worker's native platform by default. + name := "replay-default-platform-" + identity.NewID() + out, err = createCmd(sb, withArgs( + "--name="+name, + "--driver=remote", + "--platform="+other+","+native, + endpoint, + )) + require.NoError(t, err, out) + t.Cleanup(func() { + out, err := rmCmd(sb, withArgs(name)) + require.NoError(t, err, out) + }) + // Only the first node decides the default platform. A secondary node + // that cannot be booted must not be touched. + out, err = createCmd(sb, withArgs( + "--append", + "--name="+name, + "--driver=remote", + "tcp://127.0.0.1:1", + )) + require.NoError(t, err, out) + + cmd := buildxCmd(sb, withArgs( + "replay", "build", + "docker-image://"+ref, + "--builder="+name, + "--dry-run", + "--format=json", + )) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + require.NoError(t, cmd.Run(), stderr.String()) + var plan struct { + Subjects []struct { + Descriptor ocispecs.Descriptor `json:"descriptor"` + } `json:"subjects"` + } + require.NoError(t, json.Unmarshal(stdout.Bytes(), &plan), "dry-run must emit JSON plan") + require.Len(t, plan.Subjects, 1) + require.NotNil(t, plan.Subjects[0].Descriptor.Platform) + require.Equal(t, native, platforms.Format(*plan.Subjects[0].Descriptor.Platform)) +} From 278c48c18cc0ec2b8fb795751fa4983e4253a1e8 Mon Sep 17 00:00:00 2001 From: Tonis Tiigi Date: Wed, 30 Sep 2026 09:38:49 -0700 Subject: [PATCH 4/4] replay: address review feedback - verify digests when reading the image index, attestation manifest and provenance, so replay uses the content covered by the signature - deny sources whose URI is not recorded in the provenance - validate every line of .intoto.jsonl inputs and require in-toto statements - reject stdin, OCI layout, local and Bake target contexts, local mode=delete outputs, --platform all, and recorded --network=host up front - keep a recorded --network=none unless --network is set - build the dry-run plan from the actual build options and validate outputs in dry-run - error on a policy config with both a callback and policy files - add Git context and multi-platform replay integration tests Signed-off-by: Tonis Tiigi --- build/opt.go | 25 +++-- build/policy_test.go | 18 ++++ commands/replay/build.go | 15 ++- commands/replay/build_test.go | 3 + commands/replay/root.go | 2 +- docs/reference/buildx_replay.md | 7 +- docs/reference/buildx_replay_build.md | 5 +- replay/build.go | 95 ++++++++++++++++--- replay/dryrun.go | 72 +++++---------- replay/dryrun_test.go | 86 +++++++++++++++++- replay/errors.go | 18 +++- replay/policy.go | 29 +----- replay/policy_test.go | 19 ++++ replay/predicate.go | 24 +---- replay/subject.go | 40 ++++++-- replay/subject_test.go | 126 ++++++++++++++++++++++++++ tests/replay.go | 102 ++++++++++++++++++++- util/buildflags/policy.go | 6 +- util/imagetools/loader.go | 32 +++++-- util/imagetools/loader_test.go | 61 +++++++++++++ 20 files changed, 638 insertions(+), 147 deletions(-) diff --git a/build/opt.go b/build/opt.go index 375fe3941aa1..8b16f944d6bf 100644 --- a/build/opt.go +++ b/build/opt.go @@ -674,15 +674,28 @@ func proxyArgKeyExists(buildArgs map[string]string, key string) bool { return false } -func configureSourcePolicy(ctx context.Context, np *noderesolver.ResolvedNode, opt *Options, cfg *confutil.Config, bopts gateway.BuildOpts, so *client.SolveOpt, pw progress.Writer) (_ []func(error), err error) { - var callbackOnly []policysession.PolicyCallback +// splitPolicyConfigs separates programmatic policy callbacks from file-based +// policy configs. A config carries either a callback or policy files. +func splitPolicyConfigs(configs []buildflags.PolicyConfig) ([]policysession.PolicyCallback, []buildflags.PolicyConfig, error) { + var callbacks []policysession.PolicyCallback var fileConfigs []buildflags.PolicyConfig - for _, p := range opt.Policy { - if p.Callback != nil && len(p.Files) == 0 { - callbackOnly = append(callbackOnly, p.Callback) + for _, p := range configs { + if p.Callback == nil { + fileConfigs = append(fileConfigs, p) continue } - fileConfigs = append(fileConfigs, p) + if len(p.Files) > 0 { + return nil, nil, errors.New("policy config cannot set both a callback and policy files") + } + callbacks = append(callbacks, p.Callback) + } + return callbacks, fileConfigs, nil +} + +func configureSourcePolicy(ctx context.Context, np *noderesolver.ResolvedNode, opt *Options, cfg *confutil.Config, bopts gateway.BuildOpts, so *client.SolveOpt, pw progress.Writer) (_ []func(error), err error) { + callbackOnly, fileConfigs, err := splitPolicyConfigs(opt.Policy) + if err != nil { + return nil, err } // Any callback-only entry requires the session policy capability, the diff --git a/build/policy_test.go b/build/policy_test.go index e1251a62b8fe..32c2f4777a0a 100644 --- a/build/policy_test.go +++ b/build/policy_test.go @@ -1,10 +1,13 @@ package build import ( + "context" "testing" "github.com/docker/buildx/policy" "github.com/docker/buildx/util/buildflags" + gwpb "github.com/moby/buildkit/frontend/gateway/pb" + "github.com/moby/buildkit/sourcepolicy/policysession" "github.com/sirupsen/logrus" "github.com/stretchr/testify/require" ) @@ -125,3 +128,18 @@ func TestWithPolicyConfigMultipleFilesAndOverrides(t *testing.T) { require.False(t, out[2].Files[0].Optional) require.True(t, out[2].Strict) } + +func TestSplitPolicyConfigs(t *testing.T) { + cb := func(context.Context, *policysession.CheckPolicyRequest) (*policysession.DecisionResponse, *gwpb.ResolveSourceMetaRequest, error) { + return nil, nil, nil + } + file := buildflags.PolicyConfig{Files: []policy.File{{Filename: "policy.rego"}}} + + callbacks, files, err := splitPolicyConfigs([]buildflags.PolicyConfig{file, {Callback: cb}}) + require.NoError(t, err) + require.Len(t, callbacks, 1) + require.Equal(t, []buildflags.PolicyConfig{file}, files) + + _, _, err = splitPolicyConfigs([]buildflags.PolicyConfig{{Files: file.Files, Callback: cb}}) + require.ErrorContains(t, err, "cannot set both a callback and policy files") +} diff --git a/commands/replay/build.go b/commands/replay/build.go index 409d9aad7367..e22790e72802 100644 --- a/commands/replay/build.go +++ b/commands/replay/build.go @@ -90,6 +90,17 @@ func runBuild(cmd *cobra.Command, dockerCli command.Cli, opts *buildOptions, inp if cmd.Flags().Changed("format") && !opts.dryRun { return errors.New("--format requires --dry-run") } + switch opts.network { + case "", "default", "none": + default: + return errors.Errorf("unsupported --network %q (want default or none)", opts.network) + } + // Replay builds one platform at a time, so "all" is not accepted. + if _, all, err := parsePlatformFilter(opts.platforms); err != nil { + return err + } else if all { + return errors.New(`--platform "all" is not supported by replay build; select a single platform`) + } // Materials resolver. resolver, err := replay.NewMaterialsResolver(opts.materials) @@ -438,7 +449,7 @@ func filterSubjectsByPlatform(subjects []*replay.Subject, platformFilter []strin } p, err := defaultPlatform() if err != nil { - return nil, errors.Wrapf(err, "select default platform from %v — pass --platform

or --platform all", available) + return nil, errors.Wrapf(err, "select default platform from %v — pass --platform

", available) } wantPlatforms = []ocispecs.Platform{p} } @@ -511,7 +522,7 @@ func filterSubjectsByPlatform(subjects []*replay.Subject, platformFilter []strin } } if len(out) == 0 { - return nil, errors.Errorf("no subject for builder default platform %v (available: %v) — pass --platform

or --platform all", wantNames, available) + return nil, errors.Errorf("no subject for builder default platform %v (available: %v) — pass --platform

", wantNames, available) } return out, nil } diff --git a/commands/replay/build_test.go b/commands/replay/build_test.go index 531731ac30d7..c3d9da2545a0 100644 --- a/commands/replay/build_test.go +++ b/commands/replay/build_test.go @@ -145,6 +145,9 @@ func TestBuildRejectsInvalidFlagsBeforeLoadingSubject(t *testing.T) { {name: "format-without-dry-run", args: []string{"--format=json"}, err: "--format requires --dry-run"}, {name: "unknown-format", args: []string{"--dry-run", "--format=yaml"}, err: `unknown --format "yaml"`}, {name: "unknown-replay-mode", args: []string{"--replay-mode=llb"}, err: `unknown --replay-mode "llb"`}, + {name: "unknown-network", args: []string{"--network=host"}, err: `unsupported --network "host" (want default or none)`}, + {name: "platform-all", args: []string{"--platform=all"}, err: `--platform "all" is not supported by replay build; select a single platform`}, + {name: "invalid-platform", args: []string{"--platform=linux/amd64,"}, err: "invalid empty --platform value"}, } { t.Run(tc.name, func(t *testing.T) { builderName := "" diff --git a/commands/replay/root.go b/commands/replay/root.go index 67df0f71d2f8..3475d89ea387 100644 --- a/commands/replay/root.go +++ b/commands/replay/root.go @@ -31,7 +31,7 @@ func installCommonFlags(cmd *cobra.Command, opts *commonOptions) { flags := cmd.Flags() flags.StringArrayVar(&opts.materials, "materials", nil, `Materials store (repeatable; format: "provenance" | "oci-layout://[:]" | "" | "=")`) - flags.StringVar(&opts.network, "network", "default", `Network mode for RUN instructions ("default" | "none")`) + flags.StringVar(&opts.network, "network", "", `Network mode for RUN instructions ("default" | "none"; defaults to the mode of the original build)`) flags.StringArrayVar(&opts.secrets, "secret", nil, `Secret to expose to the replayed build (format: "id=mysecret[,src=/local/secret]")`) flags.StringArrayVar(&opts.ssh, "ssh", nil, `SSH agent socket or keys to expose (format: "default|[=|[,]]")`) flags.StringArrayVar(&opts.platforms, "platform", nil, `Platform of the subject to replay (defaults to the only platform of the subject or the builder default platform)`) diff --git a/docs/reference/buildx_replay.md b/docs/reference/buildx_replay.md index 3f4f670b58da..f01728b69e47 100644 --- a/docs/reference/buildx_replay.md +++ b/docs/reference/buildx_replay.md @@ -43,8 +43,11 @@ A build can be replayed when: - its provenance was recorded with `mode=max` (`--provenance=mode=max` or `--attest=type=provenance,mode=max`). `mode=min` provenance omits the build arguments, secrets, and SSH needed for replay; -- its build context was a Git repository or an HTTP(S) URL. Builds from local - directories cannot be replayed; +- its build context was a Git repository or an HTTP(S) URL. Builds that used + local directories, stdin, OCI layouts, or other Bake targets as build + contexts cannot be replayed; +- it did not use `--network=host`, unless a different `--network` is passed + to replay; - the recorded sources are still available. Replay rebuilds one platform at a time. For a multi-platform image, select the diff --git a/docs/reference/buildx_replay_build.md b/docs/reference/buildx_replay_build.md index 3976c5f36d3f..0d8f624a12ef 100644 --- a/docs/reference/buildx_replay_build.md +++ b/docs/reference/buildx_replay_build.md @@ -12,7 +12,7 @@ Rebuild an image from provenance and pinned materials | `--dry-run` | `bool` | | Print a plan of the replay without solving or exporting | | `--format` | `string` | `pretty` | Format dry-run output (`pretty` \| `json`) | | `--load` | `bool` | | Shorthand for `--output=type=docker` | -| `--network` | `string` | `default` | Network mode for RUN instructions (`default` \| `none`) | +| `--network` | `string` | | Network mode for RUN instructions (`default` \| `none`; defaults to the mode of the original build) | | `-o`, `--output` | `stringArray` | | Output destination (format: `type=local,dest=path`) | | `--platform` | `stringArray` | | Platform of the subject to replay (defaults to the only platform of the subject or the builder default platform) | | `--progress` | `string` | `auto` | Set type of progress output (`auto` \| `plain` \| `tty` \| `quiet` \| `rawjson`) | @@ -38,7 +38,8 @@ The replay mode controls how sources are resolved: - `frontend` replays the recorded frontend and options, but resolves sources again, so the result can differ from the original build. -Replayed builds do not add new provenance or SBOM attestations. +Replayed builds do not add new provenance or SBOM attestations. Local outputs +with `mode=delete` are not supported. ## Examples diff --git a/replay/build.go b/replay/build.go index 14ccc09a513e..3a789930f79c 100644 --- a/replay/build.go +++ b/replay/build.go @@ -3,6 +3,9 @@ package replay import ( "context" "fmt" + "maps" + "net/url" + "slices" "sort" "strings" @@ -14,6 +17,7 @@ import ( "github.com/docker/buildx/util/dockerutil" "github.com/docker/buildx/util/progress" "github.com/docker/cli/cli/command" + "github.com/moby/buildkit/client" "github.com/moby/buildkit/identity" provenancetypes "github.com/moby/buildkit/solver/llbsolver/provenance/types" "github.com/moby/buildkit/util/progress/progressui" @@ -54,7 +58,8 @@ type BuildRequest struct { Materials *MaterialsResolver // NetworkMode controls the network mode for RUN instructions in the - // replayed build (default | none). Material resolution is NOT affected. + // replayed build (default | none). Empty uses the recorded mode. + // Material resolution is NOT affected. NetworkMode string // Secrets / SSH hold the user-supplied specs for the replayed solve. @@ -95,14 +100,33 @@ func checkBuildRequest(req *BuildRequest) error { return nil } +// createExports parses the export specs. Local exports with mode=delete are +// rejected: `buildx build` requires --allow=buildx.local.delete for most +// destinations, and replay has no --allow flag. +func createExports(specs []*buildflags.ExportEntry) ([]client.ExportEntry, error) { + exports, _, err := build.CreateExports(specs) + if err != nil { + return nil, errors.Wrap(err, "parse --output") + } + for _, e := range exports { + if e.Type != client.ExporterLocal { + continue + } + mode, err := client.ParseLocalExporterMode(e.Attrs["mode"]) + if err != nil { + return nil, err + } + if mode == client.LocalExporterModeDelete { + return nil, errors.New("replay does not support local output mode=delete") + } + } + return exports, nil +} + // checkReplayable rejects provenance that cannot be replayed faithfully and // cross-checks the user-supplied secrets and SSH against the recorded ones. func checkReplayable(pred *Predicate, mode BuildMode, secrets buildflags.Secrets, ssh []*buildflags.SSH) error { - if locals := pred.Locals(); len(locals) > 0 { - names := make([]string, 0, len(locals)) - for _, l := range locals { - names = append(names, l.Name) - } + if names := localInputs(pred); len(names) > 0 { return ErrUnreplayableLocalContext(names) } if pred.IsMinMode() { @@ -119,6 +143,37 @@ func checkReplayable(pred *Predicate, mode BuildMode, secrets buildflags.Secrets return CheckSSH(pred.SSH(), ssh) } +// localInputs returns the names of recorded inputs that only existed on the +// original client: local directories, a context read from stdin, and named +// contexts that referenced an OCI layout store or another bake target. +func localInputs(pred *Predicate) []string { + seen := map[string]struct{}{} + for _, l := range pred.Locals() { + seen[l.Name] = struct{}{} + } + attrs := pred.FrontendAttrs() + // A context read from stdin is uploaded through the client session and + // recorded as http://buildkit-session/. + for _, uri := range []string{pred.ConfigSource().URI, attrs["context"]} { + if u, err := url.Parse(uri); err == nil && u.Host == "buildkit-session" { + seen["context (stdin)"] = struct{}{} + } + } + for k, v := range attrs { + name, ok := strings.CutPrefix(k, "context:") + if !ok { + continue + } + for _, prefix := range []string{"oci-layout://", "local:", "input:", "target:"} { + if strings.HasPrefix(v, prefix) { + seen[name] = struct{}{} + break + } + } + } + return slices.Sorted(maps.Keys(seen)) +} + // checkContextPinned rejects a Git subdirectory context whose commit is not // recorded. BuildKit before v0.25 recorded no digest for the build context // and recorded the Git material without the subdirectory, so the context @@ -170,9 +225,9 @@ func Build(ctx context.Context, dockerCli command.Cli, builderName string, req * } // Parse exports once; shared across all targets. - exports, _, err := build.CreateExports(req.Exports) + exports, err := createExports(req.Exports) if err != nil { - return errors.Wrap(err, "parse --output") + return err } // Build the map[string]build.Options keyed by subject key. @@ -322,12 +377,12 @@ func BuildOptionsFromPredicate(s *Subject, pred *Predicate, req *BuildRequest) ( return build.Options{}, ErrNotImplemented("replay build with explicit --materials sources") } - networkMode, err := networkModeForReplay(req.NetworkMode) + attrs := pred.FrontendAttrs() + networkMode, err := networkModeForReplay(req.NetworkMode, attrs["force-network-mode"]) if err != nil { return build.Options{}, err } - attrs := pred.FrontendAttrs() cfgSrc := pred.ConfigSource() labels := collectPrefixed(attrs, "label:") @@ -359,7 +414,6 @@ func BuildOptionsFromPredicate(s *Subject, pred *Predicate, req *BuildRequest) ( extraHosts = fields } } - cgroupParent := attrs["cgroup-parent"] // Dockerfile path comes from configSource.path when present — that is the // canonical provenance field for the build definition. The recorded @@ -414,7 +468,6 @@ func BuildOptionsFromPredicate(s *Subject, pred *Predicate, req *BuildRequest) ( NoCache: noCache, NoCacheFilter: nocacheFilter, ExtraHosts: extraHosts, - CgroupParent: cgroupParent, NetworkMode: networkMode, SecretSpecs: req.Secrets, SSHSpecs: req.SSH, @@ -440,9 +493,23 @@ func BuildOptionsFromPredicate(s *Subject, pred *Predicate, req *BuildRequest) ( return opt, nil } -func networkModeForReplay(mode string) (string, error) { +// networkModeForReplay returns the network mode for RUN instructions. An +// explicit mode takes precedence. Otherwise the recorded mode is kept. A +// recorded host network needs an entitlement that replay cannot grant, so it +// requires an explicit mode. +func networkModeForReplay(mode, recorded string) (string, error) { switch mode { - case "", "default": + case "": + switch recorded { + case "", "default": + return "", nil + case "none": + return "none", nil + case "host": + return "", errors.New("the original build used --network=host, which replay does not support; pass --network=default or --network=none to replay with a different network mode") + } + return "", errors.Errorf("unsupported recorded network mode %q; pass --network=default or --network=none", recorded) + case "default": return "", nil case "none": return "none", nil diff --git a/replay/dryrun.go b/replay/dryrun.go index 4f5f49be1a6a..ec38b780b66c 100644 --- a/replay/dryrun.go +++ b/replay/dryrun.go @@ -7,6 +7,7 @@ import ( "sort" "github.com/containerd/containerd/v2/core/content" + "github.com/docker/buildx/build" "github.com/docker/buildx/builder" "github.com/docker/buildx/util/buildflags" "github.com/docker/buildx/util/imagetools" @@ -20,7 +21,6 @@ import ( ocispecs "github.com/opencontainers/image-spec/specs-go/v1" "github.com/package-url/packageurl-go" "github.com/pkg/errors" - "github.com/tonistiigi/go-csvvalue" ) // BuildPlan is the JSON-serializable dry-run payload for `replay build`. @@ -114,15 +114,18 @@ func MakeBuildPlan(req *BuildRequest) (*BuildPlan, error) { if err := checkBuildRequest(req); err != nil { return nil, err } + if _, err := createExports(req.Exports); err != nil { + return nil, err + } plan := &BuildPlan{Subjects: make([]SubjectBuildPlan, 0, len(req.Targets))} for _, t := range req.Targets { - if _, err := BuildOptionsFromPredicate(t.Subject, t.Predicate, req); err != nil { + opt, err := BuildOptionsFromPredicate(t.Subject, t.Predicate, req) + if err != nil { return nil, err } - - plan.Subjects = append(plan.Subjects, subjectBuildPlan(t.Subject, t.Predicate, req)) + plan.Subjects = append(plan.Subjects, subjectBuildPlan(t.Subject, t.Predicate, opt, req)) } return plan, nil } @@ -289,38 +292,28 @@ func stripImagePurlQualifiers(uri string) string { return p.ToString() } -func subjectBuildPlan(s *Subject, pred *Predicate, req *BuildRequest) SubjectBuildPlan { - attrs := pred.FrontendAttrs() - cfgSrc := pred.ConfigSource() - contextPath := cfgSrc.URI - if contextPath == "" { - contextPath = attrs["context"] - } - dockerfilePath := cfgSrc.Path - if dockerfilePath == "" { - dockerfilePath = attrs["filename"] - } - networkMode, _ := networkModeForReplay(req.NetworkMode) - +func subjectBuildPlan(s *Subject, pred *Predicate, opt build.Options, req *BuildRequest) SubjectBuildPlan { + networkMode := opt.NetworkMode + if networkMode == "" { + networkMode = "default" + } cfg := BuildPlanConfig{ - Frontend: pred.Frontend(), - FrontendAttrs: frontendAttrSummary(attrs), - Context: contextPath, - Filename: dockerfilePath, - Target: attrs["target"], - BuildArgs: collectPrefixed(attrs, "build-arg:"), - Labels: collectPrefixed(attrs, "label:"), + Frontend: opt.Frontend, + FrontendAttrs: opt.FrontendAttrs, + Context: opt.Inputs.ContextPath, + Filename: opt.Inputs.DockerfilePath, + Target: opt.Target, + BuildArgs: opt.BuildArgs, + Labels: opt.Labels, + NoCache: opt.NoCache, + NoCacheFilter: opt.NoCacheFilter, Secrets: planSecrets(pred.Secrets()), SSH: sshIDs(pred.SSH()), NetworkMode: networkMode, Exports: exportSummaries(req.Exports), } - if v, ok := attrs["no-cache"]; ok { - if v == "" { - cfg.NoCache = true - } else if fields, err := csvvalue.Fields(v, nil); err == nil { - cfg.NoCacheFilter = fields - } + if len(cfg.FrontendAttrs) == 0 { + cfg.FrontendAttrs = nil } // Materials summary. @@ -329,29 +322,12 @@ func subjectBuildPlan(s *Subject, pred *Predicate, req *BuildRequest) SubjectBui mats = append(mats, materialPlan(m, pred.BuilderPlatform())) } - plan := SubjectBuildPlan{ + return SubjectBuildPlan{ Descriptor: s.Descriptor, Signature: s.Signature(), BuildConfig: cfg, Materials: mats, } - return plan -} - -func frontendAttrSummary(attrs map[string]string) map[string]string { - if len(attrs) == 0 { - return nil - } - out := make(map[string]string, 2) - for _, key := range []string{"source", "cmdline"} { - if v, ok := attrs[key]; ok && v != "" { - out[key] = v - } - } - if len(out) == 0 { - return nil - } - return out } // materialPlan builds the shared dry-run material summary shape. diff --git a/replay/dryrun_test.go b/replay/dryrun_test.go index e86db7a8eab9..c442b64d51d7 100644 --- a/replay/dryrun_test.go +++ b/replay/dryrun_test.go @@ -3,6 +3,7 @@ package replay import ( "context" "encoding/json" + "strings" "testing" "github.com/docker/buildx/util/buildflags" @@ -66,6 +67,83 @@ func testPredicate(secretSpecs []struct { return p } +func TestMakeBuildPlanGatewayFrontend(t *testing.T) { + pred := testPredicate(nil, nil) + pred.BuildDefinition.ExternalParameters.Request.Frontend = "gateway.v0" + plan, err := MakeBuildPlan(&BuildRequest{Targets: []Target{{Subject: testSubject(t), Predicate: pred}}}) + require.NoError(t, err) + require.Equal(t, "gateway.v0", plan.Subjects[0].BuildConfig.Frontend) + require.Equal(t, map[string]string{ + "cmdline": "docker/dockerfile:1.8", + "source": "docker/dockerfile:1.8", + }, plan.Subjects[0].BuildConfig.FrontendAttrs) +} + +func TestMakeBuildPlanRecordedNetworkMode(t *testing.T) { + pred := testPredicate(nil, nil) + pred.BuildDefinition.ExternalParameters.Request.Args["force-network-mode"] = "none" + target := []Target{{Subject: testSubject(t), Predicate: pred}} + + plan, err := MakeBuildPlan(&BuildRequest{Targets: target}) + require.NoError(t, err) + require.Equal(t, "none", plan.Subjects[0].BuildConfig.NetworkMode) + + // An explicit --network takes precedence over the recorded mode. + plan, err = MakeBuildPlan(&BuildRequest{Targets: target, NetworkMode: "default"}) + require.NoError(t, err) + require.Equal(t, "default", plan.Subjects[0].BuildConfig.NetworkMode) + + // A recorded host network cannot be replayed without an explicit mode. + pred.BuildDefinition.ExternalParameters.Request.Args["force-network-mode"] = "host" + _, err = MakeBuildPlan(&BuildRequest{Targets: target}) + require.ErrorContains(t, err, "--network=host") + plan, err = MakeBuildPlan(&BuildRequest{Targets: target, NetworkMode: "none"}) + require.NoError(t, err) + require.Equal(t, "none", plan.Subjects[0].BuildConfig.NetworkMode) +} + +func TestMakeBuildPlanRejectsLocalInputs(t *testing.T) { + for _, value := range []string{"oci-layout://abc123@sha256:" + strings.Repeat("a", 64), "input:foo", "target:foo", "local:foo"} { + t.Run(strings.SplitN(value, ":", 2)[0], func(t *testing.T) { + pred := testPredicate(nil, nil) + pred.BuildDefinition.ExternalParameters.Request.Args["context:foo"] = value + _, err := MakeBuildPlan(&BuildRequest{Targets: []Target{{Subject: testSubject(t), Predicate: pred}}}) + var local *UnreplayableLocalContextError + require.ErrorAs(t, err, &local) + require.Equal(t, []string{"foo"}, local.LocalSources) + }) + } + + // A context read from stdin is uploaded through the client session. + pred := testPredicate(nil, nil) + pred.BuildDefinition.ExternalParameters.ConfigSource.URI = "http://buildkit-session/7ljrjpb29qglz425jejnuhlnx" + pred.BuildDefinition.ExternalParameters.Request.Args["context"] = "http://buildkit-session/7ljrjpb29qglz425jejnuhlnx" + _, err := MakeBuildPlan(&BuildRequest{Targets: []Target{{Subject: testSubject(t), Predicate: pred}}}) + var local *UnreplayableLocalContextError + require.ErrorAs(t, err, &local) + require.Equal(t, []string{"context (stdin)"}, local.LocalSources) + + // Remote named contexts are replayable. + pred = testPredicate(nil, nil) + pred.BuildDefinition.ExternalParameters.Request.Args["context:foo"] = "docker-image://alpine:3.20" + _, err = MakeBuildPlan(&BuildRequest{Targets: []Target{{Subject: testSubject(t), Predicate: pred}}}) + require.NoError(t, err) +} + +func TestMakeBuildPlanValidatesOutputs(t *testing.T) { + target := []Target{{Subject: testSubject(t), Predicate: testPredicate(nil, nil)}} + + _, err := MakeBuildPlan(&BuildRequest{Targets: target, Exports: []*buildflags.ExportEntry{{Type: "local", Attrs: map[string]string{}}}}) + require.ErrorContains(t, err, "dest is required") + + _, err = MakeBuildPlan(&BuildRequest{Targets: target, Exports: []*buildflags.ExportEntry{{ + Type: "local", + Destination: t.TempDir(), + Attrs: map[string]string{"mode": "delete"}, + }}}) + require.ErrorContains(t, err, "does not support local output mode=delete") +} + func TestMakeBuildPlanMinModeRejected(t *testing.T) { pred := testPredicate(nil, nil) pred.BuildDefinition.InternalParameters.BuildConfig = nil @@ -156,10 +234,10 @@ func TestMakeBuildPlanHappyPath(t *testing.T) { require.Len(t, plan.Subjects[0].Materials, 2) require.Equal(t, "https://github.com/example/repo.git", plan.Subjects[0].BuildConfig.Context) require.Equal(t, "Dockerfile", plan.Subjects[0].BuildConfig.Filename) - require.Equal(t, map[string]string{ - "cmdline": "docker/dockerfile:1.8", - "source": "docker/dockerfile:1.8", - }, plan.Subjects[0].BuildConfig.FrontendAttrs) + // dockerfile.v0 does not use the recorded gateway source. + require.Equal(t, "dockerfile.v0", plan.Subjects[0].BuildConfig.Frontend) + require.Nil(t, plan.Subjects[0].BuildConfig.FrontendAttrs) + require.Equal(t, "default", plan.Subjects[0].BuildConfig.NetworkMode) require.Equal(t, []PlanSecret{ {ID: "optional", Optional: true}, {ID: "required"}, diff --git a/replay/errors.go b/replay/errors.go index 2b4cd118688e..d59cba57a935 100644 --- a/replay/errors.go +++ b/replay/errors.go @@ -17,9 +17,9 @@ type UnreplayableLocalContextError struct { func (e *UnreplayableLocalContextError) Error() string { const hint = "only builds from a Git repository or HTTP(S) URL context can be replayed" if len(e.LocalSources) == 0 { - return "image was built from local files; " + hint + return "image was built from local inputs that replay cannot fetch; " + hint } - return fmt.Sprintf("image was built from local files (%s); %s", strings.Join(e.LocalSources, ", "), hint) + return fmt.Sprintf("image was built from local inputs that replay cannot fetch (%s); %s", strings.Join(e.LocalSources, ", "), hint) } // ErrUnreplayableLocalContext constructs an UnreplayableLocalContextError. @@ -168,10 +168,16 @@ func ErrUnsupportedSubject(kind string) error { // found for a subject. type NoProvenanceError struct { Subject string + // Manifest is set when the subject is an image manifest referenced + // directly rather than through its image index. + Manifest bool } func (e *NoProvenanceError) Error() string { - const hint = "build the image with --provenance=mode=max to make it replayable" + hint := "build the image with --provenance=mode=max to make it replayable" + if e.Manifest { + hint = "if this is a platform manifest of a multi-platform image, use the image index reference instead; otherwise " + hint + } if e.Subject == "" { return "no SLSA provenance attestation found; " + hint } @@ -183,6 +189,12 @@ func ErrNoProvenance(subject string) error { return errors.WithStack(&NoProvenanceError{Subject: subject}) } +// ErrNoProvenanceForManifest constructs a NoProvenanceError for an image +// manifest referenced directly. +func ErrNoProvenanceForManifest(subject string) error { + return errors.WithStack(&NoProvenanceError{Subject: subject, Manifest: true}) +} + // UnsupportedPredicateError signals that the attached predicate is not SLSA // provenance. type UnsupportedPredicateError struct { diff --git a/replay/policy.go b/replay/policy.go index 502a5a51e0e3..2deb75ed466c 100644 --- a/replay/policy.go +++ b/replay/policy.go @@ -29,7 +29,6 @@ import ( type PinIndex struct { byURI map[string][]sourcePin byAlias map[string][]sourcePin - byDigest map[digest.Digest]struct{} materials []string } @@ -44,9 +43,8 @@ type sourcePin struct { // a usable digest are skipped. func NewPinIndex(p *Predicate) *PinIndex { idx := &PinIndex{ - byURI: map[string][]sourcePin{}, - byAlias: map[string][]sourcePin{}, - byDigest: map[digest.Digest]struct{}{}, + byURI: map[string][]sourcePin{}, + byAlias: map[string][]sourcePin{}, } if p == nil { return idx @@ -81,7 +79,6 @@ func NewPinIndex(p *Predicate) *PinIndex { } idx.byURI[m.URI] = appendPin(idx.byURI[m.URI], pin) } - idx.byDigest[d] = struct{}{} } sort.Strings(idx.materials) return idx @@ -112,14 +109,6 @@ func preferredDigest(set map[string]string) digest.Digest { return "" } -// Len reports the number of pin entries. Used by `--dry-run`. -func (p *PinIndex) Len() int { - if p == nil { - return 0 - } - return len(p.byDigest) -} - // Lookup resolves a URI to its pinned digest. Returns ("", false) when the // URI is not covered by the index. func (p *PinIndex) Lookup(uri string) (digest.Digest, bool) { @@ -169,7 +158,7 @@ func uniquePinDigest(pins []sourcePin) (digest.Digest, bool) { func ReplayPinCallback(idx *PinIndex) policysession.PolicyCallback { return func(ctx context.Context, req *policysession.CheckPolicyRequest) (*policysession.DecisionResponse, *gwpb.ResolveSourceMetaRequest, error) { uri, observed := extractSourceIdentity(req) - if uri == "" && observed == "" { + if uri == "" { return denyResponse("replay pin: request carried no source identifier"), nil, nil } @@ -177,7 +166,7 @@ func ReplayPinCallback(idx *PinIndex) policysession.PolicyCallback { // implementation enforces the recorded pin. Metadata is not present on // BuildKit's initial policy request, so allowing a covered URI before // adding these attributes would leave HTTP and Git sources unpinned. - if idx != nil && uri != "" { + if idx != nil { if pinned, covered, reason := idx.resolve(req, uri, observed); covered { if reason != "" { return denyResponse(reason), nil, nil @@ -201,16 +190,6 @@ func ReplayPinCallback(idx *PinIndex) policysession.PolicyCallback { } } - // Digest-only match: the observed digest matches a pinned material - // whose URI was not available on the request. Accept this — the - // content is the pinned bytes regardless of how the frontend named - // them. - if idx != nil && observed != "" { - if _, ok := idx.byDigest[observed]; ok { - return allowResponse(), nil, nil - } - } - // Source is not covered by the pin index. Fail closed. return denyResponse(notCoveredMessage(idx, req, displaySource(uri, observed))), nil, nil } diff --git a/replay/policy_test.go b/replay/policy_test.go index 0a13d11e09b6..5bd9a1738cdd 100644 --- a/replay/policy_test.go +++ b/replay/policy_test.go @@ -277,6 +277,25 @@ func TestPinIndexHTTPConvertedToRecordedChecksum(t *testing.T) { require.Equal(t, spb.PolicyAction_ALLOW, resp.Action) } +func TestPinIndexUnrecordedURIWithRecordedDigestDenied(t *testing.T) { + pred := predicateWithMaterials(slsa1.ResourceDescriptor{ + URI: httpURI, + Digest: slsacommon.DigestSet{"sha256": stripSHA256(httpSHA)}, + }) + cb := ReplayPinCallback(NewPinIndex(pred)) + + resp, _, err := cb(context.Background(), httpCheckRequest("https://unrecorded.example.com/file.tar", httpSHA)) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, spb.PolicyAction_DENY, resp.Action) + + // A request without a source identifier is denied. + resp, _, err = cb(context.Background(), httpCheckRequest("", httpSHA)) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, spb.PolicyAction_DENY, resp.Action) +} + func TestPinIndexIncludesRecordedConfigSource(t *testing.T) { pred := predicateWithMaterials() pred.BuildDefinition.ExternalParameters.ConfigSource.URI = httpURI diff --git a/replay/predicate.go b/replay/predicate.go index 2dd1beace33b..777cf2d581f2 100644 --- a/replay/predicate.go +++ b/replay/predicate.go @@ -15,8 +15,8 @@ import ( // callers must have obtained a non-nil *Predicate from Subject.Predicate. type Predicate provenancetypes.ProvenancePredicateSLSA1 -// defaultFrontend matches BuildKit's default when no frontend is recorded on -// the request (see build/opt.go:309 — dockerfile.v0). +// defaultFrontend matches the frontend buildx uses when none is recorded on +// the request. const defaultFrontend = "dockerfile.v0" // Frontend returns the frontend id recorded on the predicate, falling back @@ -29,8 +29,7 @@ func (p *Predicate) Frontend() string { } // FrontendAttrs returns the recorded frontend attrs with attestation-related -// keys stripped (see Attests for those). Returns a fresh map so callers can -// mutate it. +// keys stripped. Returns a fresh map so callers can mutate it. func (p *Predicate) FrontendAttrs() map[string]string { src := p.BuildDefinition.ExternalParameters.Request.Args out := make(map[string]string, len(src)) @@ -43,23 +42,6 @@ func (p *Predicate) FrontendAttrs() map[string]string { return out } -// Attests returns the recorded attestation-related frontend attrs as the -// map shape consumed by build.Options.Attests: key is the attestation type -// (the text after "attest:"), value is the recorded attr payload. -func (p *Predicate) Attests() map[string]*string { - src := p.BuildDefinition.ExternalParameters.Request.Args - out := map[string]*string{} - for k, v := range src { - name, ok := strings.CutPrefix(k, "attest:") - if !ok { - continue - } - vv := v - out[name] = &vv - } - return out -} - // ConfigSource returns the configSource descriptor recorded on the predicate. func (p *Predicate) ConfigSource() provenancetypes.ProvenanceConfigSourceSLSA1 { return p.BuildDefinition.ExternalParameters.ConfigSource diff --git a/replay/subject.go b/replay/subject.go index 0bfb8bdd66c5..9f66fbf31db1 100644 --- a/replay/subject.go +++ b/replay/subject.go @@ -269,21 +269,32 @@ func loadAttestationFileSubject(ctx context.Context, dockerCli command.Cli, path return nil, err } - // Heuristic: .intoto.jsonl is line-delimited JSON Statements. Pick the - // first line that carries a provenance predicateType. + // .intoto.jsonl is line-delimited JSON statements. Every line is + // validated before the first provenance statement is picked, so a signed + // entry that cannot be verified is never skipped in favor of another + // entry. if strings.HasSuffix(path, ".intoto.jsonl") { + var provenance *Subject + lineNum := 0 for line := range bytes.SplitSeq(dt, []byte("\n")) { + lineNum++ line = bytes.TrimSpace(line) if len(line) == 0 { continue } s, err := subjectFromAttestationBytes(line, path) - if err == nil { - s.signature = signature - return []*Subject{s}, nil + if err != nil { + return nil, errors.Wrapf(err, "line %d", lineNum) + } + if provenance == nil && isProvenancePredicateType(s.predicateType) { + provenance = s } } - return nil, errors.Errorf("no SLSA provenance statement found in %s", path) + if provenance == nil { + return nil, errors.Errorf("no SLSA provenance statement found in %s", path) + } + provenance.signature = signature + return []*Subject{provenance}, nil } s, err := subjectFromAttestationBytes(dt, path) @@ -443,12 +454,16 @@ func subjectFromAttestationBytes(dt []byte, inputRef string) (*Subject, error) { } var stmt struct { + Type string `json:"_type"` PredicateType string `json:"predicateType"` Predicate json.RawMessage `json:"predicate"` } if err := json.Unmarshal(dt, &stmt); err != nil { return nil, errors.Errorf("%s is not an in-toto statement, DSSE envelope or Sigstore bundle", inputRef) } + if stmt.PredicateType != "" && !strings.HasPrefix(stmt.Type, "https://in-toto.io/Statement/") { + return nil, errors.Errorf("%s is not an in-toto statement, DSSE envelope or Sigstore bundle", inputRef) + } if stmt.PredicateType == "" { // A bare provenance predicate, as printed by // `imagetools inspect --format '{{json .Provenance.SLSA}}'`. @@ -573,9 +588,9 @@ func (p *imageSubjectProvider) FetchReferrers(ctx context.Context, dgst digest.D func fanOutSubjects(ctx context.Context, provider content.Provider, root ocispecs.Descriptor, inputRef string) ([]*Subject, error) { switch root.MediaType { case ocispecs.MediaTypeImageIndex, images.MediaTypeDockerSchema2ManifestList: - dt, err := content.ReadBlob(ctx, provider, root) + dt, err := imagetools.ReadBlobVerified(ctx, provider, root) if err != nil { - return nil, errors.WithStack(err) + return nil, err } var idx ocispecs.Index if err := json.Unmarshal(dt, &idx); err != nil { @@ -697,6 +712,11 @@ func (s *Subject) Predicate(ctx context.Context) (*Predicate, error) { case subjectKindImage: if s.attestManifest.Digest == "" { + if s.rootDescriptor.Digest == s.Descriptor.Digest { + // BuildKit attaches provenance through the image index, so a + // platform manifest referenced directly has none. + return nil, ErrNoProvenanceForManifest(s.inputRef) + } return nil, ErrNoProvenance(s.inputRef) } predDt, predType, err := imagetools.ReadProvenancePredicate(ctx, s.Provider, s.attestManifest) @@ -712,6 +732,10 @@ func (s *Subject) Predicate(ctx context.Context) (*Predicate, error) { return nil, ErrUnsupportedSubject("unknown") } +func isProvenancePredicateType(predType string) bool { + return predType == slsa1.PredicateSLSAProvenance || predType == slsa02.PredicateSLSAProvenance +} + // decodeProvenancePredicate unmarshals a provenance predicate in its // native form. SLSA v1 is used as-is; SLSA v0.2 is converted to v1 via // provenancetypes.ProvenancePredicateSLSA02.ConvertToSLSA1 so the rest of diff --git a/replay/subject_test.go b/replay/subject_test.go index 243a30091812..d33347aabd21 100644 --- a/replay/subject_test.go +++ b/replay/subject_test.go @@ -106,6 +106,72 @@ func TestSubjectPredicateAcceptsBarePredicate(t *testing.T) { } } +func TestLoadSubjectsJSONL(t *testing.T) { + provenance, err := json.Marshal(map[string]any{ + "_type": "https://in-toto.io/Statement/v1", + "predicateType": "https://slsa.dev/provenance/v1", + "subject": []any{}, + "predicate": map[string]any{ + "buildDefinition": map[string]any{ + "externalParameters": map[string]any{"configSource": map[string]any{"uri": "https://example.com/repo.git"}}, + }, + }, + }) + require.NoError(t, err) + sbom, err := json.Marshal(map[string]any{ + "_type": "https://in-toto.io/Statement/v1", + "predicateType": "https://spdx.dev/Document", + "subject": []any{}, + "predicate": map[string]any{}, + }) + require.NoError(t, err) + signed, err := json.Marshal(map[string]any{ + "payloadType": "application/vnd.in-toto+json", + "payload": base64.StdEncoding.EncodeToString(provenance), + "signatures": []map[string]string{{"sig": "MEUCIQDinvalid==", "keyid": "test-key"}}, + }) + require.NoError(t, err) + + write := func(t *testing.T, lines ...[]byte) string { + path := filepath.Join(t.TempDir(), "attestations.intoto.jsonl") + require.NoError(t, os.WriteFile(path, bytes.Join(lines, []byte("\n")), 0o644)) + return path + } + + t.Run("skips-other-predicates", func(t *testing.T) { + subjects, err := LoadSubjects(context.Background(), nil, "", write(t, sbom, provenance)) + require.NoError(t, err) + require.Len(t, subjects, 1) + pred, err := subjects[0].Predicate(context.Background()) + require.NoError(t, err) + require.Equal(t, "https://example.com/repo.git", pred.ConfigSource().URI) + }) + + t.Run("signed-entry-does-not-fall-back", func(t *testing.T) { + _, err := LoadSubjects(context.Background(), nil, "", write(t, signed, provenance)) + var sigErr *SignatureVerificationRequiredError + require.ErrorAs(t, err, &sigErr) + }) + + t.Run("signed-entry-after-unsigned", func(t *testing.T) { + _, err := LoadSubjects(context.Background(), nil, "", write(t, provenance, signed)) + var sigErr *SignatureVerificationRequiredError + require.ErrorAs(t, err, &sigErr) + require.ErrorContains(t, err, "line 2") + }) + + t.Run("invalid-line-after-provenance", func(t *testing.T) { + _, err := LoadSubjects(context.Background(), nil, "", write(t, provenance, []byte("not json"))) + require.ErrorContains(t, err, "line 2") + }) + + t.Run("signed-only", func(t *testing.T) { + _, err := LoadSubjects(context.Background(), nil, "", write(t, signed)) + var sigErr *SignatureVerificationRequiredError + require.ErrorAs(t, err, &sigErr) + }) +} + func TestLoadSubjectsInputErrors(t *testing.T) { dir := t.TempDir() @@ -117,6 +183,13 @@ func TestLoadSubjectsInputErrors(t *testing.T) { _, err = LoadSubjects(context.Background(), nil, "", notStatement) require.EqualError(t, err, notStatement+" is not an in-toto statement, DSSE envelope or Sigstore bundle") + // An object with a predicate but without the in-toto statement type is + // not a statement. + untyped := filepath.Join(dir, "untyped.json") + require.NoError(t, os.WriteFile(untyped, []byte(`{"predicateType":"https://slsa.dev/provenance/v1","predicate":{}}`), 0o644)) + _, err = LoadSubjects(context.Background(), nil, "", untyped) + require.EqualError(t, err, untyped+" is not an in-toto statement, DSSE envelope or Sigstore bundle") + noType := filepath.Join(dir, "object.json") require.NoError(t, os.WriteFile(noType, []byte(`{"foo": "bar"}`), 0o644)) _, err = LoadSubjects(context.Background(), nil, "", noType) @@ -458,6 +531,59 @@ func TestSubjectPredicateAcceptsUnsignedDSSE(t *testing.T) { require.True(t, subjects[0].IsAttestationFile()) } +// TestFanOutSubjectsVerifiesIndexDigest asserts that an image index whose +// stored content does not match its digest is rejected, so the attestation +// manifest is never selected from unverified content. +func TestFanOutSubjectsVerifiesIndexDigest(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + store, err := contentlocal.NewStore(dir) + require.NoError(t, err) + + mfst := putManifest(ctx, t, store, ocispecs.Manifest{MediaType: ocispecs.MediaTypeImageManifest}, &ocispecs.Platform{Architecture: "amd64", OS: "linux"}) + idx := ocispecs.Index{ + MediaType: ocispecs.MediaTypeImageIndex, + Manifests: []ocispecs.Descriptor{mfst}, + } + idx.SchemaVersion = 2 + idxDt, err := json.Marshal(idx) + require.NoError(t, err) + idxDgst, idxSize := putBlob(ctx, t, store, idxDt, ocispecs.MediaTypeImageIndex) + root := ocispecs.Descriptor{MediaType: ocispecs.MediaTypeImageIndex, Digest: idxDgst, Size: idxSize} + + subjects, err := fanOutSubjects(ctx, store, root, "test") + require.NoError(t, err) + require.Len(t, subjects, 1) + + // Same size, different content. + tampered := bytes.Replace(idxDt, []byte("amd64"), []byte("arm64"), 1) + require.Len(t, tampered, len(idxDt)) + blobPath := filepath.Join(dir, "blobs", idxDgst.Algorithm().String(), idxDgst.Encoded()) + require.NoError(t, os.Chmod(blobPath, 0o644)) + require.NoError(t, os.WriteFile(blobPath, tampered, 0o644)) + + _, err = fanOutSubjects(ctx, store, root, "test") + require.ErrorContains(t, err, "digest mismatch") +} + +// TestSubjectPredicateManifestHint asserts that a platform manifest +// referenced directly gets a hint to use the image index reference. +func TestSubjectPredicateManifestHint(t *testing.T) { + ctx := context.Background() + store, err := contentlocal.NewStore(t.TempDir()) + require.NoError(t, err) + mfst := putManifest(ctx, t, store, ocispecs.Manifest{MediaType: ocispecs.MediaTypeImageManifest}, nil) + + subjects, err := fanOutSubjects(ctx, store, mfst, "example.com/app@"+mfst.Digest.String()) + require.NoError(t, err) + require.Len(t, subjects, 1) + _, err = subjects[0].Predicate(ctx) + var noProv *NoProvenanceError + require.ErrorAs(t, err, &noProv) + require.True(t, noProv.Manifest) + require.ErrorContains(t, err, "use the image index reference") +} + // TestLoadSubjectsIndexFanout builds an OCI layout with a two-platform // image index (amd64 + arm64) and asserts LoadSubjects returns two subjects // with distinct Descriptor.Platform. diff --git a/tests/replay.go b/tests/replay.go index 247b104da23a..d5736fee3f95 100644 --- a/tests/replay.go +++ b/tests/replay.go @@ -3,17 +3,23 @@ package tests import ( "archive/tar" "bytes" + "context" "encoding/json" "net/http" "net/http/httptest" "os" + "os/exec" "path/filepath" "strings" "sync" "testing" + "github.com/containerd/continuity/fs/fstest" "github.com/containerd/platforms" + "github.com/docker/buildx/util/gitutil" + "github.com/docker/buildx/util/gitutil/gittestutil" "github.com/moby/buildkit/identity" + bkgitutil "github.com/moby/buildkit/util/gitutil" "github.com/moby/buildkit/util/testutil/integration" ocispecs "github.com/opencontainers/image-spec/specs-go/v1" "github.com/stretchr/testify/require" @@ -26,6 +32,7 @@ import ( // that need a writable registry skip when `sb.RegistryAddress()` is empty. var replayTests = []func(t *testing.T, sb integration.Sandbox){ testReplayBuildRoundTrip, + testReplayGitContextRoundTrip, testReplayRejectsChangedHTTPContext, testReplaySnapshotExportAndRejectsOfflineReplay, testReplayVerifyDigest, @@ -132,6 +139,62 @@ func testReplayBuildRoundTrip(t *testing.T, sb integration.Sandbox) { require.FileExists(t, filepath.Join(dest, "replay.oci.tar")) } +// testReplayGitContextRoundTrip replays a build from a Git context and checks +// that the replay stays pinned to the recorded commit after the branch moves. +func testReplayGitContextRoundTrip(t *testing.T, sb integration.Sandbox) { + registry := replayRegistry(t, sb) + ref := registry + "/buildx-replay-git:" + replayTestTag(t) + + dir := tmpdir(t, + fstest.CreateFile("Dockerfile", []byte("FROM scratch\nCOPY foo /foo\n"), 0o600), + fstest.CreateFile("foo", []byte("recorded"), 0o600), + ) + git, err := gitutil.New(bkgitutil.WithDir(dir)) + require.NoError(t, err) + gittestutil.GitInit(git, t) + gittestutil.GitAdd(git, t, "Dockerfile", "foo") + gittestutil.GitCommit(git, t, "initial commit") + contextRef := gittestutil.GitServeHTTP(git, t) + "#main" + + out, err := buildCmd(sb, withArgs( + "--output=type=registry,name="+ref, + "--attest=type=provenance,mode=max", + contextRef, + )) + require.NoError(t, err, out) + prune := buildxCmd(sb, withArgs("prune", "--all", "--force")) + pruneOut, err := prune.CombinedOutput() + require.NoError(t, err, string(pruneOut)) + + dest := filepath.Join(t.TempDir(), "replay.oci.tar") + cmd := buildxCmd(sb, withArgs( + "replay", "build", + "docker-image://"+ref, + "--output=type=oci,dest="+dest, + )) + bout, err := cmd.CombinedOutput() + require.NoError(t, err, string(bout)) + require.FileExists(t, dest) + + // Move the branch. The replay must not build the new commit. + require.NoError(t, os.WriteFile(filepath.Join(dir, "foo"), []byte("changed"), 0o600)) + gittestutil.GitAdd(git, t, "foo") + gittestutil.GitCommit(git, t, "move branch") + updateInfo := exec.CommandContext(context.TODO(), "git", "update-server-info") + updateInfo.Dir = dir + infoOut, err := updateInfo.CombinedOutput() + require.NoError(t, err, string(infoOut)) + + cmd = buildxCmd(sb, withArgs( + "replay", "build", + "docker-image://"+ref, + "--output=type=oci,dest="+filepath.Join(t.TempDir(), "replay.oci.tar"), + )) + bout, err = cmd.CombinedOutput() + require.Error(t, err, string(bout)) + require.Contains(t, string(bout), "expected checksum to match") +} + func testReplayRejectsChangedHTTPContext(t *testing.T, sb integration.Sandbox) { registry := replayRegistry(t, sb) ref := registry + "/buildx-replay:" + replayTestTag(t) @@ -239,7 +302,30 @@ func testReplayRejectsLocalContext(t *testing.T, sb integration.Sandbox) { )) bout, err := cmd.CombinedOutput() require.Error(t, err, string(bout)) - require.Contains(t, string(bout), "image was built from local files") + require.Contains(t, string(bout), "image was built from local inputs that replay cannot fetch") + + // A context read from stdin is uploaded through the client session and + // cannot be fetched again. Dry-run must reject it too. + stdinRef := registry + "/buildx-replay-stdin:" + replayTestTag(t) + build := buildxCmd(sb, withArgs( + "build", "--progress=quiet", + "--output=type=registry,name="+stdinRef, + "--build-context=ctx=docker-image://alpine:latest", + "--attest=type=provenance,mode=max", + "-", + )) + build.Stdin = bytes.NewReader(replayContextArchive(t, replayTestDockerfile)) + bout, err = build.CombinedOutput() + require.NoError(t, err, string(bout)) + + cmd = buildxCmd(sb, withArgs( + "replay", "build", + "docker-image://"+stdinRef, + "--dry-run", + )) + bout, err = cmd.CombinedOutput() + require.Error(t, err, string(bout)) + require.Contains(t, string(bout), "context (stdin)") } // testReplayRejectsIncompleteProvenance checks that images without provenance @@ -361,6 +447,18 @@ func testReplayMultiPlatformRoundTrip(t *testing.T, sb integration.Sandbox) { require.Equal(t, platform, platforms.Format(*plan.Subjects[0].Descriptor.Platform)) } + // A selected platform replays. + dest := filepath.Join(t.TempDir(), "replay.oci.tar") + replay := buildxCmd(sb, withArgs( + "replay", "build", + "docker-image://"+ref, + "--platform=linux/arm64", + "--output=type=oci,dest="+dest, + )) + rout, err := replay.CombinedOutput() + require.NoError(t, err, string(rout)) + require.FileExists(t, dest) + // Replaying every platform at once is rejected up front, including in // dry-run. cmd := buildxCmd(sb, withArgs( @@ -371,7 +469,7 @@ func testReplayMultiPlatformRoundTrip(t *testing.T, sb integration.Sandbox) { )) bout, err := cmd.CombinedOutput() require.Error(t, err, string(bout)) - require.Contains(t, string(bout), "select a single platform with --platform") + require.Contains(t, string(bout), `--platform "all" is not supported by replay build`) } // testReplayDefaultPlatformUsesWorkerDefault checks that replay without diff --git a/util/buildflags/policy.go b/util/buildflags/policy.go index 7197fee0a991..601d3494e6e2 100644 --- a/util/buildflags/policy.go +++ b/util/buildflags/policy.go @@ -20,9 +20,9 @@ type PolicyConfig struct { // Callback, when non-nil, is a programmatic policy evaluator. Set in // code (not from CLI flags) for cases like `buildx replay` that - // enforce pinning without loading a policy file. Configs with - // Callback set are composed into the policy callback chain alongside - // file-based policies. + // enforce pinning without loading a policy file. A config with a + // Callback must not set Files; callbacks run after the file-based + // policies of other configs. Callback policysession.PolicyCallback `json:"-"` } diff --git a/util/imagetools/loader.go b/util/imagetools/loader.go index 257aff803f70..537bdaa60349 100644 --- a/util/imagetools/loader.go +++ b/util/imagetools/loader.go @@ -467,18 +467,38 @@ func withIntotoMediaTypes(ctx context.Context) context.Context { return ctx } +// ReadBlobVerified reads a blob and checks that its content matches the size +// and digest of the descriptor. Providers backed by a registry fetcher or an +// OCI layout do not verify the content they return. +func ReadBlobVerified(ctx context.Context, provider content.Provider, desc ocispecs.Descriptor) ([]byte, error) { + if err := desc.Digest.Validate(); err != nil { + return nil, errors.WithStack(err) + } + dt, err := content.ReadBlob(ctx, provider, desc) + if err != nil { + return nil, errors.WithStack(err) + } + if int64(len(dt)) != desc.Size { + return nil, errors.Errorf("blob %s size mismatch: expected %d, got %d", desc.Digest, desc.Size, len(dt)) + } + if got := desc.Digest.Algorithm().FromBytes(dt); got != desc.Digest { + return nil, errors.Errorf("blob digest mismatch: expected %s, got %s", desc.Digest, got) + } + return dt, nil +} + // ReadProvenancePredicate loads the SLSA provenance predicate payload from the // attestation manifest referenced by attestManifest, reading blobs through the -// supplied content provider. Returns the raw predicate JSON bytes and the -// predicate type URI. When the manifest has no provenance layer both return -// values are empty without error so callers can distinguish "no provenance" -// from a hard failure. +// supplied content provider and verifying them against their digests. Returns +// the raw predicate JSON bytes and the predicate type URI. When the manifest +// has no provenance layer both return values are empty without error so +// callers can distinguish "no provenance" from a hard failure. // // This exposes the provenance scan + DSSE unwrap logic used internally by // scanProvenance for reuse by `buildx replay`. func ReadProvenancePredicate(ctx context.Context, provider content.Provider, attestManifest ocispecs.Descriptor) ([]byte, string, error) { ctx = withIntotoMediaTypes(ctx) - dt, err := content.ReadBlob(ctx, provider, attestManifest) + dt, err := ReadBlobVerified(ctx, provider, attestManifest) if err != nil { return nil, "", errors.Wrap(err, "failed to read attestation manifest") } @@ -502,7 +522,7 @@ func ReadProvenancePredicate(ctx context.Context, provider content.Provider, att if predType == "" { return nil, "", nil } - layerDt, err := content.ReadBlob(ctx, provider, layer) + layerDt, err := ReadBlobVerified(ctx, provider, layer) if err != nil { return nil, "", errors.Wrapf(err, "failed to read provenance layer %s", layer.Digest) } diff --git a/util/imagetools/loader_test.go b/util/imagetools/loader_test.go index b96e266354e6..32e36fb5ebfe 100644 --- a/util/imagetools/loader_test.go +++ b/util/imagetools/loader_test.go @@ -1,13 +1,19 @@ package imagetools import ( + "bytes" "context" "encoding/base64" + "encoding/json" "fmt" "reflect" "testing" + "github.com/containerd/containerd/v2/core/content" + "github.com/containerd/errdefs" "github.com/opencontainers/go-digest" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/pkg/errors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -192,3 +198,58 @@ func Test_decodeDSSE(t *testing.T) { _, err = decodeDSSE([]byte("{\"payload\": \"not base64\"}"), "application/vnd.in-toto.spdx+dsse") require.Error(t, err) } + +// blobMap is a content.Provider that serves whatever bytes are stored for a +// digest, like a registry fetcher or an OCI layout store would. +type blobMap map[digest.Digest][]byte + +func (m blobMap) ReaderAt(_ context.Context, desc ocispecs.Descriptor) (content.ReaderAt, error) { + dt, ok := m[desc.Digest] + if !ok { + return nil, errors.WithStack(errdefs.ErrNotFound) + } + return &bytesReaderAt{Reader: bytes.NewReader(dt)}, nil +} + +type bytesReaderAt struct { + *bytes.Reader +} + +func (r *bytesReaderAt) Close() error { return nil } + +func TestReadProvenancePredicateVerifiesDigests(t *testing.T) { + stmt := []byte(`{"_type":"https://in-toto.io/Statement/v1","predicateType":"https://slsa.dev/provenance/v1","predicate":{"buildDefinition":{"buildType":"recorded"}}}`) + layer := ocispecs.Descriptor{ + MediaType: inTotoGenericMime, + Digest: digest.FromBytes(stmt), + Size: int64(len(stmt)), + Annotations: map[string]string{"in-toto.io/predicate-type": "https://slsa.dev/provenance/v1"}, + } + mfstDt, err := json.Marshal(ocispecs.Manifest{MediaType: ocispecs.MediaTypeImageManifest, Layers: []ocispecs.Descriptor{layer}}) + require.NoError(t, err) + mfst := ocispecs.Descriptor{ + MediaType: ocispecs.MediaTypeImageManifest, + Digest: digest.FromBytes(mfstDt), + Size: int64(len(mfstDt)), + } + + provider := blobMap{mfst.Digest: mfstDt, layer.Digest: stmt} + pred, predType, err := ReadProvenancePredicate(context.Background(), provider, mfst) + require.NoError(t, err) + require.Equal(t, "https://slsa.dev/provenance/v1", predType) + require.JSONEq(t, `{"buildDefinition":{"buildType":"recorded"}}`, string(pred)) + + // A provider serving different content of the same size for the recorded + // digest must not be trusted. + tampered := bytes.Replace(stmt, []byte("recorded"), []byte("replaced"), 1) + require.Len(t, tampered, len(stmt)) + _, _, err = ReadProvenancePredicate(context.Background(), blobMap{mfst.Digest: mfstDt, layer.Digest: tampered}, mfst) + require.ErrorContains(t, err, "digest mismatch") + + tamperedMfst := bytes.Replace(mfstDt, []byte(`"layers"`), []byte(`"Layers"`), 1) + _, _, err = ReadProvenancePredicate(context.Background(), blobMap{mfst.Digest: tamperedMfst, layer.Digest: stmt}, mfst) + require.ErrorContains(t, err, "digest mismatch") + + _, err = ReadBlobVerified(context.Background(), blobMap{layer.Digest: stmt[:len(stmt)-1]}, layer) + require.ErrorContains(t, err, "size mismatch") +}