mirror of
https://github.com/aquasecurity/trivy.git
synced 2025-12-22 07:10:41 -08:00
feat(misconf): Fetch policies from OCI registry (#3015)
Signed-off-by: Simar <simar@linux.com>
This commit is contained in:
@@ -21,8 +21,18 @@ Helm Chart scanning will resolve the chart to Kubernetes manifests then run the
|
|||||||
|
|
||||||
Ansible scanning is coming soon.
|
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
|
[defsec]: https://github.com/aquasecurity/defsec
|
||||||
[kubernetes]: https://github.com/aquasecurity/defsec/tree/master/internal/rules/policies/kubernetes
|
[kubernetes]: https://github.com/aquasecurity/defsec/tree/master/internal/rules/kubernetes
|
||||||
[docker]: https://github.com/aquasecurity/defsec/tree/master/internal/rules/policies/docker
|
[kubernetes]: https://github.com/aquasecurity/defsec/tree/master/internal/rules/rbac
|
||||||
[rbac]: https://github.com/aquasecurity/defsec/tree/master/internal/rules/policies/rbac
|
[docker]: https://github.com/aquasecurity/defsec/tree/master/internal/rules/docker
|
||||||
|
[ghcr]: https://github.com/aquasecurity/defsec/pkgs/container/defsec
|
||||||
|
|||||||
@@ -8,11 +8,10 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
dbTypes "github.com/aquasecurity/trivy-db/pkg/types"
|
dbTypes "github.com/aquasecurity/trivy-db/pkg/types"
|
||||||
"github.com/aquasecurity/trivy/pkg/flag"
|
"github.com/aquasecurity/trivy/pkg/flag"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Test_Run(t *testing.T) {
|
func Test_Run(t *testing.T) {
|
||||||
@@ -29,13 +28,16 @@ func Test_Run(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "fail without region",
|
name: "fail without region",
|
||||||
options: flag.Options{},
|
options: flag.Options{
|
||||||
|
RegoOptions: flag.RegoOptions{SkipPolicyUpdate: true},
|
||||||
|
},
|
||||||
want: "",
|
want: "",
|
||||||
expectErr: true,
|
expectErr: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "fail without creds",
|
name: "fail without creds",
|
||||||
options: flag.Options{
|
options: flag.Options{
|
||||||
|
RegoOptions: flag.RegoOptions{SkipPolicyUpdate: true},
|
||||||
AWSOptions: flag.AWSOptions{
|
AWSOptions: flag.AWSOptions{
|
||||||
Region: "us-east-1",
|
Region: "us-east-1",
|
||||||
},
|
},
|
||||||
@@ -46,6 +48,7 @@ func Test_Run(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "try to call aws if cache is expired",
|
name: "try to call aws if cache is expired",
|
||||||
options: flag.Options{
|
options: flag.Options{
|
||||||
|
RegoOptions: flag.RegoOptions{SkipPolicyUpdate: true},
|
||||||
AWSOptions: flag.AWSOptions{
|
AWSOptions: flag.AWSOptions{
|
||||||
Region: "us-east-1",
|
Region: "us-east-1",
|
||||||
Services: []string{"s3"},
|
Services: []string{"s3"},
|
||||||
@@ -61,6 +64,7 @@ func Test_Run(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "succeed with cached infra",
|
name: "succeed with cached infra",
|
||||||
options: flag.Options{
|
options: flag.Options{
|
||||||
|
RegoOptions: flag.RegoOptions{SkipPolicyUpdate: true},
|
||||||
AWSOptions: flag.AWSOptions{
|
AWSOptions: flag.AWSOptions{
|
||||||
Region: "us-east-1",
|
Region: "us-east-1",
|
||||||
Services: []string{"s3"},
|
Services: []string{"s3"},
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/aquasecurity/defsec/pkg/scanners/options"
|
"github.com/aquasecurity/defsec/pkg/scanners/options"
|
||||||
"github.com/aquasecurity/defsec/pkg/state"
|
"github.com/aquasecurity/defsec/pkg/state"
|
||||||
"github.com/aquasecurity/trivy/pkg/cloud/aws/cache"
|
"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/flag"
|
||||||
"github.com/aquasecurity/trivy/pkg/log"
|
"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 {
|
var policyPaths []string
|
||||||
scannerOpts = append(
|
var downloadedPolicyPaths []string
|
||||||
scannerOpts,
|
var err error
|
||||||
options.ScannerWithPolicyDirs(option.RegoOptions.PolicyPaths...),
|
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 {
|
if len(option.RegoOptions.PolicyNamespaces) > 0 {
|
||||||
scannerOpts = append(
|
scannerOpts = append(
|
||||||
|
|||||||
@@ -492,6 +492,18 @@ func initScannerConfig(opts flag.Options, cacheClient cache.Cache) (ScannerConfi
|
|||||||
log.Logger.Debugf("Vulnerability type: %s", scanOptions.VulnType)
|
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.
|
// ScannerOption is filled only when config scanning is enabled.
|
||||||
var configScannerOptions config.ScannerOption
|
var configScannerOptions config.ScannerOption
|
||||||
if slices.Contains(opts.SecurityChecks, types.SecurityCheckConfig) {
|
if slices.Contains(opts.SecurityChecks, types.SecurityCheckConfig) {
|
||||||
@@ -499,13 +511,14 @@ func initScannerConfig(opts flag.Options, cacheClient cache.Cache) (ScannerConfi
|
|||||||
configScannerOptions = config.ScannerOption{
|
configScannerOptions = config.ScannerOption{
|
||||||
Trace: opts.Trace,
|
Trace: opts.Trace,
|
||||||
Namespaces: append(opts.PolicyNamespaces, defaultPolicyNamespaces...),
|
Namespaces: append(opts.PolicyNamespaces, defaultPolicyNamespaces...),
|
||||||
PolicyPaths: opts.PolicyPaths,
|
PolicyPaths: append(opts.PolicyPaths, downloadedPolicyPaths...),
|
||||||
DataPaths: opts.DataPaths,
|
DataPaths: opts.DataPaths,
|
||||||
HelmValues: opts.HelmValues,
|
HelmValues: opts.HelmValues,
|
||||||
HelmValueFiles: opts.HelmValueFiles,
|
HelmValueFiles: opts.HelmValueFiles,
|
||||||
HelmFileValues: opts.HelmFileValues,
|
HelmFileValues: opts.HelmFileValues,
|
||||||
HelmStringValues: opts.HelmStringValues,
|
HelmStringValues: opts.HelmStringValues,
|
||||||
TerraformTFVars: opts.TerraformTFVars,
|
TerraformTFVars: opts.TerraformTFVars,
|
||||||
|
DisableEmbeddedPolicies: disableEmbedded,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/aquasecurity/trivy/pkg/policy"
|
||||||
|
|
||||||
"github.com/samber/lo"
|
"github.com/samber/lo"
|
||||||
|
|
||||||
"github.com/aquasecurity/trivy/pkg/flag"
|
"github.com/aquasecurity/trivy/pkg/flag"
|
||||||
@@ -135,3 +137,38 @@ func showDBInfo(cacheDir string) error {
|
|||||||
meta.Version, meta.UpdatedAt, meta.NextUpdate, meta.DownloadedAt)
|
meta.Version, meta.UpdatedAt, meta.NextUpdate, meta.DownloadedAt)
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
package flag
|
package flag
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/aquasecurity/trivy/pkg/log"
|
|
||||||
)
|
|
||||||
|
|
||||||
// e.g. config yaml:
|
// e.g. config yaml:
|
||||||
//
|
//
|
||||||
// rego:
|
// rego:
|
||||||
@@ -15,8 +11,7 @@ var (
|
|||||||
Name: "skip-policy-update",
|
Name: "skip-policy-update",
|
||||||
ConfigName: "rego.skip-policy-update",
|
ConfigName: "rego.skip-policy-update",
|
||||||
Value: false,
|
Value: false,
|
||||||
Usage: "deprecated",
|
Usage: "skip fetching rego policy updates",
|
||||||
Deprecated: true,
|
|
||||||
}
|
}
|
||||||
TraceFlag = Flag{
|
TraceFlag = Flag{
|
||||||
Name: "trace",
|
Name: "trace",
|
||||||
@@ -46,7 +41,7 @@ var (
|
|||||||
|
|
||||||
// RegoFlagGroup composes common printer flag structs used for commands providing misconfinguration scanning.
|
// RegoFlagGroup composes common printer flag structs used for commands providing misconfinguration scanning.
|
||||||
type RegoFlagGroup struct {
|
type RegoFlagGroup struct {
|
||||||
SkipPolicyUpdate *Flag // deprecated
|
SkipPolicyUpdate *Flag
|
||||||
Trace *Flag
|
Trace *Flag
|
||||||
PolicyPaths *Flag
|
PolicyPaths *Flag
|
||||||
DataPaths *Flag
|
DataPaths *Flag
|
||||||
@@ -54,7 +49,7 @@ type RegoFlagGroup struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type RegoOptions struct {
|
type RegoOptions struct {
|
||||||
SkipPolicyUpdate bool // deprecated
|
SkipPolicyUpdate bool
|
||||||
Trace bool
|
Trace bool
|
||||||
PolicyPaths []string
|
PolicyPaths []string
|
||||||
DataPaths []string
|
DataPaths []string
|
||||||
@@ -86,10 +81,6 @@ func (f *RegoFlagGroup) Flags() []*Flag {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (f *RegoFlagGroup) ToOptions() (RegoOptions, error) {
|
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{
|
return RegoOptions{
|
||||||
SkipPolicyUpdate: getBool(f.SkipPolicyUpdate),
|
SkipPolicyUpdate: getBool(f.SkipPolicyUpdate),
|
||||||
Trace: getBool(f.Trace),
|
Trace: getBool(f.Trace),
|
||||||
|
|||||||
216
pkg/policy/policy.go
Normal file
216
pkg/policy/policy.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
374
pkg/policy/policy_test.go
Normal file
374
pkg/policy/policy_test.go
Normal file
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
3
pkg/policy/testdata/broken/policy/content/.manifest
vendored
Normal file
3
pkg/policy/testdata/broken/policy/content/.manifest
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"revision": "1",
|
||||||
|
}
|
||||||
BIN
pkg/policy/testdata/bundle.tar.gz
vendored
Normal file
BIN
pkg/policy/testdata/bundle.tar.gz
vendored
Normal file
Binary file not shown.
3
pkg/policy/testdata/empty/policy/content/.manifest
vendored
Normal file
3
pkg/policy/testdata/empty/policy/content/.manifest
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"revision": "1"
|
||||||
|
}
|
||||||
4
pkg/policy/testdata/happy/policy/content/.manifest
vendored
Normal file
4
pkg/policy/testdata/happy/policy/content/.manifest
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"revision": "1",
|
||||||
|
"roots": ["kubernetes", "docker"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user