policy: add image.provenance input type

Signed-off-by: Tonis Tiigi <tonistiigi@gmail.com>
This commit is contained in:
Tonis Tiigi
2026-02-12 11:59:06 -08:00
parent 53882aec96
commit 8d605dcd87
9 changed files with 708 additions and 15 deletions
+41
View File
@@ -46,6 +46,47 @@ func TestAddUnknowns(t *testing.T) {
},
},
},
{
name: "image-provenance-enables-resolve-attestations",
unknowns: []string{"image.provenance"},
initial: &gwpb.ResolveSourceMetaRequest{},
expected: &gwpb.ResolveSourceMetaRequest{
Image: &gwpb.ResolveSourceImageRequest{
NoConfig: true,
AttestationChain: true,
ResolveAttestations: resolveProvenanceAttestations,
},
},
},
{
name: "nested-image-provenance-enables-resolve-attestations",
unknowns: []string{"image.provenance.buildType"},
initial: &gwpb.ResolveSourceMetaRequest{},
expected: &gwpb.ResolveSourceMetaRequest{
Image: &gwpb.ResolveSourceImageRequest{
NoConfig: true,
AttestationChain: true,
ResolveAttestations: resolveProvenanceAttestations,
},
},
},
{
name: "image-provenance-on-existing-image-request-preserves-fields",
unknowns: []string{"image.provenance.builderID"},
initial: &gwpb.ResolveSourceMetaRequest{
Image: &gwpb.ResolveSourceImageRequest{
NoConfig: false,
AttestationChain: true,
},
},
expected: &gwpb.ResolveSourceMetaRequest{
Image: &gwpb.ResolveSourceImageRequest{
NoConfig: false,
AttestationChain: true,
ResolveAttestations: resolveProvenanceAttestations,
},
},
},
{
name: "image-attestation-on-existing-image-request",
unknowns: []string{"image.hasProvenance"},
+175
View File
@@ -0,0 +1,175 @@
package policy
import (
"encoding/json"
"slices"
"strings"
"time"
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"
gwpb "github.com/moby/buildkit/frontend/gateway/pb"
provenancetypes "github.com/moby/buildkit/solver/llbsolver/provenance/types"
)
const predicateTypeAnnotation = "in-toto.io/predicate-type"
var resolveProvenanceAttestations = []string{
slsa02.PredicateSLSAProvenance,
slsa1.PredicateSLSAProvenance,
}
type inTotoStatement struct {
PredicateType string `json:"predicateType"`
Predicate json.RawMessage `json:"predicate"`
}
func parseProvenance(ac *gwpb.AttestationChain) (*ImageProvenance, error) {
if ac == nil || len(ac.Blobs) == 0 {
return nil, nil
}
// Prefer blobs that explicitly declare a provenance predicate type.
for _, b := range ac.Blobs {
if b == nil || b.Descriptor_ == nil || len(b.Data) == 0 {
continue
}
pt := b.Descriptor_.Annotations[predicateTypeAnnotation]
if pt == "" {
continue
}
if !slices.Contains(resolveProvenanceAttestations, pt) {
continue
}
prv, err := parseProvenanceBlob(b.Data, pt)
if err != nil {
return nil, err
}
if prv != nil {
return prv, nil
}
}
return nil, nil
}
func parseProvenanceBlob(dt []byte, pt string) (*ImageProvenance, error) {
var stmt inTotoStatement
if err := json.Unmarshal(dt, &stmt); err != nil || len(stmt.Predicate) == 0 {
return nil, nil
}
if stmt.PredicateType != "" && stmt.PredicateType != pt {
return nil, nil
}
switch pt {
case slsa1.PredicateSLSAProvenance:
return parseSLSA1Provenance(stmt.Predicate)
case slsa02.PredicateSLSAProvenance:
return parseSLSA02Provenance(stmt.Predicate)
}
return nil, nil
}
func parseSLSA1Provenance(dt []byte) (*ImageProvenance, error) {
var pred provenancetypes.ProvenancePredicateSLSA1
if err := json.Unmarshal(dt, &pred); err != nil {
return nil, nil
}
if pred.BuildDefinition.BuildType == "" && pred.RunDetails.Builder.ID == "" {
return nil, nil
}
prv := &ImageProvenance{
PredicateType: slsa1.PredicateSLSAProvenance,
BuildType: pred.BuildDefinition.BuildType,
BuilderID: pred.RunDetails.Builder.ID,
ConfigSource: &ImageProvenanceConfigSource{
URI: pred.BuildDefinition.ExternalParameters.ConfigSource.URI,
Digest: pred.BuildDefinition.ExternalParameters.ConfigSource.Digest,
Path: pred.BuildDefinition.ExternalParameters.ConfigSource.Path,
},
Frontend: pred.BuildDefinition.ExternalParameters.Request.Frontend,
BuildArgs: extractBuildArgs(pred.BuildDefinition.ExternalParameters.Request.Args),
RawArgs: pred.BuildDefinition.ExternalParameters.Request.Args,
}
if md := pred.RunDetails.Metadata; md != nil {
prv.InvocationID = md.InvocationID
prv.StartedOn = formatProvenanceTime(md.StartedOn)
prv.FinishedOn = formatProvenanceTime(md.FinishedOn)
prv.Reproducible = boolPtr(md.Reproducible)
prv.Hermetic = boolPtr(md.Hermetic)
prv.Completeness = &ImageProvenanceCompleteness{
Parameters: boolPtr(md.Completeness.Request),
Materials: boolPtr(md.Completeness.ResolvedDependencies),
}
}
return prv, nil
}
func parseSLSA02Provenance(dt []byte) (*ImageProvenance, error) {
var pred provenancetypes.ProvenancePredicateSLSA02
if err := json.Unmarshal(dt, &pred); err != nil {
return nil, nil
}
if pred.BuildType == "" && pred.Builder.ID == "" {
return nil, nil
}
prv := &ImageProvenance{
PredicateType: slsa02.PredicateSLSAProvenance,
BuildType: pred.BuildType,
BuilderID: pred.Builder.ID,
ConfigSource: &ImageProvenanceConfigSource{
URI: pred.Invocation.ConfigSource.URI,
Digest: pred.Invocation.ConfigSource.Digest,
Path: pred.Invocation.ConfigSource.EntryPoint,
},
Frontend: pred.Invocation.Parameters.Frontend,
BuildArgs: extractBuildArgs(pred.Invocation.Parameters.Args),
RawArgs: pred.Invocation.Parameters.Args,
}
if md := pred.Metadata; md != nil {
prv.InvocationID = md.BuildInvocationID
prv.StartedOn = formatProvenanceTime(md.BuildStartedOn)
prv.FinishedOn = formatProvenanceTime(md.BuildFinishedOn)
prv.Reproducible = boolPtr(md.Reproducible)
prv.Hermetic = boolPtr(md.Hermetic)
prv.Completeness = &ImageProvenanceCompleteness{
Parameters: boolPtr(md.Completeness.Parameters),
Environment: boolPtr(md.Completeness.Environment),
Materials: boolPtr(md.Completeness.Materials),
}
}
return prv, nil
}
func boolPtr(v bool) *bool {
return &v
}
func formatProvenanceTime(t *time.Time) string {
if t == nil {
return ""
}
return t.UTC().Format(time.RFC3339)
}
func extractBuildArgs(args map[string]string) map[string]string {
if len(args) == 0 {
return nil
}
const prefix = "build-arg:"
out := make(map[string]string)
for k, v := range args {
if name, ok := strings.CutPrefix(k, prefix); ok && name != "" {
out[name] = v
}
}
if len(out) == 0 {
return nil
}
return out
}
+32
View File
@@ -128,9 +128,41 @@ type Image struct {
WorkingDir string `json:"workingDir,omitempty"`
HasProvenance bool `json:"hasProvenance,omitempty"`
Provenance *ImageProvenance `json:"provenance,omitempty"`
Signatures []AttestationSignature `json:"signatures,omitempty"`
}
type ImageProvenance struct {
PredicateType string `json:"predicateType,omitempty"`
BuildType string `json:"buildType,omitempty"`
BuilderID string `json:"builderID,omitempty"`
InvocationID string `json:"invocationID,omitempty"`
StartedOn string `json:"startedOn,omitempty"`
FinishedOn string `json:"finishedOn,omitempty"`
ConfigSource *ImageProvenanceConfigSource `json:"configSource,omitempty"`
Frontend string `json:"frontend,omitempty"`
BuildArgs map[string]string `json:"buildArgs,omitempty"`
RawArgs map[string]string `json:"rawArgs,omitempty"`
Reproducible *bool `json:"reproducible,omitempty"`
Hermetic *bool `json:"hermetic,omitempty"`
Completeness *ImageProvenanceCompleteness `json:"completeness,omitempty"`
}
type ImageProvenanceConfigSource struct {
URI string `json:"uri,omitempty"`
Digest map[string]string `json:"digest,omitempty"`
Path string `json:"path,omitempty"`
}
type ImageProvenanceCompleteness struct {
Parameters *bool `json:"parameters,omitempty"`
Environment *bool `json:"environment,omitempty"`
Materials *bool `json:"materials,omitempty"`
}
type AttestationSignature struct {
SignatureKind SignatureKind `json:"kind,omitempty"`
SignatureType SignatureType `json:"type,omitempty"`
+33 -3
View File
@@ -555,7 +555,7 @@ func SourceToInputWithLogger(ctx context.Context, getVerifier PolicyVerifierProv
unknowns = append(unknowns, "input.image.checksum")
}
unknowns = append(unknowns, withPrefix(configFields, "input.image.")...)
unknowns = append(unknowns, "input.image.hasProvenance", "input.image.signatures")
unknowns = append(unknowns, "input.image.hasProvenance", "input.image.provenance", "input.image.signatures")
} else {
inp.Image.Checksum = src.Image.Digest
if cfg := src.Image.Config; cfg != nil {
@@ -577,7 +577,14 @@ func SourceToInputWithLogger(ctx context.Context, getVerifier PolicyVerifierProv
}
if ac := src.Image.AttestationChain; ac != nil {
inp.Image.HasProvenance = ac.AttestationManifest != ""
if prv, err := parseProvenance(ac); err != nil {
if logf != nil {
logf(logrus.DebugLevel, fmt.Sprintf("failed to parse image provenance: %v", err))
}
} else {
inp.Image.Provenance = prv
}
inp.Image.HasProvenance = ac.AttestationManifest != "" || inp.Image.Provenance != nil
if getVerifier != nil {
signatures, err := parseSignatures(ctx, getVerifier, ac, platform)
if err != nil {
@@ -589,7 +596,7 @@ func SourceToInputWithLogger(ctx context.Context, getVerifier PolicyVerifierProv
}
}
} else {
unknowns = append(unknowns, "input.image.hasProvenance", "input.image.signatures")
unknowns = append(unknowns, "input.image.hasProvenance", "input.image.provenance", "input.image.signatures")
}
}
case "local":
@@ -635,6 +642,17 @@ func AddUnknownsWithLogger(logf func(logrus.Level, string), req *gwpb.ResolveSou
logf(logrus.DebugLevel, fmt.Sprintf("collected unknowns: %+v", unk2))
}
for _, u := range unk2 {
if u == "image.provenance" || strings.HasPrefix(u, "image.provenance.") {
if req.Image == nil {
req.Image = &gwpb.ResolveSourceImageRequest{
NoConfig: true,
}
}
req.Image.AttestationChain = true
req.Image.ResolveAttestations = appendUnique(req.Image.ResolveAttestations, resolveProvenanceAttestations...)
continue
}
switch u {
case "image.checksum", "image.labels", "image.user", "image.volumes", "image.workingDir", "image.env":
if req.Image == nil {
@@ -729,6 +747,9 @@ func summarizeUnknownsForLog(unk []string) []string {
if strings.HasPrefix(u, "image.signatures") {
u = "image.signatures"
}
if strings.HasPrefix(u, "image.provenance") {
u = "image.provenance"
}
if u == "image" {
continue
}
@@ -741,6 +762,15 @@ func summarizeUnknownsForLog(unk []string) []string {
return out
}
func appendUnique(dst []string, values ...string) []string {
for _, v := range values {
if !slices.Contains(dst, v) {
dst = append(dst, v)
}
}
return dst
}
func hasHTTPUnknowns(unk []string) bool {
for _, u := range unk {
if strings.HasPrefix(u, "http.") {
+239 -1
View File
@@ -9,6 +9,8 @@ import (
"testing"
"time"
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"
gwpb "github.com/moby/buildkit/frontend/gateway/pb"
"github.com/moby/buildkit/solver/pb"
policyimage "github.com/moby/policy-helpers/image"
@@ -177,6 +179,7 @@ func TestSourceToInputWithLogger(t *testing.T) {
"input.image.workingDir",
"input.image.env",
"input.image.hasProvenance",
"input.image.provenance",
"input.image.signatures",
},
},
@@ -208,6 +211,7 @@ func TestSourceToInputWithLogger(t *testing.T) {
"input.image.workingDir",
"input.image.env",
"input.image.hasProvenance",
"input.image.provenance",
"input.image.signatures",
},
},
@@ -343,6 +347,113 @@ func TestSourceToInputWithLogger(t *testing.T) {
require.Equal(t, "docker/buildx", sig.Signer.SourceRepositoryIdentifier)
},
},
{
name: "image-attestation-chain-loads-provenance-fields-v0.2",
src: &gwpb.ResolveSourceMetaResponse{
Source: &pb.SourceOp{
Identifier: "docker-image://alpine:latest",
},
Image: &gwpb.ResolveSourceImageResponse{
Digest: "sha256:efefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefef",
AttestationChain: newTestAttestationChainWithProvenance(t),
},
},
platform: &ocispecs.Platform{OS: "linux", Architecture: "amd64"},
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.NotNil(t, inp.Image.Provenance)
require.Equal(t, slsa02.PredicateSLSAProvenance, inp.Image.Provenance.PredicateType)
require.Equal(t, "https://example.com/build-type", inp.Image.Provenance.BuildType)
require.Equal(t, "https://example.com/builder-id", inp.Image.Provenance.BuilderID)
require.Equal(t, "inv-v02", inp.Image.Provenance.InvocationID)
require.Equal(t, "2024-01-02T03:04:05Z", inp.Image.Provenance.StartedOn)
require.Equal(t, "2024-01-02T03:05:05Z", inp.Image.Provenance.FinishedOn)
require.Equal(t, "gateway.v0", inp.Image.Provenance.Frontend)
require.Equal(t, map[string]string{"BUILDKIT_CONTEXT_KEEP_GIT_DIR": "1"}, inp.Image.Provenance.BuildArgs)
require.Equal(t, map[string]string{
"build-arg:BUILDKIT_CONTEXT_KEEP_GIT_DIR": "1",
"cmdline": "docker/dockerfile-upstream:master",
}, inp.Image.Provenance.RawArgs)
require.NotNil(t, inp.Image.Provenance.ConfigSource)
require.Equal(t, "https://github.com/moby/buildkit.git#refs/tags/v0.21.0", inp.Image.Provenance.ConfigSource.URI)
require.Equal(t, "Dockerfile", inp.Image.Provenance.ConfigSource.Path)
require.Equal(t, map[string]string{"sha1": "52b004d2afe20c5c80967cc1784e718b52d69dae"}, inp.Image.Provenance.ConfigSource.Digest)
require.NotNil(t, inp.Image.Provenance.Completeness)
require.NotNil(t, inp.Image.Provenance.Completeness.Parameters)
require.True(t, *inp.Image.Provenance.Completeness.Parameters)
require.NotNil(t, inp.Image.Provenance.Completeness.Environment)
require.True(t, *inp.Image.Provenance.Completeness.Environment)
require.NotNil(t, inp.Image.Provenance.Completeness.Materials)
require.False(t, *inp.Image.Provenance.Completeness.Materials)
require.NotNil(t, inp.Image.Provenance.Reproducible)
require.True(t, *inp.Image.Provenance.Reproducible)
require.NotNil(t, inp.Image.Provenance.Hermetic)
require.True(t, *inp.Image.Provenance.Hermetic)
},
},
{
name: "image-attestation-chain-loads-provenance-fields-v1",
src: &gwpb.ResolveSourceMetaResponse{
Source: &pb.SourceOp{
Identifier: "docker-image://alpine:latest",
},
Image: &gwpb.ResolveSourceImageResponse{
Digest: "sha256:f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0",
AttestationChain: newTestAttestationChainWithProvenanceV1(t),
},
},
platform: &ocispecs.Platform{OS: "linux", Architecture: "amd64"},
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.NotNil(t, inp.Image.Provenance)
require.Equal(t, slsa1.PredicateSLSAProvenance, inp.Image.Provenance.PredicateType)
require.Equal(t, "https://example.com/build-type-v1", inp.Image.Provenance.BuildType)
require.Equal(t, "https://example.com/builder-id-v1", inp.Image.Provenance.BuilderID)
require.Equal(t, "inv-v1", inp.Image.Provenance.InvocationID)
require.Equal(t, "2024-02-03T04:05:06Z", inp.Image.Provenance.StartedOn)
require.Equal(t, "2024-02-03T04:06:06Z", inp.Image.Provenance.FinishedOn)
require.Equal(t, "gateway.v0", inp.Image.Provenance.Frontend)
require.Equal(t, map[string]string{"BUILDKIT_CONTEXT_KEEP_GIT_DIR": "1"}, inp.Image.Provenance.BuildArgs)
require.Equal(t, map[string]string{
"build-arg:BUILDKIT_CONTEXT_KEEP_GIT_DIR": "1",
"source": "docker/dockerfile-upstream:master",
}, inp.Image.Provenance.RawArgs)
require.NotNil(t, inp.Image.Provenance.ConfigSource)
require.Equal(t, "https://github.com/moby/buildkit.git#refs/heads/master", inp.Image.Provenance.ConfigSource.URI)
require.Equal(t, "Dockerfile", inp.Image.Provenance.ConfigSource.Path)
require.Equal(t, map[string]string{"sha1": "9836771d0c5b21cbc7f0c38b81be39c42fc46b7b"}, inp.Image.Provenance.ConfigSource.Digest)
require.NotNil(t, inp.Image.Provenance.Completeness)
require.NotNil(t, inp.Image.Provenance.Completeness.Parameters)
require.True(t, *inp.Image.Provenance.Completeness.Parameters)
require.Nil(t, inp.Image.Provenance.Completeness.Environment)
require.NotNil(t, inp.Image.Provenance.Completeness.Materials)
require.False(t, *inp.Image.Provenance.Completeness.Materials)
require.NotNil(t, inp.Image.Provenance.Reproducible)
require.True(t, *inp.Image.Provenance.Reproducible)
require.NotNil(t, inp.Image.Provenance.Hermetic)
require.True(t, *inp.Image.Provenance.Hermetic)
},
},
{
name: "image-attestation-chain-without-manifest-keeps-has-provenance-false",
src: &gwpb.ResolveSourceMetaResponse{
@@ -428,7 +539,7 @@ func TestSourceToInputWithLogger(t *testing.T) {
WorkingDir: "/work",
},
},
expUnk: []string{"input.image.hasProvenance", "input.image.signatures"},
expUnk: []string{"input.image.hasProvenance", "input.image.provenance", "input.image.signatures"},
},
{
name: "git-source-missing-full-remote-url-attr",
@@ -855,6 +966,133 @@ func newTestAttestationChain(t *testing.T) *gwpb.AttestationChain {
}
}
func newTestAttestationChainWithProvenance(t *testing.T) *gwpb.AttestationChain {
t.Helper()
ac := newTestAttestationChain(t)
provenancePredicate := map[string]any{
"builder": map[string]any{
"id": "https://example.com/builder-id",
},
"buildType": "https://example.com/build-type",
"invocation": map[string]any{
"configSource": map[string]any{
"digest": map[string]any{
"sha1": "52b004d2afe20c5c80967cc1784e718b52d69dae",
},
"entryPoint": "Dockerfile",
"uri": "https://github.com/moby/buildkit.git#refs/tags/v0.21.0",
},
"parameters": map[string]any{
"frontend": "gateway.v0",
"args": map[string]any{
"build-arg:BUILDKIT_CONTEXT_KEEP_GIT_DIR": "1",
"cmdline": "docker/dockerfile-upstream:master",
},
},
"environment": map[string]any{
"platform": "linux/amd64",
},
},
"metadata": map[string]any{
"buildInvocationID": "inv-v02",
"buildStartedOn": "2024-01-02T03:04:05Z",
"buildFinishedOn": "2024-01-02T03:05:05Z",
"completeness": map[string]any{
"parameters": true,
"environment": true,
"materials": false,
},
"reproducible": true,
"https://mobyproject.org/buildkit@v1#hermetic": true,
},
}
provenanceBytes := mustMarshalJSON(t, map[string]any{
"_type": "https://in-toto.io/Statement/v0.1",
"predicateType": slsa02.PredicateSLSAProvenance,
"predicate": provenancePredicate,
})
provenanceDigest := digest.FromBytes(provenanceBytes)
ac.Blobs[provenanceDigest.String()] = &gwpb.Blob{
Descriptor_: &gwpb.Descriptor{
MediaType: "application/vnd.in-toto+json",
Digest: provenanceDigest.String(),
Size: int64(len(provenanceBytes)),
Annotations: map[string]string{
predicateTypeAnnotation: slsa02.PredicateSLSAProvenance,
},
},
Data: provenanceBytes,
}
return ac
}
func newTestAttestationChainWithProvenanceV1(t *testing.T) *gwpb.AttestationChain {
t.Helper()
ac := newTestAttestationChain(t)
provenancePredicate := map[string]any{
"buildDefinition": map[string]any{
"buildType": "https://example.com/build-type-v1",
"externalParameters": map[string]any{
"configSource": map[string]any{
"digest": map[string]any{
"sha1": "9836771d0c5b21cbc7f0c38b81be39c42fc46b7b",
},
"path": "Dockerfile",
"uri": "https://github.com/moby/buildkit.git#refs/heads/master",
},
"request": map[string]any{
"frontend": "gateway.v0",
"args": map[string]any{
"build-arg:BUILDKIT_CONTEXT_KEEP_GIT_DIR": "1",
"source": "docker/dockerfile-upstream:master",
},
},
},
"internalParameters": map[string]any{},
},
"runDetails": map[string]any{
"builder": map[string]any{
"id": "https://example.com/builder-id-v1",
},
"metadata": map[string]any{
"invocationID": "inv-v1",
"startedOn": "2024-02-03T04:05:06Z",
"finishedOn": "2024-02-03T04:06:06Z",
"buildkit_completeness": map[string]any{
"request": true,
"resolvedDependencies": false,
},
"buildkit_reproducible": true,
"buildkit_hermetic": true,
},
},
}
provenanceBytes := mustMarshalJSON(t, map[string]any{
"_type": "https://in-toto.io/Statement/v0.1",
"predicateType": slsa1.PredicateSLSAProvenance,
"predicate": provenancePredicate,
})
provenanceDigest := digest.FromBytes(provenanceBytes)
ac.Blobs[provenanceDigest.String()] = &gwpb.Blob{
Descriptor_: &gwpb.Descriptor{
MediaType: "application/vnd.in-toto+json",
Digest: provenanceDigest.String(),
Size: int64(len(provenanceBytes)),
Annotations: map[string]string{
predicateTypeAnnotation: slsa1.PredicateSLSAProvenance,
},
},
Data: provenanceBytes,
}
return ac
}
func mustMarshalJSON(t *testing.T, v any) []byte {
t.Helper()
dt, err := json.Marshal(v)