Skip to content

Commit fe56193

Browse files
stehesselclaude
andauthored
ROX-34488: discover cosign signatures via OCI 1.1 Referrers API (#21908)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 03b66b2 commit fe56193

6 files changed

Lines changed: 573 additions & 119 deletions

File tree

‎pkg/images/enricher/testing_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ type fakeSigFetcher struct {
5656
}
5757

5858
func (f *fakeSigFetcher) FetchSignatures(_ context.Context, _ *storage.Image, _ string,
59-
_ types.Registry) ([]*storage.Signature, error) {
59+
_ types.Registry, _ ...retry.OptionsModifier) ([]*storage.Signature, error) {
6060
if f.fail {
6161
err := errors.New("some error")
6262
if f.retryable {
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
package signatures
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net/http"
7+
"strings"
8+
9+
"github.com/google/go-containerregistry/pkg/name"
10+
v1 "github.com/google/go-containerregistry/pkg/v1"
11+
"github.com/sigstore/cosign/v3/pkg/cosign"
12+
"github.com/sigstore/cosign/v3/pkg/oci"
13+
ociremote "github.com/sigstore/cosign/v3/pkg/oci/remote"
14+
)
15+
16+
const (
17+
bundleSigArtifactTypePrefix = "application/vnd.dev.sigstore.bundle"
18+
19+
// maxReferrerManifests limits the number of referrer signature manifests processed per image
20+
// to bound latency and prevent pathological cases from consuming the caller's timeout budget.
21+
maxReferrerManifests = 50
22+
)
23+
24+
// signaturePayload is the internal representation of a fetched cosign signature before
25+
// it is persisted into the CosignSignature proto. Two formats are supported:
26+
//
27+
// - SimpleSigning: the legacy cosign format. The signature, payload, certificate, and
28+
// rekor bundle are stored as individual fields in cosign.SignedPayload.
29+
// - Sigstore bundle: the OCI 1.1 bundle format. The raw bundle JSON is stored as-is
30+
// in sigstoreBundle and verified directly via sigstore-go at verification time.
31+
//
32+
// The format is determined by sigstoreBundle: non-empty means sigstore bundle,
33+
// empty means SimpleSigning.
34+
type signaturePayload struct {
35+
cosign.SignedPayload
36+
sigstoreBundle []byte
37+
}
38+
39+
var _ oci.SignedEntity = (*tagSignedEntity)(nil)
40+
41+
// tagSignedEntity adapts an image reference for tag-based signature discovery.
42+
// Signatures() constructs the cosign tag reference (<algo>-<hex>.sig) and fetches
43+
// the signature manifest from the registry. Digest() returns the image digest so
44+
// cosign can build the tag. Only these two methods are called by cosign.FetchSignatures.
45+
// Attestations and Attachment return safe defaults to prevent panics if cosign evolves.
46+
type tagSignedEntity struct {
47+
opts []ociremote.Option
48+
imgRef name.Reference
49+
imgSHA string
50+
}
51+
52+
func newTagSignedEntity(imgSHA string, imgRef name.Reference, opts ...ociremote.Option) *tagSignedEntity {
53+
return &tagSignedEntity{
54+
opts: opts,
55+
imgRef: imgRef,
56+
imgSHA: imgSHA,
57+
}
58+
}
59+
60+
func (s *tagSignedEntity) Digest() (v1.Hash, error) {
61+
return v1.NewHash(s.imgSHA)
62+
}
63+
64+
func (s *tagSignedEntity) Signatures() (oci.Signatures, error) {
65+
h, err := s.Digest()
66+
if err != nil {
67+
return nil, err
68+
}
69+
// Cosign ref: https://github.com/sigstore/cosign/blob/main/pkg/oci/remote/remote.go
70+
return ociremote.Signatures(s.imgRef.Context().Tag(fmt.Sprint(h.Algorithm, "-", h.Hex, ".sig")), s.opts...)
71+
}
72+
73+
func (s *tagSignedEntity) Attestations() (oci.Signatures, error) { return nil, nil }
74+
75+
func (s *tagSignedEntity) Attachment(_ string) (oci.File, error) { return nil, nil }
76+
77+
// fetchSignaturesByTag discovers SimpleSigning signatures via the cosign tag-based method.
78+
// Discovery: looks up the tag <algo>-<hex>.sig in the same repository as the image.
79+
// Format: always SimpleSigning (signature and payload stored in OCI layer annotations).
80+
func fetchSignaturesByTag(imgSHA string, imgRef name.Reference, opts []ociremote.Option) ([]signaturePayload, error) {
81+
se := newTagSignedEntity(imgSHA, imgRef, opts...)
82+
payloads, err := cosign.FetchSignatures(se)
83+
if err != nil && (isMissingSignatureError(err) || isUnknownMimeTypeError(err)) {
84+
return nil, nil
85+
}
86+
wrapped := make([]signaturePayload, len(payloads))
87+
for i, p := range payloads {
88+
wrapped[i] = signaturePayload{SignedPayload: p}
89+
}
90+
return wrapped, err
91+
}
92+
93+
// fetchSignaturesByReferrer discovers sigstore bundle signatures via the OCI 1.1 Referrers API.
94+
// Discovery: queries the referrers index for the image digest and filters for sigstore
95+
// bundle artifact types. Only the sigstore bundle format is supported for referrer-based
96+
// discovery — cosign v3 exclusively produces bundles for this path.
97+
func fetchSignaturesByReferrer(ctx context.Context, digestRef name.Digest, repo name.Repository,
98+
opts []ociremote.Option,
99+
) ([]signaturePayload, error) {
100+
index, err := ociremote.Referrers(digestRef, "", opts...)
101+
if err != nil {
102+
// Registries that do not implement the OCI 1.1 Referrers API return 404.
103+
if checkIfErrorContainsCode(err, http.StatusNotFound) {
104+
log.Warnf("OCI referrers API not supported for %s (404)", digestRef.String())
105+
return nil, nil
106+
}
107+
return nil, err
108+
}
109+
if index == nil {
110+
return nil, nil
111+
}
112+
113+
// Filter for sigstore bundle artifact types first, then clamp so the budget is
114+
// consumed only by manifests we are willing to process.
115+
var bundleDescs []v1.Descriptor
116+
for _, desc := range index.Manifests {
117+
if strings.HasPrefix(desc.ArtifactType, bundleSigArtifactTypePrefix) {
118+
bundleDescs = append(bundleDescs, desc)
119+
}
120+
}
121+
if len(bundleDescs) > maxReferrerManifests {
122+
log.Warnf("Image %s has %d sigstore bundle referrers, processing only first %d",
123+
digestRef.String(), len(bundleDescs), maxReferrerManifests)
124+
bundleDescs = bundleDescs[:maxReferrerManifests]
125+
}
126+
127+
var payloads []signaturePayload
128+
for _, desc := range bundleDescs {
129+
if ctx.Err() != nil {
130+
return payloads, ctx.Err()
131+
}
132+
p, err := fetchSigstoreBundle(repo.Digest(desc.Digest.String()), opts)
133+
if err != nil {
134+
return payloads, fmt.Errorf("fetching sigstore bundle from referrer %s: %w", desc.Digest, err)
135+
}
136+
payloads = append(payloads, p)
137+
}
138+
return payloads, nil
139+
}
140+
141+
// fetchSigstoreBundle fetches a sigstore bundle referrer and stores the raw JSON.
142+
// Format: sigstore bundle (DSSE envelope + verification material + tlog entries in one blob).
143+
// Storage: the raw bundle JSON is stored in SigstoreBundle; no decomposition into individual
144+
// fields. Verification is deferred to verifySigstoreBundle which uses sigstore-go directly.
145+
func fetchSigstoreBundle(bundleRef name.Reference, opts []ociremote.Option) (signaturePayload, error) {
146+
b, err := ociremote.Bundle(bundleRef, opts...)
147+
if err != nil {
148+
return signaturePayload{}, err
149+
}
150+
151+
bundleJSON, err := b.MarshalJSON()
152+
if err != nil {
153+
return signaturePayload{}, fmt.Errorf("marshalling sigstore bundle: %w", err)
154+
}
155+
156+
return signaturePayload{sigstoreBundle: bundleJSON}, nil
157+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package signatures
2+
3+
import (
4+
"context"
5+
"encoding/base64"
6+
"fmt"
7+
"testing"
8+
9+
"github.com/google/go-containerregistry/pkg/name"
10+
"github.com/sigstore/cosign/v3/pkg/oci/mutate"
11+
ociremote "github.com/sigstore/cosign/v3/pkg/oci/remote"
12+
"github.com/sigstore/cosign/v3/pkg/oci/static"
13+
"github.com/stackrox/rox/pkg/images/types"
14+
imgUtils "github.com/stackrox/rox/pkg/images/utils"
15+
"github.com/stretchr/testify/assert"
16+
"github.com/stretchr/testify/require"
17+
)
18+
19+
// uploadSignatureAsReferrer stores a cosign signature as an OCI 1.1 referrer for the given image.
20+
func uploadSignatureAsReferrer(imgRef string, b64Sig string, sigPayload, certPEM, chainPEM []byte) error {
21+
sigOpts := []static.Option{static.WithCertChain(certPEM, chainPEM)}
22+
23+
sig, err := static.NewSignature(sigPayload, b64Sig, sigOpts...)
24+
if err != nil {
25+
return err
26+
}
27+
28+
ref, err := name.ParseReference(imgRef)
29+
if err != nil {
30+
return err
31+
}
32+
d, ok := ref.(name.Digest)
33+
if !ok {
34+
return fmt.Errorf("could not cast reference %q to name.Digest", ref.String())
35+
}
36+
37+
se, err := ociremote.SignedEntity(ref)
38+
if err != nil {
39+
return err
40+
}
41+
42+
newSE, err := mutate.AttachSignatureToEntity(se, sig)
43+
if err != nil {
44+
return err
45+
}
46+
47+
return ociremote.WriteSignaturesExperimentalOCI(d, newSE)
48+
}
49+
50+
func TestFetchTagPayloads_WithSignatures(t *testing.T) {
51+
registryServer, imgRef, err := registryServerWithImage("nginx")
52+
require.NoError(t, err, "setting up registry")
53+
defer registryServer.Close()
54+
55+
sigPayload, err := base64.StdEncoding.DecodeString(payload1)
56+
require.NoError(t, err)
57+
58+
require.NoError(t, uploadSignatureForImage(imgRef, sig1, sigPayload, nil, nil, nil),
59+
"uploading tag-based signature")
60+
61+
ref, err := name.ParseReference(imgRef)
62+
require.NoError(t, err)
63+
img, err := imgUtils.GenerateImageFromString(imgRef)
64+
require.NoError(t, err)
65+
66+
payloads, err := fetchSignaturesByTag(imgUtils.GetSHA(types.ToImage(img)), ref, nil)
67+
require.NoError(t, err)
68+
assert.Len(t, payloads, 1)
69+
assert.Equal(t, sigPayload, payloads[0].Payload)
70+
assert.Equal(t, sig1, payloads[0].Base64Signature)
71+
}
72+
73+
func TestFetchTagPayloads_NoSignatures(t *testing.T) {
74+
registryServer, imgRef, err := registryServerWithImage("nginx")
75+
require.NoError(t, err, "setting up registry")
76+
defer registryServer.Close()
77+
78+
ref, err := name.ParseReference(imgRef)
79+
require.NoError(t, err)
80+
img, err := imgUtils.GenerateImageFromString(imgRef)
81+
require.NoError(t, err)
82+
83+
payloads, err := fetchSignaturesByTag(imgUtils.GetSHA(types.ToImage(img)), ref, nil)
84+
require.NoError(t, err)
85+
assert.Empty(t, payloads)
86+
}
87+
88+
func TestFetchReferrerPayloads_NoSignatures(t *testing.T) {
89+
registryServer, imgRef, err := registryServerWithImage("nginx")
90+
require.NoError(t, err, "setting up registry")
91+
defer registryServer.Close()
92+
93+
ref, err := name.ParseReference(imgRef)
94+
require.NoError(t, err)
95+
d, ok := ref.(name.Digest)
96+
require.True(t, ok)
97+
98+
payloads, err := fetchSignaturesByReferrer(context.Background(), d, ref.Context(), nil)
99+
require.NoError(t, err)
100+
assert.Empty(t, payloads)
101+
}

0 commit comments

Comments
 (0)