Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions cmd/validate/image.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ func validateImageCmd(validate imageValidationFunc) *cobra.Command {
},
IgnoreRekor: data.ignoreRekor,
SkipImageSigCheck: data.skipImageSigCheck,
SkipAttSigCheck: data.skipAttSigCheck,
PolicyRef: data.policyConfiguration,
PublicKey: data.publicKey,
RekorURL: data.rekorURL,
Expand Down Expand Up @@ -498,6 +499,9 @@ func validateImageCmd(validate imageValidationFunc) *cobra.Command {
cmd.Flags().BoolVar(&data.skipImageSigCheck, "skip-image-sig-check", data.skipImageSigCheck,
"Skip image signature validation checks.")

cmd.Flags().BoolVar(&data.skipAttSigCheck, "skip-att-sig-check", data.skipAttSigCheck,
"Skip attestation signature validation checks.")

cmd.Flags().StringVar(&data.certificateIdentity, "certificate-identity", data.certificateIdentity,
"URL of the certificate identity for keyless verification")

Expand Down Expand Up @@ -636,6 +640,7 @@ type imageData struct {
input string
ignoreRekor bool
skipImageSigCheck bool
skipAttSigCheck bool
output []string
outputFile string
policy policy.Policy
Expand Down
1 change: 1 addition & 0 deletions docs/modules/ROOT/pages/ec_validate_image.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ mark (?) sign, for example: --output text=output.txt?show-successes=false
* inline JSON ('{sources: {...}, identity: {...}}')")
-k, --public-key:: path to the public key. Overrides publicKey from EnterpriseContractPolicy
-r, --rekor-url:: Rekor URL. Overrides rekorURL from EnterpriseContractPolicy
--skip-att-sig-check:: Skip attestation signature validation checks. (Default: false)
--skip-image-sig-check:: Skip image signature validation checks. (Default: false)
--snapshot:: Provide the AppStudio Snapshot as a source of the images to validate, as inline
JSON of the "spec" or a reference to a Kubernetes object [<namespace>/]<name>
Expand Down
71 changes: 71 additions & 0 deletions features/__snapshots__/validate_image.snap
Original file line number Diff line number Diff line change
Expand Up @@ -5780,3 +5780,74 @@ Error: success criteria not met
[TestFeatures/discover artifact referrers via OCI Referrers API:stderr - 1]

---

[TestFeatures/happy day with skip-att-sig-check flag:stdout - 1]
{
"success": true,
"components": [
{
"name": "Unnamed",
"containerImage": "${REGISTRY}/acceptance/ec-happy-day@sha256:${REGISTRY_acceptance/ec-happy-day:latest_DIGEST}",
"source": {},
"successes": [
{
"msg": "Pass",
"metadata": {
"code": "builtin.attestation.syntax_check"
}
},
{
"msg": "Pass",
"metadata": {
"code": "builtin.image.signature_check"
}
},
{
"msg": "Pass",
"metadata": {
"code": "main.acceptor"
}
}
],
"success": true,
"signatures": [
{
"keyid": "",
"sig": "${IMAGE_SIGNATURE_acceptance/ec-happy-day}"
}
],
"attestations": [
{
"type": "https://in-toto.io/Statement/v0.1",
"predicateType": "https://slsa.dev/provenance/v0.2",
"predicateBuildType": "https://tekton.dev/attestations/chains/pipelinerun@v2",
"signatures": [
{
"keyid": "",
"sig": "${ATTESTATION_SIGNATURE_acceptance/ec-happy-day}"
}
]
}
]
}
],
"key": "${known_PUBLIC_KEY_JSON}",
"policy": {
"sources": [
{
"policy": [
"git::${GITHOST}/git/happy-day-policy.git?ref=${LATEST_COMMIT}"
]
}
],
"rekorUrl": "${REKOR}",
"publicKey": "${known_PUBLIC_KEY}"
},
"ec-version": "${EC_VERSION}",
"effective-time": "${TIMESTAMP}"
}
---

[TestFeatures/happy day with skip-att-sig-check flag:stderr - 1]

---
25 changes: 25 additions & 0 deletions features/validate_image.feature
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,31 @@ Feature: evaluate enterprise contract
# builtin.attestation.image_check is not found in the success output
Then the output should match the snapshot

Scenario: happy day with skip-att-sig-check flag
Given a key pair named "known"
Given an image named "acceptance/ec-happy-day"
Given a valid image signature of "acceptance/ec-happy-day" image signed by the "known" key
Given a valid attestation of "acceptance/ec-happy-day" signed by the "known" key
Given a git repository named "happy-day-policy" with
| main.rego | examples/happy_day.rego |
Given policy configuration named "ec-policy" with specification
"""
{
"sources": [
{
"policy": [
"git::https://${GITHOST}/git/happy-day-policy.git"
]
}
]
}
"""
When ec command is run with "validate image --image ${REGISTRY}/acceptance/ec-happy-day --policy acceptance/ec-policy --public-key ${known_PUBLIC_KEY} --skip-att-sig-check --rekor-url ${REKOR} --show-successes --output json"
Then the exit status should be 0
# The only difference to the happy day scenario is that
# builtin.attestation.signature_check is not found in the success output
Then the output should match the snapshot

Scenario: happy day with git config and yaml
Given a key pair named "known"
Given an image named "acceptance/ec-happy-day"
Comment on lines +59 to 86
Copy link
Copy Markdown
Contributor

@st3penta st3penta May 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The attestation here is signed with the correct key, so the skip flag is redundant. The --skip-image-sig-check has a companion scenario at line 214 with an invalid signature that proves the flag is needed. An equivalent scenario with a wrong attestation key would catch a regression that silently ignores this flag.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I was being lazy here main to satisfy Codecov stats. 😅

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import (
"github.com/santhosh-tekuri/jsonschema/v5"
"github.com/sigstore/cosign/v3/pkg/cosign"
cosignOCI "github.com/sigstore/cosign/v3/pkg/oci"
ociremote "github.com/sigstore/cosign/v3/pkg/oci/remote"
log "github.com/sirupsen/logrus"
"github.com/spf13/afero"

Expand Down Expand Up @@ -201,9 +202,49 @@ func (a *ApplicationSnapshotImage) ValidateAttestationSignature(ctx context.Cont
return a.parseAttestationsFromBundles(layers)
}

// Extract the signatures from the attestations here in order to also validate that
// the signatures do exist in the expected format.
for _, sig := range layers {
return a.parseAttestationsFromSignatures(layers)
}

// FetchAttestationsWithoutVerification fetches attestations from the registry
// without performing signature verification. This is used when --skip-att-sig-check
// is enabled but we still need the attestation data for policy evaluation.
func (a *ApplicationSnapshotImage) FetchAttestationsWithoutVerification(ctx context.Context) error {
if trace.IsEnabled() {
region := trace.StartRegion(ctx, "ec:fetch-attestations-without-verification")
defer region.End()
}

remoteOpts := oci.CreateRemoteOptions(ctx)
signedEntity, err := ociremote.SignedEntity(a.reference, ociremote.WithRemoteOptions(remoteOpts...))
Copy link
Copy Markdown
Contributor

@st3penta st3penta May 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This calls ociremote.SignedEntity directly, bypassing the oci.Client abstraction every other OCI operation in this file uses. That import was deliberately removed in #3269 to centralize OCI access. Adding a method to oci.Client would keep the pattern consistent and make this testable with fake.FakeClient.

if err != nil {
return fmt.Errorf("failed to fetch signed entity: %w", err)
}

layers, err := signedEntity.Attestations()
if err != nil {
return fmt.Errorf("failed to fetch attestations: %w", err)
}

// Check if using bundles
useBundles := a.hasBundles(ctx)
if useBundles {
sigs, err := layers.Get()
if err != nil {
return fmt.Errorf("failed to get attestation signatures: %w", err)
}
return a.parseAttestationsFromBundles(sigs)
}

sigs, err := layers.Get()
if err != nil {
return fmt.Errorf("failed to get attestation signatures: %w", err)
}

Comment on lines +231 to +242
Copy link
Copy Markdown
Contributor

@st3penta st3penta May 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

layers.Get() is identical in both branches -- could hoist it above the conditional.

sigs, err := layers.Get()
if err != nil {
    return fmt.Errorf("failed to get attestation signatures: %%w", err)
}

if useBundles {
    return a.parseAttestationsFromBundles(sigs)
}
return a.parseAttestationsFromSignatures(sigs)

return a.parseAttestationsFromSignatures(sigs)
}
Comment on lines +211 to +244
Copy link
Copy Markdown
Contributor

@st3penta st3penta May 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method has four error paths and a bundle/non-bundle branch, none tested at the unit level. The integration test does not mock this path either, so the fetch silently fails there. Routing through oci.Client (per the comment above) would make it straightforward to test with the existing fake infrastructure.


func (a *ApplicationSnapshotImage) parseAttestationsFromSignatures(sigs []cosignOCI.Signature) error {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ValidateAttestationSyntax docstring references ValidateAttestationSignature as the required prefill, but this function is now also a valid prefill path. Worth updating the docstring to mention both.

for _, sig := range sigs {
att, err := attestation.ProvenanceFromSignature(sig)
if err != nil {
return fmt.Errorf("unable to parse untyped provenance: %w", err)
Expand All @@ -212,34 +253,27 @@ func (a *ApplicationSnapshotImage) ValidateAttestationSignature(ctx context.Cont
log.Debugf("Found attestation with predicateType: %s", t)
switch t {
case attestation.PredicateSLSAProvenance:
// SLSAProvenanceFromSignature does the payload extraction
// and decoding that was done in ProvenanceFromSignature
// over again. We could refactor so we're not doing that twice,
// but it's not super important IMO.
// SLSAProvenanceFromSignature re-does the payload extraction from
// ProvenanceFromSignature. Could be deduplicated but not important.
sp, err := attestation.SLSAProvenanceFromSignature(sig)
if err != nil {
return fmt.Errorf("unable to parse as SLSA v0.2: %w", err)
}
a.attestations = append(a.attestations, sp)

case attestation.PredicateSLSAProvenanceV1:
// SLSA Provenance v1.0
sp, err := attestation.SLSAProvenanceFromSignatureV1(sig)
if err != nil {
return fmt.Errorf("unable to parse as SLSA v1: %w", err)
}
a.attestations = append(a.attestations, sp)

case attestation.PredicateSpdxDocument:
// It's an SPDX format SBOM
// Todo maybe: We could unmarshal it into a suitable SPDX struct
// similar to how it's done for SLSA above
a.attestations = append(a.attestations, att)

// Todo: CycloneDX format SBOM

default:
// It's some other kind of attestation
a.attestations = append(a.attestations, att)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1081,6 +1081,156 @@ func createBundleDSSESignature(t *testing.T, statement any) oci.Signature {
}

// createDSSESignature creates a test signature with a DSSE envelope containing the given statement
func TestParseAttestationsFromSignatures(t *testing.T) {
ref := name.MustParseReference("registry.io/repository/image:tag")

//nolint:staticcheck
slsaV02Statement := in_toto.ProvenanceStatementSLSA02{
StatementHeader: in_toto.StatementHeader{
Type: in_toto.StatementInTotoV01,
PredicateType: v02.PredicateSLSAProvenance,
Subject: []in_toto.Subject{
{
Name: "test-image",
Digest: common.DigestSet{"sha256": "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"},
},
},
},
Predicate: v02.ProvenancePredicate{
BuildType: pipelineRunBuildType,
Builder: common.ProvenanceBuilder{ID: "https://tekton.dev/chains/v2"},
},
}

//nolint:staticcheck
slsaV1Statement := in_toto.ProvenanceStatementSLSA1{
StatementHeader: in_toto.StatementHeader{
Type: in_toto.StatementInTotoV01,
PredicateType: "https://slsa.dev/provenance/v1",
Subject: []in_toto.Subject{
{
Name: "test-image",
Digest: common.DigestSet{"sha256": "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"},
},
},
},
Predicate: slsav1.ProvenancePredicate{
BuildDefinition: slsav1.ProvenanceBuildDefinition{
BuildType: "https://tekton.dev/attestations/chains/pipelinerun@v2",
ExternalParameters: json.RawMessage(`{}`),
},
RunDetails: slsav1.ProvenanceRunDetails{
Builder: slsav1.Builder{ID: "https://tekton.dev/chains/v2"},
},
},
}

//nolint:staticcheck
spdxStatement := in_toto.Statement{
StatementHeader: in_toto.StatementHeader{
Type: in_toto.StatementInTotoV01,
PredicateType: "https://spdx.dev/Document",
Subject: []in_toto.Subject{
{
Name: "test-image",
Digest: common.DigestSet{"sha256": "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"},
},
},
},
Predicate: json.RawMessage(`{"spdxVersion":"SPDX-2.3"}`),
}

//nolint:staticcheck
unknownStatement := in_toto.Statement{
StatementHeader: in_toto.StatementHeader{
Type: in_toto.StatementInTotoV01,
PredicateType: "https://example.com/unknown/v1",
Subject: []in_toto.Subject{
{
Name: "test-image",
Digest: common.DigestSet{"sha256": "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"},
},
},
},
Predicate: json.RawMessage(`{"custom":"data"}`),
}

cases := []struct {
name string
signatures []oci.Signature
expectErr bool
expectedAttCount int
expectedPredTypes []string
}{
{
name: "no signatures",
signatures: []oci.Signature{},
expectedAttCount: 0,
},
{
name: "SLSA v0.2",
signatures: []oci.Signature{createDSSESignature(t, slsaV02Statement)},
expectedAttCount: 1,
expectedPredTypes: []string{v02.PredicateSLSAProvenance},
},
{
name: "SLSA v1.0",
signatures: []oci.Signature{createDSSESignature(t, slsaV1Statement)},
expectedAttCount: 1,
expectedPredTypes: []string{"https://slsa.dev/provenance/v1"},
},
{
name: "SPDX document",
signatures: []oci.Signature{createDSSESignature(t, spdxStatement)},
expectedAttCount: 1,
expectedPredTypes: []string{"https://spdx.dev/Document"},
},
{
name: "unknown predicate type",
signatures: []oci.Signature{createDSSESignature(t, unknownStatement)},
expectedAttCount: 1,
expectedPredTypes: []string{"https://example.com/unknown/v1"},
},
{
name: "multiple mixed types",
signatures: []oci.Signature{
createDSSESignature(t, slsaV02Statement),
createDSSESignature(t, slsaV1Statement),
createDSSESignature(t, spdxStatement),
createDSSESignature(t, unknownStatement),
},
expectedAttCount: 4,
expectedPredTypes: []string{
v02.PredicateSLSAProvenance,
"https://slsa.dev/provenance/v1",
"https://spdx.dev/Document",
"https://example.com/unknown/v1",
},
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
a := ApplicationSnapshotImage{reference: ref}

err := a.parseAttestationsFromSignatures(tc.signatures)

if tc.expectErr {
require.Error(t, err)
} else {
require.NoError(t, err)
assert.Equal(t, tc.expectedAttCount, len(a.attestations))

if tc.expectedPredTypes != nil {
for i, expectedType := range tc.expectedPredTypes {
assert.Equal(t, expectedType, a.attestations[i].PredicateType())
}
}
}
})
}
}

func createDSSESignature(t *testing.T, statement any) oci.Signature {
t.Helper()

Expand Down
Loading
Loading