Skip to content

Commit 23d021b

Browse files
committed
Add vuln scanner and SBOM support, flip to new pipeline engine
- Add vuln.Scanner interface with GrafeasScanner (GCP Container Analysis) and NoopScanner - Implement WriteSBOMs to copy pre-generated SBOMs from staging to production using cosign tag convention - Default UseLegacyPipeline to false, add deprecation warning for legacy path - Wire CLI flags: --use-legacy-pipeline, --require-provenance, --allowed-builders, --allowed-source-repos - Use transport.Error for reliable 404 detection in signature and SBOM copies - Fix New() to use passed options instead of DefaultOptions - Add tests for SBOM tag derivation, vuln scanner, and promoter paths Signed-off-by: Sascha Grunert <sgrunert@redhat.com>
1 parent e60067e commit 23d021b

File tree

10 files changed

+620
-53
lines changed

10 files changed

+620
-53
lines changed

cmd/kpromo/cmd/cip/cip.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,4 +253,32 @@ check. Found vulnerabilities at or above this threshold will result in the
253253
vulnerability check failing [severity levels between 0 and 5; 0 - UNSPECIFIED,
254254
1 - MINIMAL, 2 - LOW, 3 - MEDIUM, 4 - HIGH, 5 - CRITICAL]`,
255255
)
256+
257+
CipCmd.PersistentFlags().BoolVar(
258+
&runOpts.UseLegacyPipeline, //nolint:staticcheck // intentional deprecated flag
259+
"use-legacy-pipeline",
260+
options.DefaultOptions.UseLegacyPipeline, //nolint:staticcheck // intentional deprecated flag
261+
"use the legacy sequential promotion code path instead of the new pipeline engine",
262+
)
263+
264+
CipCmd.PersistentFlags().BoolVar(
265+
&runOpts.RequireProvenance,
266+
"require-provenance",
267+
options.DefaultOptions.RequireProvenance,
268+
"require valid SLSA provenance attestations before promotion",
269+
)
270+
271+
CipCmd.PersistentFlags().StringSliceVar(
272+
&runOpts.AllowedBuilders,
273+
"allowed-builders",
274+
options.DefaultOptions.AllowedBuilders,
275+
"comma-separated list of acceptable builder identities for provenance verification",
276+
)
277+
278+
CipCmd.PersistentFlags().StringSliceVar(
279+
&runOpts.AllowedSourceRepos,
280+
"allowed-source-repos",
281+
options.DefaultOptions.AllowedSourceRepos,
282+
"comma-separated list of acceptable source repository URLs for provenance verification",
283+
)
256284
}

internal/promoter/image/sign.go

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ package imagepromoter
1818

1919
import (
2020
"context"
21+
"errors"
2122
"fmt"
23+
"net/http"
2224
"os"
2325
"strings"
2426

@@ -27,6 +29,7 @@ import (
2729
"github.com/google/go-containerregistry/pkg/crane"
2830
"github.com/google/go-containerregistry/pkg/gcrane"
2931
"github.com/google/go-containerregistry/pkg/name"
32+
"github.com/google/go-containerregistry/pkg/v1/remote/transport"
3033
"github.com/nozzle/throttler"
3134
"github.com/sigstore/sigstore/pkg/tuf"
3235
"github.com/sirupsen/logrus"
@@ -44,6 +47,7 @@ import (
4447
const (
4548
oidcTokenAudience = "sigstore"
4649
signatureTagSuffix = ".sig"
50+
sbomTagSuffix = ".sbom"
4751

4852
TestSigningAccount = "k8s-infra-promoter-test-signer@k8s-cip-test-prod.iam.gserviceaccount.com"
4953
)
@@ -60,7 +64,7 @@ func (di *DefaultPromoterImplementation) ValidateStagingSignatures(
6064
refsToEdges[ref] = edge
6165
}
6266

63-
refs := []string{}
67+
refs := make([]string, 0, len(refsToEdges))
6468
for ref := range refsToEdges {
6569
refs = append(refs, ref)
6670
}
@@ -258,7 +262,8 @@ func (di *DefaultPromoterImplementation) copyAttachedObjects(edge *reg.Promotion
258262
if err := crane.Copy(srcRef.String(), dstRef.String(), craneOpts...); err != nil {
259263
// If the signature layer does not exist it means that the src image
260264
// is not signed, so we catch the error and return nil
261-
if strings.Contains(err.Error(), "MANIFEST_UNKNOWN") {
265+
var terr *transport.Error
266+
if errors.As(err, &terr) && terr.StatusCode == http.StatusNotFound {
262267
logrus.Debugf("Reference %s is not signed, not copying", srcRef.String())
263268
return nil
264269
}
@@ -327,14 +332,71 @@ func (di *DefaultPromoterImplementation) replicateSignatures(
327332
return nil
328333
}
329334

330-
// WriteSBOMs writes SBOMs to each of the newly promoted images and stores
331-
// them along the signatures in the registry.
335+
// WriteSBOMs copies pre-generated SBOMs from the staging registry to each
336+
// production registry for the promoted images. SBOMs are expected to be
337+
// attached in staging (e.g., by the build system) and are identified by
338+
// the cosign SBOM tag convention (sha256-<hash>.sbom).
332339
func (di *DefaultPromoterImplementation) WriteSBOMs(
333-
_ *options.Options, _ *reg.SyncContext, _ map[reg.PromotionEdge]interface{},
340+
_ *options.Options, _ *reg.SyncContext, edges map[reg.PromotionEdge]interface{},
334341
) error {
342+
if len(edges) == 0 {
343+
logrus.Info("No images were promoted. No SBOMs to copy.")
344+
return nil
345+
}
346+
347+
for edge := range edges {
348+
// Skip signature and attestation layers
349+
if strings.HasSuffix(string(edge.DstImageTag.Tag), ".sig") ||
350+
strings.HasSuffix(string(edge.DstImageTag.Tag), ".att") ||
351+
strings.HasSuffix(string(edge.DstImageTag.Tag), ".sbom") ||
352+
edge.DstImageTag.Tag == "" {
353+
continue
354+
}
355+
356+
if err := di.copySBOM(&edge); err != nil {
357+
return fmt.Errorf("copying SBOM for %s: %w", edge.DstReference(), err)
358+
}
359+
}
360+
335361
return nil
336362
}
337363

364+
// copySBOM copies an SBOM from the staging registry to the production registry
365+
// for a single promotion edge. If no SBOM exists in staging, this is not an error.
366+
func (di *DefaultPromoterImplementation) copySBOM(edge *reg.PromotionEdge) error {
367+
sbomTag := digestToSBOMTag(edge.Digest)
368+
srcRefString := fmt.Sprintf(
369+
"%s/%s:%s", edge.SrcRegistry.Name, edge.SrcImageTag.Name, sbomTag,
370+
)
371+
dstRefString := fmt.Sprintf(
372+
"%s/%s:%s", edge.DstRegistry.Name, edge.DstImageTag.Name, sbomTag,
373+
)
374+
375+
craneOpts := []crane.Option{
376+
crane.WithAuthFromKeychain(gcrane.Keychain),
377+
crane.WithUserAgent(image.UserAgent),
378+
crane.WithTransport(di.getSigningTransport()),
379+
}
380+
381+
logrus.Infof("SBOM copy: %s to %s", srcRefString, dstRefString)
382+
if err := crane.Copy(srcRefString, dstRefString, craneOpts...); err != nil {
383+
// If the SBOM does not exist in staging, skip silently
384+
var terr *transport.Error
385+
if errors.As(err, &terr) && terr.StatusCode == http.StatusNotFound {
386+
logrus.Debugf("No SBOM found for %s, skipping", srcRefString)
387+
return nil
388+
}
389+
return fmt.Errorf("copying SBOM %s to %s: %w", srcRefString, dstRefString, err)
390+
}
391+
return nil
392+
}
393+
394+
// digestToSBOMTag takes a digest and infers the tag name where
395+
// its SBOM can be found.
396+
func digestToSBOMTag(dg image.Digest) string {
397+
return strings.ReplaceAll(string(dg), "sha256:", "sha256-") + sbomTagSuffix
398+
}
399+
338400
// GetIdentityToken returns an identity token for the selected service account
339401
// in order for this function to work, an account has to be already logged. This
340402
// can be achieved using the.

internal/promoter/image/sign_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
reg "sigs.k8s.io/promo-tools/v4/internal/legacy/dockerregistry"
2727
"sigs.k8s.io/promo-tools/v4/internal/legacy/dockerregistry/registry"
2828
options "sigs.k8s.io/promo-tools/v4/promoter/image/options"
29+
"sigs.k8s.io/promo-tools/v4/types/image"
2930
)
3031

3132
// TestGetIdentityToken tests the identity token generation logic. By default
@@ -55,6 +56,58 @@ func TestGetIdentityToken(t *testing.T) {
5556
require.NotEmpty(t, tok)
5657
}
5758

59+
func TestDigestToSignatureTag(t *testing.T) {
60+
t.Parallel()
61+
62+
for _, tc := range []struct {
63+
name string
64+
digest image.Digest
65+
want string
66+
}{
67+
{
68+
name: "standard sha256 digest",
69+
digest: "sha256:709e17a9c17018997724ed19afc18dbf576e9af10dfe78c13b34175027916d8f",
70+
want: "sha256-709e17a9c17018997724ed19afc18dbf576e9af10dfe78c13b34175027916d8f.sig",
71+
},
72+
{
73+
name: "bare sha256 prefix",
74+
digest: "sha256:abc",
75+
want: "sha256-abc.sig",
76+
},
77+
} {
78+
t.Run(tc.name, func(t *testing.T) {
79+
t.Parallel()
80+
require.Equal(t, tc.want, digestToSignatureTag(tc.digest))
81+
})
82+
}
83+
}
84+
85+
func TestDigestToSBOMTag(t *testing.T) {
86+
t.Parallel()
87+
88+
for _, tc := range []struct {
89+
name string
90+
digest image.Digest
91+
want string
92+
}{
93+
{
94+
name: "standard sha256 digest",
95+
digest: "sha256:709e17a9c17018997724ed19afc18dbf576e9af10dfe78c13b34175027916d8f",
96+
want: "sha256-709e17a9c17018997724ed19afc18dbf576e9af10dfe78c13b34175027916d8f.sbom",
97+
},
98+
{
99+
name: "bare sha256 prefix",
100+
digest: "sha256:abc",
101+
want: "sha256-abc.sbom",
102+
},
103+
} {
104+
t.Run(tc.name, func(t *testing.T) {
105+
t.Parallel()
106+
require.Equal(t, tc.want, digestToSBOMTag(tc.digest))
107+
})
108+
}
109+
}
110+
58111
func TestTargetIdentity(t *testing.T) {
59112
t.Parallel()
60113

promoter/image/options/options.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,8 @@ type Options struct {
125125
// MaxSignatureOps maximum number of concurrent signature operations
126126
MaxSignatureOps int
127127

128-
// UseLegacyPipeline uses the legacy sequential promotion code path
129-
// instead of the new pipeline engine. Defaults to true during transition.
128+
// Deprecated: UseLegacyPipeline uses the legacy sequential promotion
129+
// code path instead of the new pipeline engine. Defaults to false.
130130
UseLegacyPipeline bool
131131

132132
// RequireProvenance controls whether provenance verification is required
@@ -159,7 +159,7 @@ var DefaultOptions = &Options{
159159
SignCheckIssuerRegexp: "",
160160
MaxSignatureCopies: 50, // Maximum number of concurrent signature copies
161161
MaxSignatureOps: 50, // Maximum number of concurrent signature operations
162-
UseLegacyPipeline: true,
162+
UseLegacyPipeline: false,
163163
}
164164

165165
func (o *Options) Validate() error {

promoter/image/promoter.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ func New(opts *options.Options) *Promoter {
6060
di.SetSigningTransport(signRT)
6161

6262
return &Promoter{
63-
Options: options.DefaultOptions,
63+
Options: opts,
6464
impl: di,
6565
budget: budget,
6666
}
@@ -122,7 +122,7 @@ type promoterImplementation interface {
122122
// PromoteImages is the main method for image promotion.
123123
// It runs by taking all its parameters from a set of options.
124124
func (p *Promoter) PromoteImages(ctx context.Context, opts *options.Options) error {
125-
if opts.UseLegacyPipeline {
125+
if opts.UseLegacyPipeline { //nolint:staticcheck // intentional legacy routing
126126
return p.promoteImagesLegacy(opts)
127127
}
128128
return p.promoteImagesPipeline(ctx, opts)
@@ -259,9 +259,12 @@ func (p *Promoter) promoteImagesPipeline(ctx context.Context, opts *options.Opti
259259
return pipe.Run(ctx)
260260
}
261261

262-
// promoteImagesLegacy is the original sequential promotion code path.
262+
// Deprecated: promoteImagesLegacy is the original sequential promotion code path.
263+
// Use the pipeline-based path (--use-legacy-pipeline=false) instead.
264+
// This will be removed in a future release.
263265
func (p *Promoter) promoteImagesLegacy(opts *options.Options) error {
264-
logrus.Infof("PromoteImages start (legacy pipeline)")
266+
logrus.Warn("Using deprecated legacy promotion pipeline. " +
267+
"Set --use-legacy-pipeline=false to use the new pipeline engine.")
265268
if err := p.impl.ValidateOptions(opts); err != nil {
266269
return fmt.Errorf("validating options: %w", err)
267270
}

0 commit comments

Comments
 (0)