diff --git a/docs/docs/misconfiguration/policy/builtin.md b/docs/docs/misconfiguration/policy/builtin.md index 3659f52d35..f655cc07c7 100644 --- a/docs/docs/misconfiguration/policy/builtin.md +++ b/docs/docs/misconfiguration/policy/builtin.md @@ -21,8 +21,18 @@ Helm Chart scanning will resolve the chart to Kubernetes manifests then run the Ansible scanning is coming soon. -[rego]: https://www.openpolicyagent.org/docs/latest/policy-language +## Policy Distribution +defsec policies are distributed as an OPA bundle on [GitHub Container Registry][ghcr] (GHCR). +When misconfiguration detection is enabled, Trivy pulls the OPA bundle from GHCR as an OCI artifact and stores it in the cache. +Those policies are then loaded into Trivy OPA engine and used for detecting misconfigurations. +If Trivy is unable to pull down newer policies, it will use the embedded set of policies as a fallback. This is also the case in air-gap environments where `--skip-policy-update` might be passed. + +## Update Interval +Trivy checks for updates to OPA bundle on GHCR every 24 hours and pulls it if there are any updates. + +[rego]: https://www.openpolicyagent.org/docs/latest/policy-language/ [defsec]: https://github.com/aquasecurity/defsec -[kubernetes]: https://github.com/aquasecurity/defsec/tree/master/internal/rules/policies/kubernetes -[docker]: https://github.com/aquasecurity/defsec/tree/master/internal/rules/policies/docker -[rbac]: https://github.com/aquasecurity/defsec/tree/master/internal/rules/policies/rbac +[kubernetes]: https://github.com/aquasecurity/defsec/tree/master/internal/rules/kubernetes +[kubernetes]: https://github.com/aquasecurity/defsec/tree/master/internal/rules/rbac +[docker]: https://github.com/aquasecurity/defsec/tree/master/internal/rules/docker +[ghcr]: https://github.com/aquasecurity/defsec/pkgs/container/defsec diff --git a/pkg/cloud/aws/commands/run_test.go b/pkg/cloud/aws/commands/run_test.go index 6690ad196c..2d0d366a0c 100644 --- a/pkg/cloud/aws/commands/run_test.go +++ b/pkg/cloud/aws/commands/run_test.go @@ -8,11 +8,10 @@ import ( "testing" "time" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - dbTypes "github.com/aquasecurity/trivy-db/pkg/types" "github.com/aquasecurity/trivy/pkg/flag" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func Test_Run(t *testing.T) { @@ -28,14 +27,17 @@ func Test_Run(t *testing.T) { regoPolicy string }{ { - name: "fail without region", - options: flag.Options{}, + name: "fail without region", + options: flag.Options{ + RegoOptions: flag.RegoOptions{SkipPolicyUpdate: true}, + }, want: "", expectErr: true, }, { name: "fail without creds", options: flag.Options{ + RegoOptions: flag.RegoOptions{SkipPolicyUpdate: true}, AWSOptions: flag.AWSOptions{ Region: "us-east-1", }, @@ -46,6 +48,7 @@ func Test_Run(t *testing.T) { { name: "try to call aws if cache is expired", options: flag.Options{ + RegoOptions: flag.RegoOptions{SkipPolicyUpdate: true}, AWSOptions: flag.AWSOptions{ Region: "us-east-1", Services: []string{"s3"}, @@ -61,6 +64,7 @@ func Test_Run(t *testing.T) { { name: "succeed with cached infra", options: flag.Options{ + RegoOptions: flag.RegoOptions{SkipPolicyUpdate: true}, AWSOptions: flag.AWSOptions{ Region: "us-east-1", Services: []string{"s3"}, diff --git a/pkg/cloud/aws/scanner/scanner.go b/pkg/cloud/aws/scanner/scanner.go index 3397c4a369..5ef90ce3bb 100644 --- a/pkg/cloud/aws/scanner/scanner.go +++ b/pkg/cloud/aws/scanner/scanner.go @@ -11,6 +11,7 @@ import ( "github.com/aquasecurity/defsec/pkg/scanners/options" "github.com/aquasecurity/defsec/pkg/state" "github.com/aquasecurity/trivy/pkg/cloud/aws/cache" + "github.com/aquasecurity/trivy/pkg/commands/operation" "github.com/aquasecurity/trivy/pkg/flag" "github.com/aquasecurity/trivy/pkg/log" ) @@ -60,12 +61,22 @@ func (s *AWSScanner) Scan(ctx context.Context, option flag.Options) (scan.Result ) } - if len(option.RegoOptions.PolicyPaths) > 0 { - scannerOpts = append( - scannerOpts, - options.ScannerWithPolicyDirs(option.RegoOptions.PolicyPaths...), - ) + var policyPaths []string + var downloadedPolicyPaths []string + var err error + downloadedPolicyPaths, err = operation.InitBuiltinPolicies(context.Background(), option.CacheDir, option.Quiet, option.SkipPolicyUpdate) + if err != nil { + if !option.SkipPolicyUpdate { + log.Logger.Errorf("Falling back to embedded policies: %s", err) + } + } else { + log.Logger.Debug("Policies successfully loaded from disk") + policyPaths = append(policyPaths, downloadedPolicyPaths...) + scannerOpts = append(scannerOpts, + options.ScannerWithEmbeddedPolicies(false)) } + policyPaths = append(policyPaths, option.RegoOptions.PolicyPaths...) + scannerOpts = append(scannerOpts, options.ScannerWithPolicyDirs(policyPaths...)) if len(option.RegoOptions.PolicyNamespaces) > 0 { scannerOpts = append( diff --git a/pkg/commands/artifact/run.go b/pkg/commands/artifact/run.go index af38e1b177..5f818d6e85 100644 --- a/pkg/commands/artifact/run.go +++ b/pkg/commands/artifact/run.go @@ -492,20 +492,33 @@ func initScannerConfig(opts flag.Options, cacheClient cache.Cache) (ScannerConfi log.Logger.Debugf("Vulnerability type: %s", scanOptions.VulnType) } + var downloadedPolicyPaths []string + var disableEmbedded bool + downloadedPolicyPaths, err := operation.InitBuiltinPolicies(context.Background(), opts.CacheDir, opts.Quiet, opts.SkipPolicyUpdate) + if err != nil { + if !opts.SkipPolicyUpdate { + log.Logger.Errorf("Falling back to embedded policies: %s", err) + } + } else { + log.Logger.Debug("Policies successfully loaded from disk") + disableEmbedded = true + } + // ScannerOption is filled only when config scanning is enabled. var configScannerOptions config.ScannerOption if slices.Contains(opts.SecurityChecks, types.SecurityCheckConfig) { log.Logger.Info("Misconfiguration scanning is enabled") configScannerOptions = config.ScannerOption{ - Trace: opts.Trace, - Namespaces: append(opts.PolicyNamespaces, defaultPolicyNamespaces...), - PolicyPaths: opts.PolicyPaths, - DataPaths: opts.DataPaths, - HelmValues: opts.HelmValues, - HelmValueFiles: opts.HelmValueFiles, - HelmFileValues: opts.HelmFileValues, - HelmStringValues: opts.HelmStringValues, - TerraformTFVars: opts.TerraformTFVars, + Trace: opts.Trace, + Namespaces: append(opts.PolicyNamespaces, defaultPolicyNamespaces...), + PolicyPaths: append(opts.PolicyPaths, downloadedPolicyPaths...), + DataPaths: opts.DataPaths, + HelmValues: opts.HelmValues, + HelmValueFiles: opts.HelmValueFiles, + HelmFileValues: opts.HelmFileValues, + HelmStringValues: opts.HelmStringValues, + TerraformTFVars: opts.TerraformTFVars, + DisableEmbeddedPolicies: disableEmbedded, } } diff --git a/pkg/commands/operation/operation.go b/pkg/commands/operation/operation.go index a1ae16db3b..670f87bd19 100644 --- a/pkg/commands/operation/operation.go +++ b/pkg/commands/operation/operation.go @@ -6,6 +6,8 @@ import ( "os" "strings" + "github.com/aquasecurity/trivy/pkg/policy" + "github.com/samber/lo" "github.com/aquasecurity/trivy/pkg/flag" @@ -135,3 +137,38 @@ func showDBInfo(cacheDir string) error { meta.Version, meta.UpdatedAt, meta.NextUpdate, meta.DownloadedAt) return nil } + +// InitBuiltinPolicies downloads the built-in policies and loads them +func InitBuiltinPolicies(ctx context.Context, cacheDir string, quiet, skipUpdate bool) ([]string, error) { + client, err := policy.NewClient(cacheDir, quiet) + if err != nil { + return nil, xerrors.Errorf("policy client error: %w", err) + } + + needsUpdate := false + if !skipUpdate { + needsUpdate, err = client.NeedsUpdate() + if err != nil { + return nil, xerrors.Errorf("unable to check if built-in policies need to be updated: %w", err) + } + } + + if needsUpdate { + log.Logger.Info("Need to update the built-in policies") + log.Logger.Info("Downloading the built-in policies...") + if err = client.DownloadBuiltinPolicies(ctx); err != nil { + return nil, xerrors.Errorf("failed to download built-in policies: %w", err) + } + } + + policyPaths, err := client.LoadBuiltinPolicies() + if err != nil { + if skipUpdate { + msg := "No downloadable policies were loaded as --skip-policy-update is enabled" + log.Logger.Info(msg) + return nil, xerrors.Errorf(msg) + } + return nil, xerrors.Errorf("policy load error: %w", err) + } + return policyPaths, nil +} diff --git a/pkg/flag/rego_flags.go b/pkg/flag/rego_flags.go index 44f1ad9095..17af139726 100644 --- a/pkg/flag/rego_flags.go +++ b/pkg/flag/rego_flags.go @@ -1,9 +1,5 @@ package flag -import ( - "github.com/aquasecurity/trivy/pkg/log" -) - // e.g. config yaml: // // rego: @@ -15,8 +11,7 @@ var ( Name: "skip-policy-update", ConfigName: "rego.skip-policy-update", Value: false, - Usage: "deprecated", - Deprecated: true, + Usage: "skip fetching rego policy updates", } TraceFlag = Flag{ Name: "trace", @@ -46,7 +41,7 @@ var ( // RegoFlagGroup composes common printer flag structs used for commands providing misconfinguration scanning. type RegoFlagGroup struct { - SkipPolicyUpdate *Flag // deprecated + SkipPolicyUpdate *Flag Trace *Flag PolicyPaths *Flag DataPaths *Flag @@ -54,7 +49,7 @@ type RegoFlagGroup struct { } type RegoOptions struct { - SkipPolicyUpdate bool // deprecated + SkipPolicyUpdate bool Trace bool PolicyPaths []string DataPaths []string @@ -86,10 +81,6 @@ func (f *RegoFlagGroup) Flags() []*Flag { } func (f *RegoFlagGroup) ToOptions() (RegoOptions, error) { - skipPolicyUpdateFlag := getBool(f.SkipPolicyUpdate) - if skipPolicyUpdateFlag { - log.Logger.Warn("'--skip-policy-update' is no longer necessary as the built-in policies are embedded into the binary") - } return RegoOptions{ SkipPolicyUpdate: getBool(f.SkipPolicyUpdate), Trace: getBool(f.Trace), diff --git a/pkg/policy/policy.go b/pkg/policy/policy.go new file mode 100644 index 0000000000..63eae2fe41 --- /dev/null +++ b/pkg/policy/policy.go @@ -0,0 +1,216 @@ +package policy + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/open-policy-agent/opa/bundle" + + "github.com/aquasecurity/trivy/pkg/log" + "github.com/aquasecurity/trivy/pkg/oci" + + "golang.org/x/xerrors" + "k8s.io/utils/clock" +) + +const ( + bundleVersion = 0 // Latest released MAJOR version for defsec + bundleRepository = "ghcr.io/aquasecurity/defsec" + policyMediaType = "application/vnd.cncf.openpolicyagent.layer.v1.tar+gzip" + updateInterval = 24 * time.Hour +) + +type options struct { + artifact *oci.Artifact + clock clock.Clock +} + +// WithOCIArtifact takes an OCI artifact +func WithOCIArtifact(art *oci.Artifact) Option { + return func(opts *options) { + opts.artifact = art + } +} + +// WithClock takes a clock +func WithClock(clock clock.Clock) Option { + return func(opts *options) { + opts.clock = clock + } +} + +// Option is a functional option +type Option func(*options) + +// Client implements policy operations +type Client struct { + *options + policyDir string + quiet bool + insecure bool +} + +// Metadata holds default policy metadata +type Metadata struct { + Digest string + DownloadedAt time.Time +} + +// NewClient is the factory method for policy client +func NewClient(cacheDir string, quiet bool, opts ...Option) (*Client, error) { + o := &options{ + clock: clock.RealClock{}, + } + + for _, opt := range opts { + opt(o) + } + + return &Client{ + options: o, + policyDir: filepath.Join(cacheDir, "policy"), + quiet: quiet, + }, nil +} + +func (c *Client) populateOCIArtifact() error { + if c.artifact == nil { + repo := fmt.Sprintf("%s:%d", bundleRepository, bundleVersion) + art, err := oci.NewArtifact(repo, policyMediaType, c.quiet, c.insecure) + if err != nil { + return xerrors.Errorf("OCI artifact error: %w", err) + } + c.artifact = art + } + return nil +} + +// DownloadBuiltinPolicies download default policies from GitHub Pages +func (c *Client) DownloadBuiltinPolicies(ctx context.Context) error { + if err := c.populateOCIArtifact(); err != nil { + return xerrors.Errorf("OPA bundle error: %w", err) + } + + dst := c.contentDir() + if err := c.artifact.Download(ctx, dst); err != nil { + return xerrors.Errorf("download error: %w", err) + } + + digest, err := c.artifact.Digest() + if err != nil { + return xerrors.Errorf("digest error: %w", err) + } + log.Logger.Debugf("Digest of the built-in policies: %s", digest) + + // Update metadata.json with the new digest and the current date + if err = c.updateMetadata(digest, c.clock.Now()); err != nil { + return xerrors.Errorf("unable to update the policy metadata: %w", err) + } + + return nil +} + +// LoadBuiltinPolicies loads default policies +func (c *Client) LoadBuiltinPolicies() ([]string, error) { + f, err := os.Open(c.manifestPath()) + if err != nil { + return nil, xerrors.Errorf("manifest file open error (%s): %w", c.manifestPath(), err) + } + defer f.Close() + + var manifest bundle.Manifest + if err = json.NewDecoder(f).Decode(&manifest); err != nil { + return nil, xerrors.Errorf("json decode error (%s): %w", c.manifestPath(), err) + } + + // If the "roots" field is not included in the manifest it defaults to [""] + // which means that ALL data and policy must come from the bundle. + if manifest.Roots == nil || len(*manifest.Roots) == 0 { + return []string{c.contentDir()}, nil + } + + var policyPaths []string + for _, root := range *manifest.Roots { + policyPaths = append(policyPaths, filepath.Join(c.contentDir(), root)) + } + + return policyPaths, nil +} + +// NeedsUpdate returns if the default policy should be updated +func (c *Client) NeedsUpdate() (bool, error) { + f, err := os.Open(c.metadataPath()) + if err != nil { + log.Logger.Debugf("Failed to open the policy metadata: %s", err) + return true, nil + } + defer f.Close() + + var meta Metadata + if err = json.NewDecoder(f).Decode(&meta); err != nil { + log.Logger.Warnf("Policy metadata decode error: %s", err) + return true, nil + } + + // No need to update if it's been within a day since the last update. + if c.clock.Now().Before(meta.DownloadedAt.Add(updateInterval)) { + return false, nil + } + + if err = c.populateOCIArtifact(); err != nil { + return false, xerrors.Errorf("OPA bundle error: %w", err) + } + + digest, err := c.artifact.Digest() + if err != nil { + return false, xerrors.Errorf("digest error: %w", err) + } + + if meta.Digest != digest { + return true, nil + } + + // Update DownloadedAt with the current time. + // Otherwise, if there are no updates in the remote registry, + // the digest will be fetched every time even after this. + if err = c.updateMetadata(meta.Digest, time.Now()); err != nil { + return false, xerrors.Errorf("unable to update the policy metadata: %w", err) + } + + return false, nil +} + +func (c *Client) contentDir() string { + return filepath.Join(c.policyDir, "content") +} + +func (c *Client) metadataPath() string { + return filepath.Join(c.policyDir, "metadata.json") +} + +func (c *Client) manifestPath() string { + return filepath.Join(c.contentDir(), bundle.ManifestExt) +} + +func (c *Client) updateMetadata(digest string, now time.Time) error { + f, err := os.Create(c.metadataPath()) + if err != nil { + return xerrors.Errorf("failed to open a policy manifest: %w", err) + } + defer f.Close() + + meta := Metadata{ + Digest: digest, + DownloadedAt: now, + } + + if err = json.NewEncoder(f).Encode(meta); err != nil { + return xerrors.Errorf("json encode error: %w", err) + } + + return nil +} diff --git a/pkg/policy/policy_test.go b/pkg/policy/policy_test.go new file mode 100644 index 0000000000..1d493d8301 --- /dev/null +++ b/pkg/policy/policy_test.go @@ -0,0 +1,374 @@ +package policy_test + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "testing" + "time" + + v1 "github.com/google/go-containerregistry/pkg/v1" + fakei "github.com/google/go-containerregistry/pkg/v1/fake" + "github.com/google/go-containerregistry/pkg/v1/tarball" + "github.com/google/go-containerregistry/pkg/v1/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/utils/clock" + fake "k8s.io/utils/clock/testing" + + "github.com/aquasecurity/trivy/pkg/oci" + "github.com/aquasecurity/trivy/pkg/policy" +) + +type fakeLayer struct { + v1.Layer +} + +func (f fakeLayer) MediaType() (types.MediaType, error) { + return "application/vnd.cncf.openpolicyagent.layer.v1.tar+gzip", nil +} + +func newFakeLayer(t *testing.T) v1.Layer { + layer, err := tarball.LayerFromFile("testdata/bundle.tar.gz") + require.NoError(t, err) + require.NotNil(t, layer) + + return fakeLayer{layer} +} + +type brokenLayer struct { + v1.Layer +} + +func (b brokenLayer) MediaType() (types.MediaType, error) { + return "application/vnd.cncf.openpolicyagent.layer.v1.tar+gzip", nil +} + +func (b brokenLayer) Compressed() (io.ReadCloser, error) { + return nil, fmt.Errorf("compressed error") +} + +func newBrokenLayer(t *testing.T) v1.Layer { + layer, err := tarball.LayerFromFile("testdata/bundle.tar.gz") + require.NoError(t, err) + + return brokenLayer{layer} +} + +func TestClient_LoadBuiltinPolicies(t *testing.T) { + tests := []struct { + name string + cacheDir string + want []string + wantErr string + }{ + { + name: "happy path", + cacheDir: "testdata/happy", + want: []string{ + filepath.Join("testdata/happy/policy/content/kubernetes"), + filepath.Join("testdata/happy/policy/content/docker"), + }, + }, + { + name: "empty roots", + cacheDir: "testdata/empty", + want: []string{ + filepath.Join("testdata/empty/policy/content"), + }, + }, + { + name: "broken manifest", + cacheDir: "testdata/broken", + want: []string{}, + wantErr: "json decode error", + }, + { + name: "no such file", + cacheDir: "testdata/unknown", + want: []string{}, + wantErr: "manifest file open error", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Mock image + img := new(fakei.FakeImage) + img.LayersReturns([]v1.Layer{newFakeLayer(t)}, nil) + img.ManifestReturns(&v1.Manifest{ + Layers: []v1.Descriptor{ + { + MediaType: "application/vnd.cncf.openpolicyagent.layer.v1.tar+gzip", + Size: 100, + Digest: v1.Hash{ + Algorithm: "sha256", + Hex: "cba33656188782852f58993f45b68bfb8577f64cdcf02a604e3fc2afbeb5f2d8"}, + Annotations: map[string]string{ + "org.opencontainers.image.title": "bundle.tar.gz", + }, + }, + }, + }, nil) + + // Mock OCI artifact + mediaType := "application/vnd.cncf.openpolicyagent.layer.v1.tar+gzip" + art, err := oci.NewArtifact("repo", mediaType, true, true, oci.WithImage(img)) + require.NoError(t, err) + + c, err := policy.NewClient(tt.cacheDir, true, policy.WithOCIArtifact(art)) + require.NoError(t, err) + + got, err := c.LoadBuiltinPolicies() + if tt.wantErr != "" { + require.NotNil(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestClient_NeedsUpdate(t *testing.T) { + type digestReturns struct { + h v1.Hash + err error + } + tests := []struct { + name string + clock clock.Clock + digestReturns digestReturns + metadata interface{} + want bool + wantErr bool + }{ + { + name: "recent download", + clock: fake.NewFakeClock(time.Date(2021, 1, 1, 1, 0, 0, 0, time.UTC)), + digestReturns: digestReturns{ + h: v1.Hash{Algorithm: "sha256", Hex: "01e033e78bd8a59fa4f4577215e7da06c05e1152526094d8d79d2aa06e98cb9d"}, + }, + metadata: policy.Metadata{ + Digest: `sha256:922e50f14ab484f11ae65540c3d2d76009020213f1027d4331d31141575e5414`, + DownloadedAt: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), + }, + want: false, + }, + { + name: "same digest", + clock: fake.NewFakeClock(time.Date(2021, 1, 2, 1, 0, 0, 0, time.UTC)), + digestReturns: digestReturns{ + h: v1.Hash{Algorithm: "sha256", Hex: "01e033e78bd8a59fa4f4577215e7da06c05e1152526094d8d79d2aa06e98cb9d"}, + }, + metadata: policy.Metadata{ + Digest: `sha256:01e033e78bd8a59fa4f4577215e7da06c05e1152526094d8d79d2aa06e98cb9d`, + DownloadedAt: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), + }, + want: false, + }, + { + name: "different digest", + clock: fake.NewFakeClock(time.Date(2021, 1, 2, 1, 0, 0, 0, time.UTC)), + digestReturns: digestReturns{ + h: v1.Hash{Algorithm: "sha256", Hex: "01e033e78bd8a59fa4f4577215e7da06c05e1152526094d8d79d2aa06e98cb9d"}, + }, + metadata: policy.Metadata{ + Digest: `sha256:922e50f14ab484f11ae65540c3d2d76009020213f1027d4331d31141575e5414`, + DownloadedAt: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), + }, + want: true, + }, + { + name: "sad: Digest returns an error", + clock: fake.NewFakeClock(time.Date(2021, 1, 2, 1, 0, 0, 0, time.UTC)), + digestReturns: digestReturns{ + err: fmt.Errorf("error"), + }, + metadata: policy.Metadata{ + Digest: `sha256:922e50f14ab484f11ae65540c3d2d76009020213f1027d4331d31141575e5414`, + DownloadedAt: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), + }, + want: false, + wantErr: true, + }, + { + name: "sad: non-existent metadata", + clock: fake.NewFakeClock(time.Date(2021, 1, 1, 1, 0, 0, 0, time.UTC)), + want: true, + }, + { + name: "sad: broken metadata", + clock: fake.NewFakeClock(time.Date(2021, 1, 1, 1, 0, 0, 0, time.UTC)), + metadata: `"foo"`, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set up a temporary directory + tmpDir := t.TempDir() + + // Mock image + img := new(fakei.FakeImage) + img.LayersReturns([]v1.Layer{newFakeLayer(t)}, nil) + img.DigestReturns(tt.digestReturns.h, tt.digestReturns.err) + img.ManifestReturns(&v1.Manifest{ + Layers: []v1.Descriptor{ + { + MediaType: "application/vnd.cncf.openpolicyagent.layer.v1.tar+gzip", + Size: 100, + Digest: v1.Hash{ + Algorithm: "sha256", + Hex: "cba33656188782852f58993f45b68bfb8577f64cdcf02a604e3fc2afbeb5f2d8"}, + Annotations: map[string]string{ + "org.opencontainers.image.title": "bundle.tar.gz", + }, + }, + }, + }, nil) + + // Create a policy directory + err := os.MkdirAll(filepath.Join(tmpDir, "policy"), os.ModePerm) + require.NoError(t, err) + + if tt.metadata != nil { + b, err := json.Marshal(tt.metadata) + require.NoError(t, err) + + // Write a metadata file + metadataPath := filepath.Join(tmpDir, "policy", "metadata.json") + err = os.WriteFile(metadataPath, b, os.ModePerm) + require.NoError(t, err) + } + + mediaType := "application/vnd.cncf.openpolicyagent.layer.v1.tar+gzip" + art, err := oci.NewArtifact("repo", mediaType, true, true, oci.WithImage(img)) + require.NoError(t, err) + + c, err := policy.NewClient(tmpDir, true, policy.WithOCIArtifact(art), policy.WithClock(tt.clock)) + require.NoError(t, err) + + // Assert results + got, err := c.NeedsUpdate() + assert.Equal(t, tt.wantErr, err != nil) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestClient_DownloadBuiltinPolicies(t *testing.T) { + type digestReturns struct { + h v1.Hash + err error + } + type layersReturns struct { + layers []v1.Layer + err error + } + tests := []struct { + name string + clock clock.Clock + layersReturns layersReturns + digestReturns digestReturns + want *policy.Metadata + wantErr string + }{ + { + name: "happy path", + clock: fake.NewFakeClock(time.Date(2021, 1, 1, 1, 0, 0, 0, time.UTC)), + layersReturns: layersReturns{ + layers: []v1.Layer{newFakeLayer(t)}, + }, + digestReturns: digestReturns{ + h: v1.Hash{Algorithm: "sha256", Hex: "01e033e78bd8a59fa4f4577215e7da06c05e1152526094d8d79d2aa06e98cb9d"}, + }, + want: &policy.Metadata{ + Digest: "sha256:01e033e78bd8a59fa4f4577215e7da06c05e1152526094d8d79d2aa06e98cb9d", + DownloadedAt: time.Date(2021, 1, 1, 1, 0, 0, 0, time.UTC), + }, + }, + { + name: "sad: broken layer", + clock: fake.NewFakeClock(time.Date(2021, 1, 1, 1, 0, 0, 0, time.UTC)), + layersReturns: layersReturns{ + layers: []v1.Layer{newBrokenLayer(t)}, + }, + digestReturns: digestReturns{ + h: v1.Hash{Algorithm: "sha256", Hex: "01e033e78bd8a59fa4f4577215e7da06c05e1152526094d8d79d2aa06e98cb9d"}, + }, + wantErr: "compressed error", + }, + { + name: "sad: Digest returns an error", + clock: fake.NewFakeClock(time.Date(2021, 1, 1, 1, 0, 0, 0, time.UTC)), + layersReturns: layersReturns{ + layers: []v1.Layer{newFakeLayer(t)}, + }, + digestReturns: digestReturns{ + err: fmt.Errorf("error"), + }, + want: &policy.Metadata{ + Digest: "sha256:01e033e78bd8a59fa4f4577215e7da06c05e1152526094d8d79d2aa06e98cb9d", + DownloadedAt: time.Date(2021, 1, 1, 1, 0, 0, 0, time.UTC), + }, + wantErr: "digest error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tempDir := t.TempDir() + + // Mock image + img := new(fakei.FakeImage) + img.DigestReturns(tt.digestReturns.h, tt.digestReturns.err) + img.LayersReturns(tt.layersReturns.layers, tt.layersReturns.err) + img.ManifestReturns(&v1.Manifest{ + Layers: []v1.Descriptor{ + { + MediaType: "application/vnd.cncf.openpolicyagent.layer.v1.tar+gzip", + Size: 100, + Digest: v1.Hash{ + Algorithm: "sha256", + Hex: "cba33656188782852f58993f45b68bfb8577f64cdcf02a604e3fc2afbeb5f2d8"}, + Annotations: map[string]string{ + "org.opencontainers.image.title": "bundle.tar.gz", + }, + }, + }, + }, nil) + + // Mock OCI artifact + mediaType := "application/vnd.cncf.openpolicyagent.layer.v1.tar+gzip" + art, err := oci.NewArtifact("repo", mediaType, true, true, oci.WithImage(img)) + require.NoError(t, err) + + c, err := policy.NewClient(tempDir, true, policy.WithClock(tt.clock), policy.WithOCIArtifact(art)) + require.NoError(t, err) + + err = c.DownloadBuiltinPolicies(context.Background()) + if tt.wantErr != "" { + require.NotNil(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + assert.NoError(t, err) + + // Assert metadata.json + metadata := filepath.Join(tempDir, "policy", "metadata.json") + b, err := os.ReadFile(metadata) + require.NoError(t, err) + + got := new(policy.Metadata) + err = json.Unmarshal(b, got) + require.NoError(t, err) + + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/pkg/policy/testdata/broken/policy/content/.manifest b/pkg/policy/testdata/broken/policy/content/.manifest new file mode 100644 index 0000000000..c7759bfead --- /dev/null +++ b/pkg/policy/testdata/broken/policy/content/.manifest @@ -0,0 +1,3 @@ +{ + "revision": "1", + } diff --git a/pkg/policy/testdata/bundle.tar.gz b/pkg/policy/testdata/bundle.tar.gz new file mode 100644 index 0000000000..83fd553a7a Binary files /dev/null and b/pkg/policy/testdata/bundle.tar.gz differ diff --git a/pkg/policy/testdata/empty/policy/content/.manifest b/pkg/policy/testdata/empty/policy/content/.manifest new file mode 100644 index 0000000000..68cfbeb68d --- /dev/null +++ b/pkg/policy/testdata/empty/policy/content/.manifest @@ -0,0 +1,3 @@ +{ + "revision": "1" +} \ No newline at end of file diff --git a/pkg/policy/testdata/happy/policy/content/.manifest b/pkg/policy/testdata/happy/policy/content/.manifest new file mode 100644 index 0000000000..a5a92a1639 --- /dev/null +++ b/pkg/policy/testdata/happy/policy/content/.manifest @@ -0,0 +1,4 @@ +{ + "revision": "1", + "roots": ["kubernetes", "docker"] + } \ No newline at end of file