Skip to content

Commit

Permalink
Feature: Create an interface for downstream CIP integrations.
Browse files Browse the repository at this point in the history
🎁 This change factors a new small library `./pkg/policy` which is intended to streamline incorporating CIP validation into downstream tooling.

For a (much) more verbose explanation see [here](ko-build/ko#356 (comment)), but the general idea behind this is to allow CIP's to gate consumption of images in other contexts, for example the base
images in build tools such as `ko` or `kaniko`.  The idea is to enable the tool providers to bake-in default policies for default base images, and optionally expose configuration to let users write policies to authorize base images prior
to consumption.

For example, I might write the following `.ko.yaml`:
```yaml
verification:
  noMatchPolicy: deny
  policies:
  - data: |
      # inline policy
  - url: https://github.com/foo/bar/blobs/main/POLICY.yaml
```

With this library, it is likely <100 LoC to add base image policy verification to `ko`, and significantly simplifies our own `policy-tester` which has spaghetti code replicating some of this functionality.

/kind feature

Signed-off-by: Matt Moore <mattmoor@chainguard.dev>
  • Loading branch information
mattmoor committed Jan 2, 2023
1 parent d6ef1f3 commit 7b1eed2
Show file tree
Hide file tree
Showing 9 changed files with 1,369 additions and 80 deletions.
117 changes: 37 additions & 80 deletions cmd/tester/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
Expand All @@ -38,21 +37,11 @@ import (
"sigs.k8s.io/release-utils/version"
"sigs.k8s.io/yaml"

"github.com/sigstore/policy-controller/pkg/apis/glob"
"github.com/sigstore/policy-controller/pkg/apis/policy/v1alpha1"
"github.com/sigstore/policy-controller/pkg/policy"
"github.com/sigstore/policy-controller/pkg/webhook"
webhookcip "github.com/sigstore/policy-controller/pkg/webhook/clusterimagepolicy"
)

var (
ns = "unused"

remoteOpts = []ociremote.Option{
ociremote.WithRemoteOptions(
remote.WithAuthFromKeychain(authn.DefaultKeychain),
),
}

ctx = logging.WithLogger(context.Background(), func() *zap.SugaredLogger {
x, _ := zap.NewDevelopmentConfig().Build()
return x.Sugar()
Expand Down Expand Up @@ -103,21 +92,43 @@ func main() {
}
}

// TODO(jdolitsky): This should use v1beta1 once there exists a
// webhookcip.ConvertClusterImagePolicyV1beta1ToWebhook() method
var v1alpha1cip v1alpha1.ClusterImagePolicy
if err := yaml.UnmarshalStrict(cipRaw, &v1alpha1cip); err != nil {
log.Fatal(err)
v := policy.Verification{
NoMatchPolicy: "deny",
Policies: &[]policy.Source{{
// TODO(mattmoor): Switch this to use Path or URL instead and
// subsume the above logic.
Data: string(cipRaw),
}},
}
if err := v.Validate(ctx); err != nil {
// CIP validation can return Warnings so let's just go through them
// and only exit if there are Errors.
if warnFE := err.Filter(apis.WarningLevel); warnFE != nil {
log.Printf("CIP has warnings:\n%s\n", warnFE.Error())
}
if errorFE := err.Filter(apis.ErrorLevel); errorFE != nil {
log.Fatalf("CIP is invalid: %s", errorFE.Error())
}
}
v1alpha1cip.SetDefaults(ctx)

// Show what the defaults look like
defaulted, err := yaml.Marshal(v1alpha1cip)
ref, err := name.ParseReference(*image)
if err != nil {
log.Fatal(err)
}
digest, err := ociremote.ResolveDigest(ref, ociremote.WithRemoteOptions(
remote.WithAuthFromKeychain(authn.DefaultKeychain),
))
if err != nil {
log.Fatalf("Failed to marshal the defaulted cip: %s", err)
log.Fatal(err)
}

log.Printf("Using the following cip:\n%s", defaulted)
warningStrings := []string{}
vfy, err := policy.Compile(ctx, v, func(s string, i ...interface{}) {
warningStrings = append(warningStrings, fmt.Sprintf(s, i...))
})
if err != nil {
log.Fatal(err)
}

if *resourceFilePath != "" {
raw, err := os.ReadFile(*resourceFilePath)
Expand Down Expand Up @@ -152,76 +163,22 @@ func main() {
ctx = webhook.IncludeTypeMeta(ctx, typeMeta)
}

validateErrs := v1alpha1cip.Validate(ctx)
if validateErrs != nil {
// CIP validation can return Warnings so let's just go through them
// and only exit if there are Errors.
if warnFE := validateErrs.Filter(apis.WarningLevel); warnFE != nil {
log.Printf("CIP has warnings:\n%s\n", warnFE.Error())
}
if errorFE := validateErrs.Filter(apis.ErrorLevel); errorFE != nil {
log.Fatalf("CIP is invalid: %s", errorFE.Error())
}
}
cip := webhookcip.ConvertClusterImagePolicyV1alpha1ToWebhook(&v1alpha1cip)

// We have to marshal/unmarshal the CIP since that handles converting
// inlined Data into PublicKey objects that validator uses.
webhookCip, err := json.Marshal(cip)
if err != nil {
log.Fatalf("Failed to marshal the webhook cip: %s", err)
}
if err := json.Unmarshal(webhookCip, &cip); err != nil {
log.Fatalf("Failed to unmarshal the webhook CIP: %s", err)
}
ref, err := name.ParseReference(*image)
if err != nil {
log.Fatal(err)
}

matches := false
for _, pattern := range cip.Images {
if pattern.Glob != "" {
if matched, err := glob.Match(pattern.Glob, *image); err != nil {
log.Fatalf("Failed to match glob: %s", err)
} else if matched {
log.Printf("image matches glob %q", pattern.Glob)
matches = true
}
}
}
if !matches {
log.Fatalf("Image does not match any of the provided globs")
}

result, errs := webhook.ValidatePolicy(ctx, ns, ref, *cip, authn.DefaultKeychain, remoteOpts...)
errStrings := []string{}
warningStrings := []string{}
for _, err := range errs {
var fe *apis.FieldError
if errors.As(err, &fe) {
if warnFE := fe.Filter(apis.WarningLevel); warnFE != nil {
warningStrings = append(warningStrings, strings.Trim(warnFE.Error(), "\n"))
}
if errorFE := fe.Filter(apis.ErrorLevel); errorFE != nil {
errStrings = append(errStrings, strings.Trim(errorFE.Error(), "\n"))
}
} else {
errStrings = append(errStrings, strings.Trim(err.Error(), "\n"))
}
if err := vfy.Verify(ctx, digest, authn.DefaultKeychain); err != nil {
errStrings = append(errStrings, strings.Trim(err.Error(), "\n"))
}

var o []byte
o, err = json.Marshal(&output{
Errors: errStrings,
Warnings: warningStrings,
Result: result,
})
if err != nil {
log.Fatal(err)
}

fmt.Println(string(o))
if len(errs) > 0 {
if len(errStrings) > 0 {
os.Exit(1)
}
}
115 changes: 115 additions & 0 deletions pkg/policy/parse.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// Copyright 2023 The Sigstore Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package policy

import (
"context"
"encoding/json"
"fmt"
"strings"

"github.com/sigstore/policy-controller/pkg/apis/policy/v1alpha1"
"github.com/sigstore/policy-controller/pkg/apis/policy/v1beta1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"knative.dev/pkg/apis"
"sigs.k8s.io/yaml"
)

// Parse decodes a provided YAML document containing zero or more objects into
// a collection of unstructured.Unstructured objects.
func Parse(ctx context.Context, document string) ([]*unstructured.Unstructured, error) {
docs := strings.Split(document, "\n---\n")

objs := make([]*unstructured.Unstructured, 0, len(docs))
for i, doc := range docs {
doc = strings.TrimSpace(doc)
if doc == "" {
continue
}
var obj unstructured.Unstructured
if err := yaml.Unmarshal([]byte(doc), &obj); err != nil {
return nil, fmt.Errorf("decoding object[%d]: %w", i, err)
}
if obj.GetAPIVersion() == "" {
return nil, apis.ErrMissingField("apiVersion").ViaIndex(i)
}
if obj.GetName() == "" {
return nil, apis.ErrMissingField("metadata.name").ViaIndex(i)
}
objs = append(objs, &obj)
}
return objs, nil
}

// ParseClusterImagePolicies returns ClusterImagePolicy objects found in the
// policy document.
func ParseClusterImagePolicies(ctx context.Context, document string) (cips []*v1alpha1.ClusterImagePolicy, warns error, err error) {
if warns, err = Validate(ctx, document); err != nil {
return nil, warns, err
}

ol, err := Parse(ctx, document)
if err != nil {
// "Validate" above calls "Parse", so this is unreachable.
return nil, warns, err
}

cips = make([]*v1alpha1.ClusterImagePolicy, 0, len(ol))
for _, obj := range ol {
gv, err := schema.ParseGroupVersion(obj.GetAPIVersion())
if err != nil {
// Practically speaking unstructured.Unstructured won't let this happen.
return nil, warns, fmt.Errorf("error parsing apiVersion of: %w", err)
}

cip := &v1alpha1.ClusterImagePolicy{}

switch gv.WithKind(obj.GetKind()) {
case v1beta1.SchemeGroupVersion.WithKind("ClusterImagePolicy"):
v1b1 := &v1beta1.ClusterImagePolicy{}
if err := convert(obj, v1b1); err != nil {
return nil, warns, err
}
if err := cip.ConvertFrom(ctx, v1b1); err != nil {
return nil, warns, err
}

case v1alpha1.SchemeGroupVersion.WithKind("ClusterImagePolicy"):
// This is allowed, but we should convert things.
if err := convert(obj, cip); err != nil {
return nil, warns, err
}

default:
continue
}

cips = append(cips, cip)
}
return cips, warns, nil
}

func convert(from interface{}, to runtime.Object) error {
bs, err := json.Marshal(from)
if err != nil {
return fmt.Errorf("Marshal() = %w", err)
}
if err := json.Unmarshal(bs, to); err != nil {
return fmt.Errorf("Unmarshal() = %w", err)
}
return nil
}
Loading

0 comments on commit 7b1eed2

Please sign in to comment.