diff --git a/commands/policy/test.go b/commands/policy/test.go index 169ebf80f59e..ec956b50d1b5 100644 --- a/commands/policy/test.go +++ b/commands/policy/test.go @@ -84,7 +84,7 @@ func runTest(ctx context.Context, out io.Writer, path string, opts policy.TestOp _, _ = fmt.Fprintln(out, "decision: ") } if len(result.MissingInput) > 0 { - _, _ = fmt.Fprintf(out, "missing_input: %s\n", strings.Join(result.MissingInput, ", ")) + _, _ = fmt.Fprintf(out, "missing_input: %s\n", strings.Join(withInputPrefix(result.MissingInput), ", ")) } if len(result.MetadataNeeded) > 0 { _, _ = fmt.Fprintf(out, "metadata_resolve: %s\n", strings.Join(result.MetadataNeeded, ", ")) @@ -106,6 +106,14 @@ func writeJSON(out io.Writer, label string, v any) { _, _ = fmt.Fprintf(out, "%s:\n%s\n", label, string(dt)) } +func withInputPrefix(keys []string) []string { + out := make([]string, len(keys)) + for i, k := range keys { + out[i] = "input." + k + } + return out +} + type policyTestResolver struct { dockerCli command.Cli builderName *string diff --git a/go.mod b/go.mod index 10295ca4d6a8..933b32aaf128 100644 --- a/go.mod +++ b/go.mod @@ -44,6 +44,7 @@ require ( github.com/pkg/errors v0.9.1 github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 github.com/serialx/hashring v0.0.0-20200727003509-22c0c7ab6b1b + github.com/sigstore/sigstore-go v1.1.4-0.20251124094504-b5fe07a5a7d7 github.com/sirupsen/logrus v1.9.4 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 @@ -196,7 +197,6 @@ require ( github.com/sigstore/rekor v1.4.3 // indirect github.com/sigstore/rekor-tiles/v2 v2.0.1 // indirect github.com/sigstore/sigstore v1.10.0 // indirect - github.com/sigstore/sigstore-go v1.1.4-0.20251124094504-b5fe07a5a7d7 // indirect github.com/sigstore/timestamp-authority/v2 v2.0.2 // indirect github.com/tchap/go-patricia/v2 v2.3.3 // indirect github.com/theupdateframework/go-tuf/v2 v2.3.0 // indirect diff --git a/policy/add_unknowns_test.go b/policy/add_unknowns_test.go new file mode 100644 index 000000000000..2db947336056 --- /dev/null +++ b/policy/add_unknowns_test.go @@ -0,0 +1,118 @@ +package policy + +import ( + "testing" + + gwpb "github.com/moby/buildkit/frontend/gateway/pb" + "github.com/stretchr/testify/require" +) + +func TestAddUnknowns(t *testing.T) { + tests := []struct { + name string + unknowns []string + initial *gwpb.ResolveSourceMetaRequest + expected *gwpb.ResolveSourceMetaRequest + expErrMsg string + }{ + { + name: "empty-unknowns", + unknowns: nil, + initial: &gwpb.ResolveSourceMetaRequest{}, + expected: &gwpb.ResolveSourceMetaRequest{}, + }, + { + name: "parent-key-ignored", + unknowns: []string{"image"}, + initial: &gwpb.ResolveSourceMetaRequest{}, + expected: &gwpb.ResolveSourceMetaRequest{}, + }, + { + name: "image-config-fields-enable-image-request", + unknowns: []string{"image.labels"}, + initial: &gwpb.ResolveSourceMetaRequest{}, + expected: &gwpb.ResolveSourceMetaRequest{ + Image: &gwpb.ResolveSourceImageRequest{}, + }, + }, + { + name: "image-attestation-fields-enable-attestation-chain", + unknowns: []string{"image.signatures"}, + initial: &gwpb.ResolveSourceMetaRequest{}, + expected: &gwpb.ResolveSourceMetaRequest{ + Image: &gwpb.ResolveSourceImageRequest{ + NoConfig: true, + AttestationChain: true, + }, + }, + }, + { + name: "image-attestation-on-existing-image-request", + unknowns: []string{"image.hasProvenance"}, + initial: &gwpb.ResolveSourceMetaRequest{ + Image: &gwpb.ResolveSourceImageRequest{ + NoConfig: false, + }, + }, + expected: &gwpb.ResolveSourceMetaRequest{ + Image: &gwpb.ResolveSourceImageRequest{ + NoConfig: false, + AttestationChain: true, + }, + }, + }, + { + name: "git-ref-field-enables-git-request", + unknowns: []string{"git.ref"}, + initial: &gwpb.ResolveSourceMetaRequest{}, + expected: &gwpb.ResolveSourceMetaRequest{ + Git: &gwpb.ResolveSourceGitRequest{}, + }, + }, + { + name: "git-commit-enables-return-object", + unknowns: []string{"git.commit"}, + initial: &gwpb.ResolveSourceMetaRequest{}, + expected: &gwpb.ResolveSourceMetaRequest{ + Git: &gwpb.ResolveSourceGitRequest{ + ReturnObject: true, + }, + }, + }, + { + name: "http-checksum-no-op", + unknowns: []string{"http.checksum"}, + initial: &gwpb.ResolveSourceMetaRequest{}, + expected: &gwpb.ResolveSourceMetaRequest{}, + }, + { + name: "non-canonical-input-prefix-errors", + unknowns: []string{"input.image.labels"}, + initial: &gwpb.ResolveSourceMetaRequest{}, + expErrMsg: "unhandled unknown property input.image.labels", + }, + { + name: "unknown-field-errors", + unknowns: []string{"git.notAField"}, + initial: &gwpb.ResolveSourceMetaRequest{}, + expErrMsg: "unhandled unknown property git.notAField", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + req := tc.initial + if req == nil { + req = &gwpb.ResolveSourceMetaRequest{} + } + err := AddUnknowns(req, tc.unknowns) + if tc.expErrMsg != "" { + require.Error(t, err) + require.Equal(t, tc.expErrMsg, err.Error()) + return + } + require.NoError(t, err) + require.Equal(t, tc.expected, req) + }) + } +} diff --git a/policy/signatures.go b/policy/signatures.go index daa0178d23d0..c18b3ac65ffe 100644 --- a/policy/signatures.go +++ b/policy/signatures.go @@ -14,12 +14,17 @@ import ( gwpb "github.com/moby/buildkit/frontend/gateway/pb" 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" ) -type PolicyVerifierProvider func() (*policyverifier.Verifier, error) +type PolicyVerifier interface { + VerifyImage(context.Context, policyimage.ReferrersProvider, ocispecs.Descriptor, *ocispecs.Platform) (*policytypes.SignatureInfo, error) +} + +type PolicyVerifierProvider func() (PolicyVerifier, error) func SignatureVerifier(cfg *confutil.Config) PolicyVerifierProvider { if cfg == nil { @@ -27,9 +32,9 @@ func SignatureVerifier(cfg *confutil.Config) PolicyVerifierProvider { } var ( mu sync.Mutex - v *policyverifier.Verifier + v PolicyVerifier ) - return func() (*policyverifier.Verifier, error) { + return func() (PolicyVerifier, error) { mu.Lock() defer mu.Unlock() diff --git a/policy/tester.go b/policy/tester.go index b5f3bfaaa952..e93cc3b9517c 100644 --- a/policy/tester.go +++ b/policy/tester.go @@ -311,7 +311,7 @@ func runPolicyTest(ctx context.Context, policyModules map[string]*ast.Module, te result.Allow = allow result.DenyMessages = deny - missing := missingInputRefs(policyPackageModules, effectiveInput) + missing := missingInputRefs(policyPackageModules, effectiveInput, runtimeUnknownInputRefs(testState), runtimeUnknownInputRefs(decisionState)) result.MissingInput = uniqueSortedStrings(missing) result.MetadataNeeded = summarizeMetadataRequests(result.MissingInput) @@ -534,7 +534,7 @@ func hasEnv(env Env) bool { func filterResolvableMissing(missing []string) []string { out := make([]string, 0, len(missing)) for _, m := range missing { - if strings.HasPrefix(m, "input.image.") || strings.HasPrefix(m, "input.git.") { + if strings.HasPrefix(m, "image.") || strings.HasPrefix(m, "git.") { out = append(out, m) } } @@ -595,24 +595,27 @@ func modulesForPackage(modules map[string]*ast.Module, pkgPath string) []*ast.Mo return out } -func missingInputRefs(mods []*ast.Module, input *Input) []string { +func missingInputRefs(mods []*ast.Module, input *Input, extraRefs ...[]string) []string { if len(mods) == 0 { return nil } inputMap := normalizeInput(input) refs := collectUnknowns(mods, nil) + for _, er := range extraRefs { + refs = append(refs, er...) + } + seen := map[string]struct{}{} missing := make([]string, 0, len(refs)) - for _, ref := range refs { - key := strings.TrimPrefix(ref, "input.") - if key == ref { + for _, key := range refs { + if key == "" { continue } - key = trimKey(key) - if key == "" { + if _, ok := seen[key]; ok { continue } + seen[key] = struct{}{} if !inputHasPath(inputMap, strings.Split(key, ".")) { - missing = append(missing, "input."+key) + missing = append(missing, key) } } return missing @@ -703,11 +706,7 @@ func summarizeMetadataRequests(missing []string) []string { return nil } req := &gwpb.ResolveSourceMetaRequest{} - trimmed := make([]string, 0, len(missing)) - for _, m := range missing { - trimmed = append(trimmed, strings.TrimPrefix(m, "input.")) - } - if err := AddUnknowns(req, trimmed); err != nil { + if err := AddUnknowns(req, missing); err != nil { return nil } var out []string diff --git a/policy/utils_test.go b/policy/utils_test.go index a0e706e8e20e..6b9998930586 100644 --- a/policy/utils_test.go +++ b/policy/utils_test.go @@ -3,6 +3,7 @@ package policy import ( "testing" + "github.com/open-policy-agent/opa/v1/ast" "github.com/stretchr/testify/require" ) @@ -18,12 +19,16 @@ func TestTrimKey(t *testing.T) { // one separator → stays as-is {"git.tag", "git.tag"}, {"git[tag", "git[tag"}, + {"input.git.tag", "git.tag"}, + {"input.git[tag", "git[tag"}, // multiple separators → cut before second one {"git.tag.author", "git.tag"}, {"git.tag.author.email", "git.tag"}, {"git.tag[0][1]", "git.tag"}, {"git.tag[0]", "git.tag"}, + {"input.git.tag.author", "git.tag"}, + {"input.git.tag[0]", "git.tag"}, {"a.b.c", "a.b"}, } @@ -34,3 +39,51 @@ func TestTrimKey(t *testing.T) { }) } } + +func TestCollectUnknowns(t *testing.T) { + mod, err := ast.ParseModule("x.rego", ` + package x + p if { + input.git.tag[0].author == "a" + input.image.signatures[_].signer.certificateIssuer != "" + data.foo.bar == 1 + } + `) + require.NoError(t, err) + + all := collectUnknowns([]*ast.Module{mod}, nil) + require.ElementsMatch(t, []string{"git.tag", "image.signatures"}, all) + + filtered := collectUnknowns([]*ast.Module{mod}, []string{"input.image.signatures"}) + require.Equal(t, []string{"image.signatures"}, filtered) +} + +func TestRuntimeUnknownInputRefs(t *testing.T) { + require.Nil(t, runtimeUnknownInputRefs(nil)) + require.Nil(t, runtimeUnknownInputRefs(&state{})) + + st := &state{ + Unknowns: map[string]struct{}{ + funcVerifyGitSignature: {}, + }, + } + require.Equal(t, []string{"git.commit"}, runtimeUnknownInputRefs(st)) +} + +func TestMissingInputRefsWithRuntimeUnknowns(t *testing.T) { + mod, err := ast.ParseModule("x.rego", ` + package x + p if { + input.git.ref != "" + } + `) + require.NoError(t, err) + + in := &Input{ + Git: &Git{ + Ref: "refs/heads/main", + }, + } + missing := missingInputRefs([]*ast.Module{mod}, in, []string{"git.commit"}) + require.Equal(t, []string{"git.commit"}, missing) +} diff --git a/policy/validate.go b/policy/validate.go index a66b3a6ca75d..3cd5b89192c8 100644 --- a/policy/validate.go +++ b/policy/validate.go @@ -224,9 +224,7 @@ func (p *Policy) CheckPolicy(ctx context.Context, req *policysession.CheckPolicy return nil, nil, err } unk := collectUnknowns(pq.Support, unknowns) - if _, ok := st.Unknowns[funcVerifyGitSignature]; ok { - unk = append(unk, "input.git.commit") - } + unk = append(unk, runtimeUnknownInputRefs(st)...) if len(unk) > 0 { next := &gwpb.ResolveSourceMetaRequest{ @@ -405,7 +403,7 @@ func SourceToInputWithLogger(ctx context.Context, getVerifier PolicyVerifierProv g.Subdir = "" } } - if v, ok := src.Source.Attrs[pb.AttrFullRemoteURL]; !ok { + if v, ok := src.Source.Attrs[pb.AttrFullRemoteURL]; ok { if !gitutil.IsGitTransport(v) { v = "https://" + v } @@ -418,11 +416,11 @@ func SourceToInputWithLogger(ctx context.Context, getVerifier PolicyVerifierProv g.Host = u.Host g.FullURL = v } - if tag, ok := strings.CutPrefix(g.Ref, "refs/tags/"); ok { + if tag, ok := strings.CutPrefix(ref, "refs/tags/"); ok { g.TagName = tag isFullRef = true } - if branch, ok := strings.CutPrefix(g.Ref, "refs/heads/"); ok { + if branch, ok := strings.CutPrefix(ref, "refs/heads/"); ok { g.Branch = branch isFullRef = true } @@ -621,14 +619,12 @@ func AddUnknowns(req *gwpb.ResolveSourceMetaRequest, unk []string) error { func AddUnknownsWithLogger(logf func(logrus.Level, string), req *gwpb.ResolveSourceMetaRequest, unk []string) error { unk2 := make([]string, 0, len(unk)) for _, u := range unk { - k := strings.TrimPrefix(u, "input.") - k = trimKey(k) - switch k { + switch u { case "image", "git", "http", "local": // parents are returned as unknowns for some reason, ignore continue default: - unk2 = append(unk2, k) + unk2 = append(unk2, u) } } if len(unk2) == 0 { @@ -680,8 +676,10 @@ func collectUnknowns(mods []*ast.Module, allowed []string) []string { for _, mod := range mods { ast.WalkRefs(mod, func(ref ast.Ref) bool { if ref.HasPrefix(ast.InputRootRef) { - s := ref.String() // e.g. "input.request.path" - s = "input." + trimKey(strings.TrimPrefix(s, "input.")) + s := trimKey(ref.String()) + if s == "" { + return true + } if _, ok := seen[s]; !ok { seen[s] = struct{}{} out = append(out, s) @@ -696,6 +694,10 @@ func collectUnknowns(mods []*ast.Module, allowed []string) []string { valid := map[string]struct{}{} for _, k := range allowed { + k = trimKey(k) + if k == "" { + continue + } valid[k] = struct{}{} } @@ -709,14 +711,25 @@ func collectUnknowns(mods []*ast.Module, allowed []string) []string { return filtered } +func runtimeUnknownInputRefs(st *state) []string { + if st == nil || len(st.Unknowns) == 0 { + return nil + } + var out []string + if _, ok := st.Unknowns[funcVerifyGitSignature]; ok { + out = append(out, "git.commit") + } + return out +} + func summarizeUnknownsForLog(unk []string) []string { out := make([]string, 0, len(unk)) seen := map[string]struct{}{} for _, u := range unk { - if strings.HasPrefix(u, "input.image.signatures") { - u = "input.image.signatures" + if strings.HasPrefix(u, "image.signatures") { + u = "image.signatures" } - if u == "input.image" { + if u == "image" { continue } if _, ok := seen[u]; ok { @@ -730,7 +743,7 @@ func summarizeUnknownsForLog(unk []string) []string { func hasHTTPUnknowns(unk []string) bool { for _, u := range unk { - if strings.HasPrefix(u, "input.http.") { + if strings.HasPrefix(u, "http.") { return true } } @@ -738,6 +751,8 @@ func hasHTTPUnknowns(unk []string) bool { } func trimKey(s string) string { + s = strings.TrimPrefix(s, "input.") + const ( dot = '.' sb = '[' diff --git a/policy/validate_test.go b/policy/validate_test.go new file mode 100644 index 000000000000..6930e2fe98b5 --- /dev/null +++ b/policy/validate_test.go @@ -0,0 +1,863 @@ +package policy + +import ( + "context" + "crypto/sha1" //nolint:gosec // used for git object checksums in tests + "encoding/hex" + "encoding/json" + "fmt" + "testing" + "time" + + gwpb "github.com/moby/buildkit/frontend/gateway/pb" + "github.com/moby/buildkit/solver/pb" + 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/sigstore/sigstore-go/pkg/fulcio/certificate" + "github.com/stretchr/testify/require" +) + +func TestSourceToInputWithLogger(t *testing.T) { + tm := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC) + + tests := []struct { + name string + src *gwpb.ResolveSourceMetaResponse + platform *ocispecs.Platform + verifier PolicyVerifierProvider + expInput Input + expUnk []string + expErrMsg string + assert func(*testing.T, Input, []string, error) + }{ + { + name: "nil-source-metadata", + src: nil, + expErrMsg: "no source info in request", + }, + { + name: "invalid-source-identifier", + src: &gwpb.ResolveSourceMetaResponse{ + Source: &pb.SourceOp{Identifier: "not-a-source"}, + }, + expErrMsg: "invalid source identifier: not-a-source", + }, + { + name: "http-source-with-checksum-and-auth", + src: &gwpb.ResolveSourceMetaResponse{ + Source: &pb.SourceOp{ + Identifier: "https://example.com/foo.tar.gz?download=1", + Attrs: map[string]string{ + pb.AttrHTTPAuthHeaderSecret: "my-secret", + }, + }, + HTTP: &gwpb.ResolveSourceHTTPResponse{ + Checksum: "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + }, + }, + expInput: Input{ + HTTP: &HTTP{ + URL: "https://example.com/foo.tar.gz?download=1", + Schema: "https", + Host: "example.com", + Path: "/foo.tar.gz", + Query: map[string][]string{"download": {"1"}}, + HasAuth: true, + Checksum: "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + }, + }, + }, + { + name: "http-source-without-checksum", + src: &gwpb.ResolveSourceMetaResponse{ + Source: &pb.SourceOp{ + Identifier: "http://example.com/archive.tgz", + }, + }, + expInput: Input{ + HTTP: &HTTP{ + URL: "http://example.com/archive.tgz", + Schema: "http", + Host: "example.com", + Path: "/archive.tgz", + Query: map[string][]string{}, + }, + }, + expUnk: []string{"input.http.checksum"}, + }, + { + name: "http-with-query-and-fragment-parses-fields-correctly", + src: &gwpb.ResolveSourceMetaResponse{ + Source: &pb.SourceOp{ + Identifier: "https://example.com/a/b.tar.gz?x=1&x=2#frag", + }, + HTTP: &gwpb.ResolveSourceHTTPResponse{ + Checksum: "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + }, + }, + expInput: Input{ + HTTP: &HTTP{ + URL: "https://example.com/a/b.tar.gz?x=1&x=2#frag", + Schema: "https", + Host: "example.com", + Path: "/a/b.tar.gz", + Query: map[string][]string{"x": {"1", "2"}}, + Checksum: "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + }, + }, + }, + { + name: "http-with-nil-attrs-does-not-set-auth", + src: &gwpb.ResolveSourceMetaResponse{ + Source: &pb.SourceOp{ + Identifier: "https://example.com/secure.tgz", + }, + HTTP: &gwpb.ResolveSourceHTTPResponse{ + Checksum: "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + }, + }, + expInput: Input{ + HTTP: &HTTP{ + URL: "https://example.com/secure.tgz", + Schema: "https", + Host: "example.com", + Path: "/secure.tgz", + Query: map[string][]string{}, + Checksum: "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + }, + }, + }, + { + name: "local-source", + src: &gwpb.ResolveSourceMetaResponse{ + Source: &pb.SourceOp{ + Identifier: "local://context", + }, + }, + expInput: Input{ + Local: &Local{Name: "context"}, + }, + }, + { + name: "image-source-without-platform", + src: &gwpb.ResolveSourceMetaResponse{ + Source: &pb.SourceOp{ + Identifier: "docker-image://alpine:latest", + }, + }, + expErrMsg: "platform required for image source", + }, + { + name: "image-source-without-resolved-metadata", + src: &gwpb.ResolveSourceMetaResponse{ + Source: &pb.SourceOp{ + Identifier: "docker-image://alpine:latest", + }, + }, + platform: &ocispecs.Platform{OS: "linux", Architecture: "amd64"}, + expInput: Input{ + Image: &Image{ + Ref: "docker.io/library/alpine:latest", + Host: "docker.io", + Repo: "alpine", + FullRepo: "docker.io/library/alpine", + Tag: "latest", + Platform: "linux/amd64", + OS: "linux", + Architecture: "amd64", + }, + }, + expUnk: []string{ + "input.image.checksum", + "input.image.labels", + "input.image.user", + "input.image.volumes", + "input.image.workingDir", + "input.image.env", + "input.image.hasProvenance", + "input.image.signatures", + }, + }, + { + name: "docker-image-canonical-ref-does-not-request-checksum-unknown", + src: &gwpb.ResolveSourceMetaResponse{ + Source: &pb.SourceOp{ + Identifier: "docker-image://alpine@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + }, + }, + platform: &ocispecs.Platform{OS: "linux", Architecture: "amd64"}, + expInput: Input{ + Image: &Image{ + Ref: "docker.io/library/alpine@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + Host: "docker.io", + Repo: "alpine", + FullRepo: "docker.io/library/alpine", + IsCanonical: true, + Checksum: "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + Platform: "linux/amd64", + OS: "linux", + Architecture: "amd64", + }, + }, + expUnk: []string{ + "input.image.labels", + "input.image.user", + "input.image.volumes", + "input.image.workingDir", + "input.image.env", + "input.image.hasProvenance", + "input.image.signatures", + }, + }, + { + name: "docker-image-invalid-config-bytes-returns-error", + src: &gwpb.ResolveSourceMetaResponse{ + Source: &pb.SourceOp{ + Identifier: "docker-image://alpine:latest", + }, + Image: &gwpb.ResolveSourceImageResponse{ + Digest: "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + Config: []byte("{"), + }, + }, + platform: &ocispecs.Platform{OS: "linux", Architecture: "amd64"}, + expErrMsg: "failed to unmarshal image config", + }, + { + name: "image-attestation-chain-sets-has-provenance-without-verifier", + src: &gwpb.ResolveSourceMetaResponse{ + Source: &pb.SourceOp{ + Identifier: "docker-image://alpine:latest", + }, + Image: &gwpb.ResolveSourceImageResponse{ + Digest: "sha256:abababababababababababababababababababababababababababababababab", + AttestationChain: &gwpb.AttestationChain{ + AttestationManifest: "sha256:bcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbc", + }, + }, + }, + platform: &ocispecs.Platform{OS: "linux", Architecture: "amd64"}, + expInput: Input{ + Image: &Image{ + Ref: "docker.io/library/alpine:latest", + Host: "docker.io", + Repo: "alpine", + FullRepo: "docker.io/library/alpine", + Tag: "latest", + Platform: "linux/amd64", + OS: "linux", + Architecture: "amd64", + Checksum: "sha256:abababababababababababababababababababababababababababababababab", + HasProvenance: true, + }, + }, + expUnk: []string{ + "input.image.labels", + "input.image.user", + "input.image.volumes", + "input.image.workingDir", + "input.image.env", + }, + }, + { + name: "image-attestation-chain-with-mock-verifier-sets-signature-properties", + src: &gwpb.ResolveSourceMetaResponse{ + Source: &pb.SourceOp{ + Identifier: "docker-image://alpine:latest", + }, + Image: &gwpb.ResolveSourceImageResponse{ + Digest: "sha256:cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd", + AttestationChain: newTestAttestationChain(t), + }, + }, + platform: &ocispecs.Platform{OS: "linux", Architecture: "amd64"}, + verifier: func() (PolicyVerifier, error) { + return &mockPolicyVerifier{ + verifyImage: func(context.Context, policyimage.ReferrersProvider, ocispecs.Descriptor, *ocispecs.Platform) (*policytypes.SignatureInfo, error) { + ts := time.Date(2024, 2, 3, 4, 5, 6, 0, time.UTC) + return &policytypes.SignatureInfo{ + Kind: policytypes.KindDockerGithubBuilder, + SignatureType: policytypes.SignatureSimpleSigningV1, + DockerReference: "docker.io/library/alpine:latest", + IsDHI: true, + Timestamps: []policytypes.TimestampVerificationResult{ + {Type: "rekor", URI: "https://rekor.sigstore.dev", Timestamp: ts}, + }, + Signer: &certificate.Summary{ + CertificateIssuer: "https://token.actions.githubusercontent.com", + SubjectAlternativeName: "https://github.com/docker/buildx/.github/workflows/ci.yml@refs/heads/main", + Extensions: certificate.Extensions{ + BuildSignerURI: "https://github.com/docker/buildx/.github/workflows/ci.yml", + BuildSignerDigest: "sha256:1234", + RunnerEnvironment: "github-hosted", + SourceRepositoryURI: "https://github.com/docker/buildx", + SourceRepositoryDigest: "abcdef", + SourceRepositoryRef: "refs/heads/main", + SourceRepositoryOwnerURI: "https://github.com/docker", + BuildConfigURI: "https://github.com/docker/buildx/.github/workflows/ci.yml", + BuildConfigDigest: "sha256:5678", + RunInvocationURI: "https://github.com/docker/buildx/actions/runs/1", + SourceRepositoryIdentifier: "docker/buildx", + }, + }, + }, nil + }, + }, nil + }, + assert: func(t *testing.T, inp Input, unknowns []string, err error) { + t.Helper() + require.NoError(t, err) + require.Equal(t, []string{ + "input.image.labels", + "input.image.user", + "input.image.volumes", + "input.image.workingDir", + "input.image.env", + }, unknowns) + require.NotNil(t, inp.Image) + require.True(t, inp.Image.HasProvenance) + require.Len(t, inp.Image.Signatures, 1) + sig := inp.Image.Signatures[0] + require.Equal(t, SignatureKindDockerGithubBuilder, sig.SignatureKind) + require.Equal(t, SignatureTypeSimpleSigningV1, sig.SignatureType) + require.Equal(t, "docker.io/library/alpine:latest", sig.DockerReference) + require.True(t, sig.IsDHI) + require.Len(t, sig.Timestamps, 1) + require.Equal(t, "rekor", sig.Timestamps[0].Type) + require.Equal(t, "https://rekor.sigstore.dev", sig.Timestamps[0].URI) + require.NotNil(t, sig.Signer) + require.Equal(t, "https://token.actions.githubusercontent.com", sig.Signer.CertificateIssuer) + require.Equal(t, "https://github.com/docker/buildx/.github/workflows/ci.yml@refs/heads/main", sig.Signer.SubjectAlternativeName) + require.Equal(t, "https://github.com/docker/buildx/.github/workflows/ci.yml", sig.Signer.BuildSignerURI) + require.Equal(t, "sha256:1234", sig.Signer.BuildSignerDigest) + require.Equal(t, "github-hosted", sig.Signer.RunnerEnvironment) + require.Equal(t, "https://github.com/docker/buildx", sig.Signer.SourceRepositoryURI) + require.Equal(t, "abcdef", sig.Signer.SourceRepositoryDigest) + require.Equal(t, "refs/heads/main", sig.Signer.SourceRepositoryRef) + require.Equal(t, "https://github.com/docker", sig.Signer.SourceRepositoryOwnerURI) + require.Equal(t, "https://github.com/docker/buildx/.github/workflows/ci.yml", sig.Signer.BuildConfigURI) + require.Equal(t, "sha256:5678", sig.Signer.BuildConfigDigest) + require.Equal(t, "https://github.com/docker/buildx/actions/runs/1", sig.Signer.RunInvocationURI) + require.Equal(t, "docker/buildx", sig.Signer.SourceRepositoryIdentifier) + }, + }, + { + name: "image-attestation-chain-without-manifest-keeps-has-provenance-false", + src: &gwpb.ResolveSourceMetaResponse{ + Source: &pb.SourceOp{ + Identifier: "docker-image://alpine:latest", + }, + Image: &gwpb.ResolveSourceImageResponse{ + Digest: "sha256:babababababababababababababababababababababababababababababababa", + AttestationChain: &gwpb.AttestationChain{ + AttestationManifest: "", + }, + }, + }, + platform: &ocispecs.Platform{OS: "linux", Architecture: "amd64"}, + expInput: Input{ + Image: &Image{ + Ref: "docker.io/library/alpine:latest", + Host: "docker.io", + Repo: "alpine", + FullRepo: "docker.io/library/alpine", + Tag: "latest", + Platform: "linux/amd64", + OS: "linux", + Architecture: "amd64", + Checksum: "sha256:babababababababababababababababababababababababababababababababa", + }, + }, + expUnk: []string{ + "input.image.labels", + "input.image.user", + "input.image.volumes", + "input.image.workingDir", + "input.image.env", + }, + assert: func(t *testing.T, inp Input, unknowns []string, err error) { + t.Helper() + require.NoError(t, err) + require.NotNil(t, inp.Image) + require.False(t, inp.Image.HasProvenance) + require.NotContains(t, unknowns, "input.image.hasProvenance") + require.NotContains(t, unknowns, "input.image.signatures") + }, + }, + { + name: "image-source-with-config-and-no-attestation-chain", + src: &gwpb.ResolveSourceMetaResponse{ + Source: &pb.SourceOp{ + Identifier: "docker-image://alpine:latest", + }, + Image: &gwpb.ResolveSourceImageResponse{ + Digest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + Config: mustMarshalImageConfig(t, ocispecs.Image{ + Created: &tm, + Config: ocispecs.ImageConfig{ + Labels: map[string]string{"a": "b"}, + Env: []string{"A=B"}, + User: "root", + Volumes: map[string]struct{}{ + "/data": {}, + }, + WorkingDir: "/work", + }, + }), + }, + }, + platform: &ocispecs.Platform{OS: "linux", Architecture: "amd64"}, + expInput: Input{ + Image: &Image{ + Ref: "docker.io/library/alpine:latest", + Host: "docker.io", + Repo: "alpine", + FullRepo: "docker.io/library/alpine", + Tag: "latest", + Platform: "linux/amd64", + OS: "linux", + Architecture: "amd64", + Checksum: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + CreatedTime: "2024-01-02T03:04:05Z", + Labels: map[string]string{"a": "b"}, + Env: []string{"A=B"}, + User: "root", + Volumes: []string{"/data"}, + WorkingDir: "/work", + }, + }, + expUnk: []string{"input.image.hasProvenance", "input.image.signatures"}, + }, + { + name: "git-source-missing-full-remote-url-attr", + src: &gwpb.ResolveSourceMetaResponse{ + Source: &pb.SourceOp{ + Identifier: "git://github.com/docker/buildx.git", + }, + }, + expInput: Input{ + Git: &Git{ + Schema: "https", + Host: "github.com", + Remote: "https://github.com/docker/buildx.git", + }, + }, + expUnk: []string{ + "input.git.tagName", + "input.git.branch", + "input.git.ref", + "input.git.checksum", + "input.git.isAnnotatedTag", + "input.git.commitChecksum", + "input.git.isSHA256", + "input.git.tag", + "input.git.commit", + }, + }, + { + name: "git-source-with-full-remote-url-attr-uses-attr", + src: &gwpb.ResolveSourceMetaResponse{ + Source: &pb.SourceOp{ + Identifier: "git://github.com/docker/buildx.git", + Attrs: map[string]string{ + pb.AttrFullRemoteURL: "https://github.com/docker/buildx.git", + }, + }, + }, + expInput: Input{ + Git: &Git{ + Schema: "https", + Host: "github.com", + Remote: "https://github.com/docker/buildx.git", + FullURL: "https://github.com/docker/buildx.git", + }, + }, + expUnk: []string{ + "input.git.tagName", + "input.git.branch", + "input.git.ref", + "input.git.checksum", + "input.git.isAnnotatedTag", + "input.git.commitChecksum", + "input.git.isSHA256", + "input.git.tag", + "input.git.commit", + }, + }, + { + name: "git-source-with-full-remote-url-attr-ssh-uses-attr", + src: &gwpb.ResolveSourceMetaResponse{ + Source: &pb.SourceOp{ + Identifier: "git://github.com/docker/buildx.git", + Attrs: map[string]string{ + pb.AttrFullRemoteURL: "ssh://git@github.com/docker/buildx.git", + }, + }, + }, + expInput: Input{ + Git: &Git{ + Schema: "ssh", + Host: "github.com", + Remote: "ssh://git@github.com/docker/buildx.git", + FullURL: "ssh://git@github.com/docker/buildx.git", + }, + }, + expUnk: []string{ + "input.git.tagName", + "input.git.branch", + "input.git.ref", + "input.git.checksum", + "input.git.isAnnotatedTag", + "input.git.commitChecksum", + "input.git.isSHA256", + "input.git.tag", + "input.git.commit", + }, + }, + { + name: "git-source-with-full-remote-url-attr-ssh2-uses-attr", + src: &gwpb.ResolveSourceMetaResponse{ + Source: &pb.SourceOp{ + Identifier: "git://github.com/docker/buildx.git", + Attrs: map[string]string{ + pb.AttrFullRemoteURL: "git@github.com:docker/buildx.git", + }, + }, + }, + expInput: Input{ + Git: &Git{ + Schema: "ssh", + Host: "github.com", + Remote: "git@github.com:docker/buildx.git", + FullURL: "git@github.com:docker/buildx.git", + }, + }, + expUnk: []string{ + "input.git.tagName", + "input.git.branch", + "input.git.ref", + "input.git.checksum", + "input.git.isAnnotatedTag", + "input.git.commitChecksum", + "input.git.isSHA256", + "input.git.tag", + "input.git.commit", + }, + }, + { + name: "git-source-with-full-remote-url-attr-ssh-meta-with-objects-sets-commit-and-tag", + src: func() *gwpb.ResolveSourceMetaResponse { + commitRaw := []byte("" + + "tree 0123456789abcdef0123456789abcdef01234567\n" + + "author Alice 1700000000 +0000\n" + + "committer Bob 1700003600 +0000\n" + + "\n" + + "hello from commit\n") + commitSHA := gitObjectSHA1("commit", commitRaw) + tagRaw := []byte("" + + "object " + commitSHA + "\n" + + "type commit\n" + + "tag v1.2.3\n" + + "tagger Carol 1700007200 +0000\n" + + "\n" + + "release v1.2.3\n") + tagSHA := gitObjectSHA1("tag", tagRaw) + return &gwpb.ResolveSourceMetaResponse{ + Source: &pb.SourceOp{ + Identifier: "git://github.com/docker/buildx.git", + Attrs: map[string]string{ + pb.AttrFullRemoteURL: "ssh://git@github.com/docker/buildx.git", + }, + }, + Git: &gwpb.ResolveSourceGitResponse{ + Ref: "refs/tags/v1.2.3", + Checksum: tagSHA, + CommitChecksum: commitSHA, + CommitObject: commitRaw, + TagObject: tagRaw, + }, + } + }(), + assert: func(t *testing.T, inp Input, unknowns []string, err error) { + t.Helper() + require.NoError(t, err) + require.Empty(t, unknowns) + require.NotNil(t, inp.Git) + require.Equal(t, "ssh", inp.Git.Schema) + require.Equal(t, "github.com", inp.Git.Host) + require.Equal(t, "ssh://git@github.com/docker/buildx.git", inp.Git.Remote) + require.Equal(t, "ssh://git@github.com/docker/buildx.git", inp.Git.FullURL) + require.Equal(t, "refs/tags/v1.2.3", inp.Git.Ref) + require.Equal(t, "v1.2.3", inp.Git.TagName) + require.True(t, inp.Git.IsAnnotatedTag) + require.NotEmpty(t, inp.Git.Checksum) + require.NotEmpty(t, inp.Git.CommitChecksum) + require.NotNil(t, inp.Git.Commit) + require.Equal(t, "0123456789abcdef0123456789abcdef01234567", inp.Git.Commit.Tree) + require.Equal(t, "hello from commit", inp.Git.Commit.Message) + require.Equal(t, "Alice", inp.Git.Commit.Author.Name) + require.Equal(t, "alice@example.com", inp.Git.Commit.Author.Email) + require.Equal(t, "Bob", inp.Git.Commit.Committer.Name) + require.Equal(t, "bob@example.com", inp.Git.Commit.Committer.Email) + require.NotNil(t, inp.Git.Tag) + require.Equal(t, inp.Git.CommitChecksum, inp.Git.Tag.Object) + require.Equal(t, "commit", inp.Git.Tag.Type) + require.Equal(t, "v1.2.3", inp.Git.Tag.Tag) + require.Equal(t, "release v1.2.3", inp.Git.Tag.Message) + require.Equal(t, "Carol", inp.Git.Tag.Tagger.Name) + require.Equal(t, "carol@example.com", inp.Git.Tag.Tagger.Email) + }, + }, + { + name: "git-meta-ref-heads-main-sets-branch", + src: &gwpb.ResolveSourceMetaResponse{ + Source: &pb.SourceOp{ + Identifier: "git://github.com/docker/buildx.git", + }, + Git: &gwpb.ResolveSourceGitResponse{ + Ref: "refs/heads/main", + Checksum: "1111111111111111111111111111111111111111", + }, + }, + expInput: Input{ + Git: &Git{ + Schema: "https", + Host: "github.com", + Remote: "https://github.com/docker/buildx.git", + Ref: "refs/heads/main", + Branch: "main", + Checksum: "1111111111111111111111111111111111111111", + CommitChecksum: "1111111111111111111111111111111111111111", + }, + }, + expUnk: []string{"input.git.commit", "input.git.tag"}, + }, + { + name: "git-meta-ref-tags-v1-sets-tag-name", + src: &gwpb.ResolveSourceMetaResponse{ + Source: &pb.SourceOp{ + Identifier: "git://github.com/docker/buildx.git", + }, + Git: &gwpb.ResolveSourceGitResponse{ + Ref: "refs/tags/v1.2.3", + Checksum: "2222222222222222222222222222222222222222", + }, + }, + expInput: Input{ + Git: &Git{ + Schema: "https", + Host: "github.com", + Remote: "https://github.com/docker/buildx.git", + Ref: "refs/tags/v1.2.3", + TagName: "v1.2.3", + Checksum: "2222222222222222222222222222222222222222", + CommitChecksum: "2222222222222222222222222222222222222222", + }, + }, + expUnk: []string{"input.git.commit", "input.git.tag"}, + }, + { + name: "git-meta-empty-commit-checksum-falls-back-to-checksum", + src: &gwpb.ResolveSourceMetaResponse{ + Source: &pb.SourceOp{ + Identifier: "git://github.com/docker/buildx.git", + }, + Git: &gwpb.ResolveSourceGitResponse{ + Checksum: "3333333333333333333333333333333333333333", + }, + }, + expInput: Input{ + Git: &Git{ + Schema: "https", + Host: "github.com", + Remote: "https://github.com/docker/buildx.git", + Checksum: "3333333333333333333333333333333333333333", + CommitChecksum: "3333333333333333333333333333333333333333", + }, + }, + expUnk: []string{"input.git.commit", "input.git.tag"}, + }, + { + name: "git-meta-sha256-checksum-sets-is-sha256", + src: &gwpb.ResolveSourceMetaResponse{ + Source: &pb.SourceOp{ + Identifier: "git://github.com/docker/buildx.git", + }, + Git: &gwpb.ResolveSourceGitResponse{ + Checksum: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }, + }, + expInput: Input{ + Git: &Git{ + Schema: "https", + Host: "github.com", + Remote: "https://github.com/docker/buildx.git", + Checksum: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + CommitChecksum: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + IsSHA256: true, + }, + }, + expUnk: []string{"input.git.commit", "input.git.tag"}, + }, + { + name: "git-meta-checksum-ne-commit-checksum-sets-annotated-tag", + src: &gwpb.ResolveSourceMetaResponse{ + Source: &pb.SourceOp{ + Identifier: "git://github.com/docker/buildx.git", + }, + Git: &gwpb.ResolveSourceGitResponse{ + Checksum: "4444444444444444444444444444444444444444", + CommitChecksum: "5555555555555555555555555555555555555555", + }, + }, + expInput: Input{ + Git: &Git{ + Schema: "https", + Host: "github.com", + Remote: "https://github.com/docker/buildx.git", + Checksum: "4444444444444444444444444444444444444444", + CommitChecksum: "5555555555555555555555555555555555555555", + IsAnnotatedTag: true, + }, + }, + expUnk: []string{"input.git.commit", "input.git.tag"}, + }, + { + name: "git-meta-missing-commit-object-adds-commit-and-tag-unknowns", + src: &gwpb.ResolveSourceMetaResponse{ + Source: &pb.SourceOp{ + Identifier: "git://github.com/docker/buildx.git", + }, + Git: &gwpb.ResolveSourceGitResponse{ + Ref: "refs/heads/main", + Checksum: "6666666666666666666666666666666666666666", + CommitChecksum: "6666666666666666666666666666666666666666", + }, + }, + expInput: Input{ + Git: &Git{ + Schema: "https", + Host: "github.com", + Remote: "https://github.com/docker/buildx.git", + Ref: "refs/heads/main", + Branch: "main", + Checksum: "6666666666666666666666666666666666666666", + CommitChecksum: "6666666666666666666666666666666666666666", + }, + }, + expUnk: []string{"input.git.commit", "input.git.tag"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + inp, unknowns, err := SourceToInputWithLogger(t.Context(), tc.verifier, tc.src, tc.platform, nil) + if tc.assert != nil { + tc.assert(t, inp, unknowns, err) + return + } + if tc.expErrMsg != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.expErrMsg) + return + } + require.NoError(t, err) + require.Equal(t, tc.expInput, inp) + require.Equal(t, tc.expUnk, unknowns) + }) + } +} + +func mustMarshalImageConfig(t *testing.T, img ocispecs.Image) []byte { + t.Helper() + dt, err := json.Marshal(img) + require.NoError(t, err) + return dt +} + +func gitObjectSHA1(objType string, raw []byte) string { + prefix := fmt.Appendf(nil, "%s %d\x00", objType, len(raw)) + //nolint:gosec // Git object IDs are defined using SHA-1 for this test fixture. + sum := sha1.Sum(append(prefix, raw...)) + return hex.EncodeToString(sum[:]) +} + +type mockPolicyVerifier struct { + verifyImage func(context.Context, policyimage.ReferrersProvider, ocispecs.Descriptor, *ocispecs.Platform) (*policytypes.SignatureInfo, error) +} + +func (m *mockPolicyVerifier) VerifyImage(ctx context.Context, provider policyimage.ReferrersProvider, desc ocispecs.Descriptor, platform *ocispecs.Platform) (*policytypes.SignatureInfo, error) { + return m.verifyImage(ctx, provider, desc, platform) +} + +func newTestAttestationChain(t *testing.T) *gwpb.AttestationChain { + t.Helper() + + imgDigest := digest.FromString("image-manifest") + attDigest := digest.FromString("attestation-manifest") + + indexBytes := mustMarshalJSON(t, map[string]any{ + "mediaType": ocispecs.MediaTypeImageIndex, + "manifests": []map[string]any{ + { + "mediaType": ocispecs.MediaTypeImageManifest, + "digest": imgDigest.String(), + "size": int64(10), + "platform": map[string]any{ + "os": "linux", + "architecture": "amd64", + }, + }, + { + "mediaType": ocispecs.MediaTypeImageManifest, + "digest": attDigest.String(), + "size": int64(10), + "annotations": map[string]string{ + policyimage.AnnotationDockerReferenceType: policyimage.AttestationManifestType, + policyimage.AnnotationDockerReferenceDigest: imgDigest.String(), + }, + }, + }, + }) + indexDigest := digest.FromBytes(indexBytes) + + sigManifestBytes := mustMarshalJSON(t, map[string]any{ + "schemaVersion": 2, + "mediaType": ocispecs.MediaTypeImageManifest, + "artifactType": policyimage.ArtifactTypeSigstoreBundle, + }) + sigDigest := digest.FromBytes(sigManifestBytes) + + return &gwpb.AttestationChain{ + Root: indexDigest.String(), + AttestationManifest: attDigest.String(), + SignatureManifests: []string{sigDigest.String()}, + Blobs: map[string]*gwpb.Blob{ + indexDigest.String(): { + Descriptor_: &gwpb.Descriptor{ + MediaType: ocispecs.MediaTypeImageIndex, + Digest: indexDigest.String(), + Size: int64(len(indexBytes)), + }, + Data: indexBytes, + }, + sigDigest.String(): { + Descriptor_: &gwpb.Descriptor{ + MediaType: ocispecs.MediaTypeImageManifest, + Digest: sigDigest.String(), + Size: int64(len(sigManifestBytes)), + }, + Data: sigManifestBytes, + }, + }, + } +} + +func mustMarshalJSON(t *testing.T, v any) []byte { + t.Helper() + dt, err := json.Marshal(v) + require.NoError(t, err) + return dt +}