Merge pull request #1250 from fluxcd/cosign-identity-matching
cosign: allow identity matching for keyless verification
This commit is contained in:
commit
a8a81965c7
|
@ -190,6 +190,28 @@ type OCIRepositoryVerification struct {
|
|||
// trusted public keys.
|
||||
// +optional
|
||||
SecretRef *meta.LocalObjectReference `json:"secretRef,omitempty"`
|
||||
|
||||
// MatchOIDCIdentity specifies the identity matching criteria to use
|
||||
// while verifying an OCI artifact which was signed using Cosign keyless
|
||||
// signing. The artifact's identity is deemed to be verified if any of the
|
||||
// specified matchers match against the identity.
|
||||
// +optional
|
||||
MatchOIDCIdentity []OIDCIdentityMatch `json:"matchOIDCIdentity,omitempty"`
|
||||
}
|
||||
|
||||
// OIDCIdentityMatch specifies options for verifying the certificate identity,
|
||||
// i.e. the issuer and the subject of the certificate.
|
||||
type OIDCIdentityMatch struct {
|
||||
// Issuer specifies the regex pattern to match against to verify
|
||||
// the OIDC issuer in the Fulcio certificate. The pattern must be a
|
||||
// valid Go regular expression.
|
||||
// +required
|
||||
Issuer string `json:"issuer"`
|
||||
// Subject specifies the regex pattern to match against to verify
|
||||
// the identity subject in the Fulcio certificate. The pattern must
|
||||
// be a valid Go regular expression.
|
||||
// +required
|
||||
Subject string `json:"subject"`
|
||||
}
|
||||
|
||||
// OCIRepositoryStatus defines the observed state of OCIRepository
|
||||
|
|
|
@ -834,6 +834,11 @@ func (in *OCIRepositoryVerification) DeepCopyInto(out *OCIRepositoryVerification
|
|||
*out = new(meta.LocalObjectReference)
|
||||
**out = **in
|
||||
}
|
||||
if in.MatchOIDCIdentity != nil {
|
||||
in, out := &in.MatchOIDCIdentity, &out.MatchOIDCIdentity
|
||||
*out = make([]OIDCIdentityMatch, len(*in))
|
||||
copy(*out, *in)
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OCIRepositoryVerification.
|
||||
|
@ -845,3 +850,18 @@ func (in *OCIRepositoryVerification) DeepCopy() *OCIRepositoryVerification {
|
|||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *OIDCIdentityMatch) DeepCopyInto(out *OIDCIdentityMatch) {
|
||||
*out = *in
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OIDCIdentityMatch.
|
||||
func (in *OIDCIdentityMatch) DeepCopy() *OIDCIdentityMatch {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(OIDCIdentityMatch)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
|
|
@ -411,6 +411,32 @@ spec:
|
|||
Chart dependencies, which are not bundled in the umbrella chart
|
||||
artifact, are not verified.
|
||||
properties:
|
||||
matchOIDCIdentity:
|
||||
description: MatchOIDCIdentity specifies the identity matching
|
||||
criteria to use while verifying an OCI artifact which was signed
|
||||
using Cosign keyless signing. The artifact's identity is deemed
|
||||
to be verified if any of the specified matchers match against
|
||||
the identity.
|
||||
items:
|
||||
description: OIDCIdentityMatch specifies options for verifying
|
||||
the certificate identity, i.e. the issuer and the subject
|
||||
of the certificate.
|
||||
properties:
|
||||
issuer:
|
||||
description: Issuer specifies the regex pattern to match
|
||||
against to verify the OIDC issuer in the Fulcio certificate.
|
||||
The pattern must be a valid Go regular expression.
|
||||
type: string
|
||||
subject:
|
||||
description: Subject specifies the regex pattern to match
|
||||
against to verify the identity subject in the Fulcio certificate.
|
||||
The pattern must be a valid Go regular expression.
|
||||
type: string
|
||||
required:
|
||||
- issuer
|
||||
- subject
|
||||
type: object
|
||||
type: array
|
||||
provider:
|
||||
default: cosign
|
||||
description: Provider specifies the technology used to sign the
|
||||
|
|
|
@ -164,6 +164,32 @@ spec:
|
|||
public keys used to verify the signature and specifies which provider
|
||||
to use to check whether OCI image is authentic.
|
||||
properties:
|
||||
matchOIDCIdentity:
|
||||
description: MatchOIDCIdentity specifies the identity matching
|
||||
criteria to use while verifying an OCI artifact which was signed
|
||||
using Cosign keyless signing. The artifact's identity is deemed
|
||||
to be verified if any of the specified matchers match against
|
||||
the identity.
|
||||
items:
|
||||
description: OIDCIdentityMatch specifies options for verifying
|
||||
the certificate identity, i.e. the issuer and the subject
|
||||
of the certificate.
|
||||
properties:
|
||||
issuer:
|
||||
description: Issuer specifies the regex pattern to match
|
||||
against to verify the OIDC issuer in the Fulcio certificate.
|
||||
The pattern must be a valid Go regular expression.
|
||||
type: string
|
||||
subject:
|
||||
description: Subject specifies the regex pattern to match
|
||||
against to verify the identity subject in the Fulcio certificate.
|
||||
The pattern must be a valid Go regular expression.
|
||||
type: string
|
||||
required:
|
||||
- issuer
|
||||
- subject
|
||||
type: object
|
||||
type: array
|
||||
provider:
|
||||
default: cosign
|
||||
description: Provider specifies the technology used to sign the
|
||||
|
|
|
@ -3319,6 +3319,71 @@ github.com/fluxcd/pkg/apis/meta.LocalObjectReference
|
|||
trusted public keys.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>matchOIDCIdentity</code><br>
|
||||
<em>
|
||||
<a href="#source.toolkit.fluxcd.io/v1beta2.OIDCIdentityMatch">
|
||||
[]OIDCIdentityMatch
|
||||
</a>
|
||||
</em>
|
||||
</td>
|
||||
<td>
|
||||
<em>(Optional)</em>
|
||||
<p>MatchOIDCIdentity specifies the identity matching criteria to use
|
||||
while verifying an OCI artifact which was signed using Cosign keyless
|
||||
signing. The artifact’s identity is deemed to be verified if any of the
|
||||
specified matchers match against the identity.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<h3 id="source.toolkit.fluxcd.io/v1beta2.OIDCIdentityMatch">OIDCIdentityMatch
|
||||
</h3>
|
||||
<p>
|
||||
(<em>Appears on:</em>
|
||||
<a href="#source.toolkit.fluxcd.io/v1beta2.OCIRepositoryVerification">OCIRepositoryVerification</a>)
|
||||
</p>
|
||||
<p>OIDCIdentityMatch specifies options for verifying the certificate identity,
|
||||
i.e. the issuer and the subject of the certificate.</p>
|
||||
<div class="md-typeset__scrollwrap">
|
||||
<div class="md-typeset__table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Field</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<code>issuer</code><br>
|
||||
<em>
|
||||
string
|
||||
</em>
|
||||
</td>
|
||||
<td>
|
||||
<p>Issuer specifies the regex pattern to match against to verify
|
||||
the OIDC issuer in the Fulcio certificate. The pattern must be a
|
||||
valid Go regular expression.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>subject</code><br>
|
||||
<em>
|
||||
string
|
||||
</em>
|
||||
</td>
|
||||
<td>
|
||||
<p>Subject specifies the regex pattern to match against to verify
|
||||
the identity subject in the Fulcio certificate. The pattern must
|
||||
be a valid Go regular expression.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
|
|
@ -253,11 +253,13 @@ For practical information, see
|
|||
**Note:** This feature is available only for Helm charts fetched from an OCI Registry.
|
||||
|
||||
`.spec.verify` is an optional field to enable the verification of [Cosign](https://github.com/sigstore/cosign)
|
||||
signatures. The field offers two subfields:
|
||||
signatures. The field offers three subfields:
|
||||
|
||||
- `.provider`, to specify the verification provider. Only supports `cosign` at present.
|
||||
- `.secretRef.name`, to specify a reference to a Secret in the same namespace as
|
||||
the HelmChart, containing the Cosign public keys of trusted authors.
|
||||
- `.matchOIDCIdentity`, to specify a list of OIDC identity matchers. Please see
|
||||
[Keyless verification](#keyless-verification) for more details.
|
||||
|
||||
```yaml
|
||||
---
|
||||
|
@ -307,6 +309,18 @@ For publicly available HelmCharts, which are signed using the
|
|||
[Cosign Keyless](https://github.com/sigstore/cosign/blob/main/KEYLESS.md) procedure,
|
||||
you can enable the verification by omitting the `.verify.secretRef` field.
|
||||
|
||||
To verify the identity's subject and the OIDC issuer present in the Fulcio
|
||||
certificate, you can specify a list of OIDC identity matchers using
|
||||
`.spec.verify.matchOIDCIdentity`. The matcher provides two required fields:
|
||||
|
||||
- `.issuer`, to specify a regexp that matches against the OIDC issuer.
|
||||
- `.subject`, to specify a regexp that matches against the subject identity in
|
||||
the certificate.
|
||||
Both values should follow the [Go regular expression syntax](https://golang.org/s/re2syntax).
|
||||
|
||||
The matchers are evaluated in an OR fashion, i.e. the identity is deemed to be
|
||||
verified if any one matcher successfully matches against the identity.
|
||||
|
||||
Example of verifying HelmCharts signed by the
|
||||
[Cosign GitHub Action](https://github.com/sigstore/cosign-installer) with GitHub OIDC Token:
|
||||
|
||||
|
@ -325,6 +339,9 @@ spec:
|
|||
version: ">=6.1.6"
|
||||
verify:
|
||||
provider: cosign
|
||||
matchOIDCIdentity:
|
||||
- issuer: "^https://token.actions.githubusercontent.com$"
|
||||
subject: "^https://github.com/stefanprodan/podinfo.*$"
|
||||
```
|
||||
|
||||
```yaml
|
||||
|
|
|
@ -501,11 +501,13 @@ for more information.
|
|||
### Verification
|
||||
|
||||
`.spec.verify` is an optional field to enable the verification of [Cosign](https://github.com/sigstore/cosign)
|
||||
signatures. The field offers two subfields:
|
||||
signatures. The field offers three subfields:
|
||||
|
||||
- `.provider`, to specify the verification provider. Only supports `cosign` at present.
|
||||
- `.secretRef.name`, to specify a reference to a Secret in the same namespace as
|
||||
the OCIRepository, containing the Cosign public keys of trusted authors.
|
||||
- `.matchOIDCIdentity`, to specify a list of OIDC identity matchers. Please see
|
||||
[Keyless verification](#keyless-verification) for more details.
|
||||
|
||||
```yaml
|
||||
---
|
||||
|
@ -555,6 +557,18 @@ For publicly available OCI artifacts, which are signed using the
|
|||
[Cosign Keyless](https://github.com/sigstore/cosign/blob/main/KEYLESS.md) procedure,
|
||||
you can enable the verification by omitting the `.verify.secretRef` field.
|
||||
|
||||
To verify the identity's subject and the OIDC issuer present in the Fulcio
|
||||
certificate, you can specify a list of OIDC identity matchers using
|
||||
`.spec.verify.matchOIDCIdentity`. The matcher provides two required fields:
|
||||
|
||||
- `.issuer`, to specify a regexp that matches against the OIDC issuer.
|
||||
- `.subject`, to specify a regexp that matches against the subject identity in
|
||||
the certificate.
|
||||
Both values should follow the [Go regular expression syntax](https://golang.org/s/re2syntax).
|
||||
|
||||
The matchers are evaluated in an OR fashion, i.e. the identity is deemed to be
|
||||
verified if any one matcher successfully matches against the identity.
|
||||
|
||||
Example of verifying artifacts signed by the
|
||||
[Cosign GitHub Action](https://github.com/sigstore/cosign-installer) with GitHub OIDC Token:
|
||||
|
||||
|
@ -568,6 +582,9 @@ spec:
|
|||
url: oci://ghcr.io/stefanprodan/manifests/podinfo
|
||||
verify:
|
||||
provider: cosign
|
||||
matchOIDCIdentity:
|
||||
- issuer: "^https://token.actions.githubusercontent.com$"
|
||||
subject: "^https://github.com/stefanprodan/podinfo.*$"
|
||||
```
|
||||
|
||||
The controller verifies the signatures using the Fulcio root CA and the Rekor
|
||||
|
|
|
@ -29,6 +29,7 @@ import (
|
|||
|
||||
"github.com/google/go-containerregistry/pkg/v1/remote"
|
||||
"github.com/opencontainers/go-digest"
|
||||
"github.com/sigstore/cosign/v2/pkg/cosign"
|
||||
helmgetter "helm.sh/helm/v3/pkg/getter"
|
||||
helmreg "helm.sh/helm/v3/pkg/registry"
|
||||
helmrepo "helm.sh/helm/v3/pkg/repo"
|
||||
|
@ -1338,6 +1339,15 @@ func (r *HelmChartReconciler) makeVerifiers(ctx context.Context, obj *helmv1.Hel
|
|||
}
|
||||
|
||||
// if no secret is provided, add a keyless verifier
|
||||
var identities []cosign.Identity
|
||||
for _, match := range obj.Spec.Verify.MatchOIDCIdentity {
|
||||
identities = append(identities, cosign.Identity{
|
||||
IssuerRegExp: match.Issuer,
|
||||
SubjectRegExp: match.Subject,
|
||||
})
|
||||
}
|
||||
defaultCosignOciOpts = append(defaultCosignOciOpts, soci.WithIdentities(identities))
|
||||
|
||||
verifier, err := soci.NewCosignVerifier(ctx, defaultCosignOciOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
|
@ -2533,6 +2533,181 @@ func TestHelmChartReconciler_reconcileSourceFromOCI_authStrategy(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestHelmChartRepository_reconcileSource_verifyOCISourceSignature_keyless(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
version string
|
||||
want sreconcile.Result
|
||||
wantErr bool
|
||||
beforeFunc func(obj *helmv1.HelmChart)
|
||||
assertConditions []metav1.Condition
|
||||
revision string
|
||||
}{
|
||||
{
|
||||
name: "signed image with no identity matching specified should pass verification",
|
||||
version: "6.5.1",
|
||||
want: sreconcile.ResultSuccess,
|
||||
assertConditions: []metav1.Condition{
|
||||
*conditions.TrueCondition(sourcev1.SourceVerifiedCondition, meta.SucceededReason, "verified signature of version <version>"),
|
||||
*conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: pulled '<name>' chart with version '<version>'"),
|
||||
*conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: pulled '<name>' chart with version '<version>'"),
|
||||
},
|
||||
revision: "6.5.1@sha256:af589b918022cd8d85a4543312d28170c2e894ccab8484050ff4bdefdde30b4e",
|
||||
},
|
||||
{
|
||||
name: "signed image with correct subject and issuer should pass verification",
|
||||
version: "6.5.1",
|
||||
want: sreconcile.ResultSuccess,
|
||||
beforeFunc: func(obj *helmv1.HelmChart) {
|
||||
obj.Spec.Verify.MatchOIDCIdentity = []helmv1.OIDCIdentityMatch{
|
||||
{
|
||||
|
||||
Subject: "^https://github.com/stefanprodan/podinfo.*$",
|
||||
Issuer: "^https://token.actions.githubusercontent.com$",
|
||||
},
|
||||
}
|
||||
},
|
||||
assertConditions: []metav1.Condition{
|
||||
*conditions.TrueCondition(sourcev1.SourceVerifiedCondition, meta.SucceededReason, "verified signature of version <version>"),
|
||||
*conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: pulled '<name>' chart with version '<version>'"),
|
||||
*conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: pulled '<name>' chart with version '<version>'"),
|
||||
},
|
||||
revision: "6.5.1@sha256:af589b918022cd8d85a4543312d28170c2e894ccab8484050ff4bdefdde30b4e",
|
||||
},
|
||||
{
|
||||
name: "signed image with incorrect and correct identity matchers should pass verification",
|
||||
version: "6.5.1",
|
||||
want: sreconcile.ResultSuccess,
|
||||
beforeFunc: func(obj *helmv1.HelmChart) {
|
||||
obj.Spec.Verify.MatchOIDCIdentity = []helmv1.OIDCIdentityMatch{
|
||||
{
|
||||
Subject: "intruder",
|
||||
Issuer: "^https://honeypot.com$",
|
||||
},
|
||||
{
|
||||
|
||||
Subject: "^https://github.com/stefanprodan/podinfo.*$",
|
||||
Issuer: "^https://token.actions.githubusercontent.com$",
|
||||
},
|
||||
}
|
||||
},
|
||||
assertConditions: []metav1.Condition{
|
||||
*conditions.TrueCondition(sourcev1.SourceVerifiedCondition, meta.SucceededReason, "verified signature of version <version>"),
|
||||
*conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: pulled '<name>' chart with version '<version>'"),
|
||||
*conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: pulled '<name>' chart with version '<version>'"),
|
||||
},
|
||||
revision: "6.5.1@sha256:af589b918022cd8d85a4543312d28170c2e894ccab8484050ff4bdefdde30b4e",
|
||||
},
|
||||
{
|
||||
name: "signed image with incorrect subject and issuer should not pass verification",
|
||||
version: "6.5.1",
|
||||
wantErr: true,
|
||||
want: sreconcile.ResultEmpty,
|
||||
beforeFunc: func(obj *helmv1.HelmChart) {
|
||||
obj.Spec.Verify.MatchOIDCIdentity = []helmv1.OIDCIdentityMatch{
|
||||
{
|
||||
Subject: "intruder",
|
||||
Issuer: "^https://honeypot.com$",
|
||||
},
|
||||
}
|
||||
},
|
||||
assertConditions: []metav1.Condition{
|
||||
*conditions.TrueCondition(sourcev1.BuildFailedCondition, "ChartVerificationError", "chart verification error: failed to verify <url>: no matching signatures: none of the expected identities matched what was in the certificate"),
|
||||
*conditions.FalseCondition(sourcev1.SourceVerifiedCondition, sourcev1.VerificationError, "chart verification error: failed to verify <url>: no matching signatures"),
|
||||
},
|
||||
revision: "6.5.1@sha256:af589b918022cd8d85a4543312d28170c2e894ccab8484050ff4bdefdde30b4e",
|
||||
},
|
||||
{
|
||||
name: "unsigned image should not pass verification",
|
||||
version: "6.1.0",
|
||||
wantErr: true,
|
||||
want: sreconcile.ResultEmpty,
|
||||
assertConditions: []metav1.Condition{
|
||||
*conditions.TrueCondition(sourcev1.BuildFailedCondition, "ChartVerificationError", "chart verification error: failed to verify <url>: no matching signatures"),
|
||||
*conditions.FalseCondition(sourcev1.SourceVerifiedCondition, sourcev1.VerificationError, "chart verification error: failed to verify <url>: no matching signatures"),
|
||||
},
|
||||
revision: "6.1.0@sha256:642383f56ccb529e3f658d40312d01b58d9bc6caeef653da43e58d1afe88982a",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
g := NewWithT(t)
|
||||
|
||||
clientBuilder := fakeclient.NewClientBuilder()
|
||||
|
||||
repository := &helmv1.HelmRepository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
GenerateName: "helmrepository-",
|
||||
},
|
||||
Spec: helmv1.HelmRepositorySpec{
|
||||
URL: "oci://ghcr.io/stefanprodan/charts",
|
||||
Timeout: &metav1.Duration{Duration: timeout},
|
||||
Provider: helmv1.GenericOCIProvider,
|
||||
Type: helmv1.HelmRepositoryTypeOCI,
|
||||
},
|
||||
}
|
||||
clientBuilder.WithObjects(repository)
|
||||
|
||||
r := &HelmChartReconciler{
|
||||
Client: clientBuilder.Build(),
|
||||
EventRecorder: record.NewFakeRecorder(32),
|
||||
Getters: testGetters,
|
||||
Storage: testStorage,
|
||||
RegistryClientGenerator: registry.ClientGenerator,
|
||||
patchOptions: getPatchOptions(helmChartReadyCondition.Owned, "sc"),
|
||||
}
|
||||
|
||||
obj := &helmv1.HelmChart{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
GenerateName: "helmchart-",
|
||||
},
|
||||
Spec: helmv1.HelmChartSpec{
|
||||
SourceRef: helmv1.LocalHelmChartSourceReference{
|
||||
Kind: helmv1.HelmRepositoryKind,
|
||||
Name: repository.Name,
|
||||
},
|
||||
Version: tt.version,
|
||||
Chart: "podinfo",
|
||||
Verify: &helmv1.OCIRepositoryVerification{
|
||||
Provider: "cosign",
|
||||
},
|
||||
},
|
||||
}
|
||||
chartUrl := fmt.Sprintf("%s/%s:%s", repository.Spec.URL, obj.Spec.Chart, obj.Spec.Version)
|
||||
|
||||
assertConditions := tt.assertConditions
|
||||
for k := range assertConditions {
|
||||
assertConditions[k].Message = strings.ReplaceAll(assertConditions[k].Message, "<name>", obj.Spec.Chart)
|
||||
assertConditions[k].Message = strings.ReplaceAll(assertConditions[k].Message, "<version>", obj.Spec.Version)
|
||||
assertConditions[k].Message = strings.ReplaceAll(assertConditions[k].Message, "<url>", chartUrl)
|
||||
assertConditions[k].Message = strings.ReplaceAll(assertConditions[k].Message, "<provider>", "cosign")
|
||||
}
|
||||
|
||||
if tt.beforeFunc != nil {
|
||||
tt.beforeFunc(obj)
|
||||
}
|
||||
|
||||
g.Expect(r.Client.Create(ctx, obj)).ToNot(HaveOccurred())
|
||||
defer func() {
|
||||
g.Expect(r.Client.Delete(ctx, obj)).ToNot(HaveOccurred())
|
||||
}()
|
||||
|
||||
sp := patch.NewSerialPatcher(obj, r.Client)
|
||||
|
||||
var b chart.Build
|
||||
got, err := r.reconcileSource(ctx, sp, obj, &b)
|
||||
if tt.wantErr {
|
||||
g.Expect(err).To(HaveOccurred())
|
||||
} else {
|
||||
g.Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
g.Expect(got).To(Equal(tt.want))
|
||||
g.Expect(obj.Status.Conditions).To(conditions.MatchConditions(tt.assertConditions))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelmChartReconciler_reconcileSourceFromOCI_verifySignature(t *testing.T) {
|
||||
g := NewWithT(t)
|
||||
|
||||
|
|
|
@ -35,6 +35,7 @@ import (
|
|||
"github.com/google/go-containerregistry/pkg/name"
|
||||
gcrv1 "github.com/google/go-containerregistry/pkg/v1"
|
||||
"github.com/google/go-containerregistry/pkg/v1/remote"
|
||||
"github.com/sigstore/cosign/v2/pkg/cosign"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
|
@ -663,6 +664,16 @@ func (r *OCIRepositoryReconciler) verifySignature(ctx context.Context, obj *ociv
|
|||
|
||||
// if no secret is provided, try keyless verification
|
||||
ctrl.LoggerFrom(ctx).Info("no secret reference is provided, trying to verify the image using keyless method")
|
||||
|
||||
var identities []cosign.Identity
|
||||
for _, match := range obj.Spec.Verify.MatchOIDCIdentity {
|
||||
identities = append(identities, cosign.Identity{
|
||||
IssuerRegExp: match.Issuer,
|
||||
SubjectRegExp: match.Subject,
|
||||
})
|
||||
}
|
||||
defaultCosignOciOpts = append(defaultCosignOciOpts, soci.WithIdentities(identities))
|
||||
|
||||
verifier, err := soci.NewCosignVerifier(ctxTimeout, defaultCosignOciOpts...)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
|
@ -1435,6 +1435,181 @@ func TestOCIRepository_reconcileSource_verifyOCISourceSignature(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestOCIRepository_reconcileSource_verifyOCISourceSignature_keyless(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
reference *ociv1.OCIRepositoryRef
|
||||
want sreconcile.Result
|
||||
wantErr bool
|
||||
wantErrMsg string
|
||||
beforeFunc func(obj *ociv1.OCIRepository)
|
||||
assertConditions []metav1.Condition
|
||||
revision string
|
||||
}{
|
||||
{
|
||||
name: "signed image with no identity matching specified should pass verification",
|
||||
reference: &ociv1.OCIRepositoryRef{
|
||||
Tag: "6.5.1",
|
||||
},
|
||||
want: sreconcile.ResultSuccess,
|
||||
assertConditions: []metav1.Condition{
|
||||
*conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: new revision '<revision>' for '<url>'"),
|
||||
*conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new revision '<revision>' for '<url>'"),
|
||||
*conditions.TrueCondition(sourcev1.SourceVerifiedCondition, meta.SucceededReason, "verified signature of revision <revision>"),
|
||||
},
|
||||
revision: "6.5.1@sha256:049fff8f9c92abba8615c6c3dcf9d10d30082213f6fe86c9305257f806c31e31",
|
||||
},
|
||||
{
|
||||
name: "signed image with correct subject and issuer should pass verification",
|
||||
reference: &ociv1.OCIRepositoryRef{
|
||||
Tag: "6.5.1",
|
||||
},
|
||||
want: sreconcile.ResultSuccess,
|
||||
beforeFunc: func(obj *ociv1.OCIRepository) {
|
||||
obj.Spec.Verify.MatchOIDCIdentity = []ociv1.OIDCIdentityMatch{
|
||||
{
|
||||
|
||||
Subject: "^https://github.com/stefanprodan/podinfo.*$",
|
||||
Issuer: "^https://token.actions.githubusercontent.com$",
|
||||
},
|
||||
}
|
||||
},
|
||||
assertConditions: []metav1.Condition{
|
||||
*conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: new revision '<revision>' for '<url>'"),
|
||||
*conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new revision '<revision>' for '<url>'"),
|
||||
*conditions.TrueCondition(sourcev1.SourceVerifiedCondition, meta.SucceededReason, "verified signature of revision <revision>"),
|
||||
},
|
||||
revision: "6.5.1@sha256:049fff8f9c92abba8615c6c3dcf9d10d30082213f6fe86c9305257f806c31e31",
|
||||
},
|
||||
{
|
||||
name: "signed image with both incorrect and correct identity matchers should pass verification",
|
||||
reference: &ociv1.OCIRepositoryRef{
|
||||
Tag: "6.5.1",
|
||||
},
|
||||
want: sreconcile.ResultSuccess,
|
||||
beforeFunc: func(obj *ociv1.OCIRepository) {
|
||||
obj.Spec.Verify.MatchOIDCIdentity = []ociv1.OIDCIdentityMatch{
|
||||
{
|
||||
Subject: "intruder",
|
||||
Issuer: "^https://honeypot.com$",
|
||||
},
|
||||
{
|
||||
|
||||
Subject: "^https://github.com/stefanprodan/podinfo.*$",
|
||||
Issuer: "^https://token.actions.githubusercontent.com$",
|
||||
},
|
||||
}
|
||||
},
|
||||
assertConditions: []metav1.Condition{
|
||||
*conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: new revision '<revision>' for '<url>'"),
|
||||
*conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new revision '<revision>' for '<url>'"),
|
||||
*conditions.TrueCondition(sourcev1.SourceVerifiedCondition, meta.SucceededReason, "verified signature of revision <revision>"),
|
||||
},
|
||||
revision: "6.5.1@sha256:049fff8f9c92abba8615c6c3dcf9d10d30082213f6fe86c9305257f806c31e31",
|
||||
},
|
||||
{
|
||||
name: "signed image with incorrect subject and issuer should not pass verification",
|
||||
reference: &ociv1.OCIRepositoryRef{
|
||||
Tag: "6.5.1",
|
||||
},
|
||||
wantErr: true,
|
||||
want: sreconcile.ResultEmpty,
|
||||
beforeFunc: func(obj *ociv1.OCIRepository) {
|
||||
obj.Spec.Verify.MatchOIDCIdentity = []ociv1.OIDCIdentityMatch{
|
||||
{
|
||||
Subject: "intruder",
|
||||
Issuer: "^https://honeypot.com$",
|
||||
},
|
||||
}
|
||||
},
|
||||
assertConditions: []metav1.Condition{
|
||||
*conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: new revision '<revision>' for '<url>'"),
|
||||
*conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new revision '<revision>' for '<url>'"),
|
||||
*conditions.FalseCondition(sourcev1.SourceVerifiedCondition, sourcev1.VerificationError, "failed to verify the signature using provider '<provider> keyless': no matching signatures: none of the expected identities matched what was in the certificate"),
|
||||
},
|
||||
revision: "6.5.1@sha256:049fff8f9c92abba8615c6c3dcf9d10d30082213f6fe86c9305257f806c31e31",
|
||||
},
|
||||
{
|
||||
name: "unsigned image should not pass verification",
|
||||
reference: &ociv1.OCIRepositoryRef{
|
||||
Tag: "6.1.0",
|
||||
},
|
||||
wantErr: true,
|
||||
want: sreconcile.ResultEmpty,
|
||||
assertConditions: []metav1.Condition{
|
||||
*conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: new revision '<revision>' for '<url>'"),
|
||||
*conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new revision '<revision>' for '<url>'"),
|
||||
*conditions.FalseCondition(sourcev1.SourceVerifiedCondition, sourcev1.VerificationError, "failed to verify the signature using provider '<provider> keyless': no matching signatures"),
|
||||
},
|
||||
revision: "6.1.0@sha256:3816fe9636a297f0c934b1fa0f46fe4c068920375536ac2803604adfb4c55894",
|
||||
},
|
||||
}
|
||||
|
||||
clientBuilder := fakeclient.NewClientBuilder().
|
||||
WithScheme(testEnv.GetScheme()).
|
||||
WithStatusSubresource(&ociv1.OCIRepository{})
|
||||
|
||||
r := &OCIRepositoryReconciler{
|
||||
Client: clientBuilder.Build(),
|
||||
EventRecorder: record.NewFakeRecorder(32),
|
||||
Storage: testStorage,
|
||||
patchOptions: getPatchOptions(ociRepositoryReadyCondition.Owned, "sc"),
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
g := NewWithT(t)
|
||||
|
||||
obj := &ociv1.OCIRepository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
GenerateName: "verify-oci-source-signature-",
|
||||
Generation: 1,
|
||||
},
|
||||
Spec: ociv1.OCIRepositorySpec{
|
||||
URL: "oci://ghcr.io/stefanprodan/manifests/podinfo",
|
||||
Verify: &ociv1.OCIRepositoryVerification{
|
||||
Provider: "cosign",
|
||||
},
|
||||
Interval: metav1.Duration{Duration: interval},
|
||||
Timeout: &metav1.Duration{Duration: timeout},
|
||||
Reference: tt.reference,
|
||||
},
|
||||
}
|
||||
url := strings.TrimPrefix(obj.Spec.URL, "oci://") + ":" + tt.reference.Tag
|
||||
|
||||
assertConditions := tt.assertConditions
|
||||
for k := range assertConditions {
|
||||
assertConditions[k].Message = strings.ReplaceAll(assertConditions[k].Message, "<revision>", tt.revision)
|
||||
assertConditions[k].Message = strings.ReplaceAll(assertConditions[k].Message, "<url>", url)
|
||||
assertConditions[k].Message = strings.ReplaceAll(assertConditions[k].Message, "<provider>", "cosign")
|
||||
}
|
||||
|
||||
if tt.beforeFunc != nil {
|
||||
tt.beforeFunc(obj)
|
||||
}
|
||||
|
||||
g.Expect(r.Client.Create(ctx, obj)).ToNot(HaveOccurred())
|
||||
defer func() {
|
||||
g.Expect(r.Client.Delete(ctx, obj)).ToNot(HaveOccurred())
|
||||
}()
|
||||
|
||||
sp := patch.NewSerialPatcher(obj, r.Client)
|
||||
|
||||
artifact := &sourcev1.Artifact{}
|
||||
got, err := r.reconcileSource(ctx, sp, obj, artifact, t.TempDir())
|
||||
if tt.wantErr {
|
||||
g.Expect(err).To(HaveOccurred())
|
||||
tt.wantErrMsg = strings.ReplaceAll(tt.wantErrMsg, "<url>", url)
|
||||
g.Expect(err.Error()).To(ContainSubstring(tt.wantErrMsg))
|
||||
} else {
|
||||
g.Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
g.Expect(got).To(Equal(tt.want))
|
||||
g.Expect(obj.Status.Conditions).To(conditions.MatchConditions(tt.assertConditions))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOCIRepository_reconcileSource_noop(t *testing.T) {
|
||||
g := NewWithT(t)
|
||||
|
||||
|
|
|
@ -40,8 +40,9 @@ type Verifier interface {
|
|||
|
||||
// options is a struct that holds options for verifier.
|
||||
type options struct {
|
||||
PublicKey []byte
|
||||
ROpt []remote.Option
|
||||
PublicKey []byte
|
||||
ROpt []remote.Option
|
||||
Identities []cosign.Identity
|
||||
}
|
||||
|
||||
// Options is a function that configures the options applied to a Verifier.
|
||||
|
@ -62,6 +63,14 @@ func WithRemoteOptions(opts ...remote.Option) Options {
|
|||
}
|
||||
}
|
||||
|
||||
// WithIdentities specifies the identity matchers that have to be met
|
||||
// for the signature to be deemed valid.
|
||||
func WithIdentities(identities []cosign.Identity) Options {
|
||||
return func(opts *options) {
|
||||
opts.Identities = identities
|
||||
}
|
||||
}
|
||||
|
||||
// CosignVerifier is a struct which is responsible for executing verification logic.
|
||||
type CosignVerifier struct {
|
||||
opts *cosign.CheckOpts
|
||||
|
@ -82,6 +91,7 @@ func NewCosignVerifier(ctx context.Context, opts ...Options) (*CosignVerifier, e
|
|||
return nil, err
|
||||
}
|
||||
|
||||
checkOpts.Identities = o.Identities
|
||||
if o.ROpt != nil {
|
||||
co = append(co, ociremote.WithRemoteOptions(o.ROpt...))
|
||||
}
|
||||
|
@ -141,17 +151,7 @@ func NewCosignVerifier(ctx context.Context, opts ...Options) (*CosignVerifier, e
|
|||
|
||||
// VerifyImageSignatures verify the authenticity of the given ref OCI image.
|
||||
func (v *CosignVerifier) VerifyImageSignatures(ctx context.Context, ref name.Reference) ([]oci.Signature, bool, error) {
|
||||
opts := v.opts
|
||||
|
||||
// TODO: expose the match conditions in the CRD
|
||||
opts.Identities = []cosign.Identity{
|
||||
{
|
||||
IssuerRegExp: ".*",
|
||||
SubjectRegExp: ".*",
|
||||
},
|
||||
}
|
||||
|
||||
return cosign.VerifyImageSignatures(ctx, ref, opts)
|
||||
return cosign.VerifyImageSignatures(ctx, ref, v.opts)
|
||||
}
|
||||
|
||||
// Verify verifies the authenticity of the given ref OCI image.
|
||||
|
|
|
@ -23,6 +23,7 @@ import (
|
|||
|
||||
"github.com/google/go-containerregistry/pkg/authn"
|
||||
"github.com/google/go-containerregistry/pkg/v1/remote"
|
||||
"github.com/sigstore/cosign/v2/pkg/cosign"
|
||||
)
|
||||
|
||||
func TestOptions(t *testing.T) {
|
||||
|
@ -75,6 +76,30 @@ func TestOptions(t *testing.T) {
|
|||
remote.WithTransport(http.DefaultTransport),
|
||||
},
|
||||
},
|
||||
}, {
|
||||
name: "identities option",
|
||||
opts: []Options{WithIdentities([]cosign.Identity{
|
||||
{
|
||||
SubjectRegExp: "test-user",
|
||||
IssuerRegExp: "^https://token.actions.githubusercontent.com$",
|
||||
},
|
||||
{
|
||||
SubjectRegExp: "dev-user",
|
||||
IssuerRegExp: "^https://accounts.google.com$",
|
||||
},
|
||||
})},
|
||||
want: &options{
|
||||
Identities: []cosign.Identity{
|
||||
{
|
||||
SubjectRegExp: "test-user",
|
||||
IssuerRegExp: "^https://token.actions.githubusercontent.com$",
|
||||
},
|
||||
{
|
||||
SubjectRegExp: "dev-user",
|
||||
IssuerRegExp: "^https://accounts.google.com$",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue