diff --git a/pkg/k8s/k8s.go b/pkg/k8s/k8s.go index 642340f..ad0e99c 100644 --- a/pkg/k8s/k8s.go +++ b/pkg/k8s/k8s.go @@ -403,6 +403,10 @@ func IsBuiltInWorkload(resource *metav1.OwnerReference) bool { resource.Kind == string(KindJob)) } +func GetAllResources() []string { + return append(getClusterResources(), getNamespaceResources()...) +} + func getClusterResources() []string { return []string{ ClusterRoles, diff --git a/pkg/trivyk8s/trivyk8s.go b/pkg/trivyk8s/trivyk8s.go index a7b37bf..c7582bc 100644 --- a/pkg/trivyk8s/trivyk8s.go +++ b/pkg/trivyk8s/trivyk8s.go @@ -161,8 +161,90 @@ func (c client) GetIncludeKinds() []string { return c.includeKinds } +// initResourceList collects scannable resources. +func (c *client) initResourceList() { + // skip if resources are already created + if len(c.resources) > 0 { + return + } + + // collect only included kinds + if len(c.includeKinds) != 0 { + // a customer can input resources in different cases: Pods, deployments etc. + // `includeKinds` are already low cased, so we can just assign the values + c.resources = c.includeKinds + return + } + // if there are no included and excluded kinds - don't collect resources + if len(c.excludeKinds) == 0 { + return + } + // skip excluded resources + for _, kind := range k8s.GetAllResources() { + if slices.Contains(c.excludeKinds, kind) { + continue + } + c.resources = append(c.resources, kind) + } +} + +// getNamespaces collects scannable namespaces +func (c *client) getNamespaces() ([]string, error) { + if len(c.includeNamespaces) > 0 { + return c.includeNamespaces, nil + } + + result := []string{} + if len(c.excludeNamespaces) == 0 { + return result, nil + } + namespaceGVR := schema.GroupVersionResource{ + Group: "", + Version: "v1", + Resource: "namespaces", + } + dClient := c.getDynamicClient(namespaceGVR) + namespaces, err := dClient.List(context.TODO(), v1.ListOptions{}) + if err != nil { + if errors.IsForbidden(err) { + return result, fmt.Errorf("'exclude namespaces' option requires a cluster role with permissions to list namespaces") + } + return result, fmt.Errorf("unable to list namespaces: %w", err) + } + for _, ns := range namespaces.Items { + if slices.Contains(c.excludeNamespaces, ns.GetName()) { + continue + } + result = append(result, ns.GetName()) + } + return result, nil +} + // ListArtifacts returns kubernetes scannable artifacs. func (c *client) ListArtifacts(ctx context.Context) ([]*artifacts.Artifact, error) { + c.initResourceList() + namespaces, err := c.getNamespaces() + if err != nil { + return nil, err + } + if len(namespaces) == 0 { + return c.ListSpecificArtifacts(ctx) + } + artifactList := make([]*artifacts.Artifact, 0) + + for _, namespace := range namespaces { + c.namespace = namespace + arts, err := c.ListSpecificArtifacts(ctx) + if err != nil { + return nil, err + } + artifactList = append(artifactList, arts...) + } + return artifactList, nil +} + +// ListSpecificArtifacts returns kubernetes scannable artifacs for a specific namespace or a cluster +func (c *client) ListSpecificArtifacts(ctx context.Context) ([]*artifacts.Artifact, error) { artifactList := make([]*artifacts.Artifact, 0) namespaced := isNamespaced(c.namespace, c.allNamespaces) @@ -195,15 +277,6 @@ func (c *client) ListArtifacts(ctx context.Context) ([]*artifacts.Artifact, erro if c.excludeOwned && c.hasOwner(resource) { continue } - // filter resources by kind - if FilterResources(c.includeKinds, c.excludeKinds, resource.GetKind()) { - continue - } - - // filter resources by namespace - if FilterResources(c.includeNamespaces, c.excludeNamespaces, resource.GetNamespace()) { - continue - } lastAppliedResource := resource if jsonManifest, ok := resource.GetAnnotations()["kubectl.kubernetes.io/last-applied-configuration"]; ok { // required for outdated-api when k8s convert resources @@ -470,7 +543,7 @@ func rawResource(resource interface{}) (map[string]interface{}, error) { func (c *client) getDynamicClient(gvr schema.GroupVersionResource) dynamic.ResourceInterface { dclient := c.cluster.GetDynamicClient() - // don't use namespace if it is a cluster levle resource, + // don't use namespace if it is a cluster level resource, // or namespace is empty if k8s.IsClusterResource(gvr) || len(c.namespace) == 0 { return dclient.Resource(gvr) diff --git a/pkg/trivyk8s/trivyk8s_test.go b/pkg/trivyk8s/trivyk8s_test.go index f57ede5..5681904 100644 --- a/pkg/trivyk8s/trivyk8s_test.go +++ b/pkg/trivyk8s/trivyk8s_test.go @@ -1,12 +1,223 @@ package trivyk8s import ( + "context" + "fmt" "testing" - "github.com/aquasecurity/trivy-kubernetes/pkg/artifacts" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" + "github.com/stretchr/testify/assert" + + "github.com/aquasecurity/trivy-kubernetes/pkg/artifacts" + "github.com/aquasecurity/trivy-kubernetes/pkg/bom" + "github.com/aquasecurity/trivy-kubernetes/pkg/k8s" + "github.com/aquasecurity/trivy-kubernetes/pkg/k8s/docker" ) +type MockClusterDynamicClient struct { + resource dynamic.NamespaceableResourceInterface +} + +func (m MockClusterDynamicClient) Resource(schema.GroupVersionResource) dynamic.NamespaceableResourceInterface { + return m.resource + +} + +type MockNamespaceableResourceInterface struct { + err error + namespaces []string +} + +func (m MockNamespaceableResourceInterface) Namespace(s string) dynamic.ResourceInterface { + return nil +} + +func (m MockNamespaceableResourceInterface) Create(ctx context.Context, obj *unstructured.Unstructured, options metav1.CreateOptions, subresources ...string) (*unstructured.Unstructured, error) { + return &unstructured.Unstructured{}, nil +} + +func (m MockNamespaceableResourceInterface) Update(ctx context.Context, obj *unstructured.Unstructured, options metav1.UpdateOptions, subresources ...string) (*unstructured.Unstructured, error) { + return &unstructured.Unstructured{}, nil +} + +func (m MockNamespaceableResourceInterface) UpdateStatus(ctx context.Context, obj *unstructured.Unstructured, options metav1.UpdateOptions) (*unstructured.Unstructured, error) { + return &unstructured.Unstructured{}, nil +} + +func (m MockNamespaceableResourceInterface) Delete(ctx context.Context, name string, options metav1.DeleteOptions, subresources ...string) error { + return nil +} + +func (m MockNamespaceableResourceInterface) DeleteCollection(ctx context.Context, options metav1.DeleteOptions, listOptions metav1.ListOptions) error { + return nil +} + +func (m MockNamespaceableResourceInterface) Get(ctx context.Context, name string, options metav1.GetOptions, subresources ...string) (*unstructured.Unstructured, error) { + return &unstructured.Unstructured{}, nil +} + +func (m MockNamespaceableResourceInterface) Watch(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { + return nil, nil +} + +func (m MockNamespaceableResourceInterface) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, options metav1.PatchOptions, subresources ...string) (*unstructured.Unstructured, error) { + return &unstructured.Unstructured{}, nil +} + +func (m MockNamespaceableResourceInterface) Apply(ctx context.Context, name string, obj *unstructured.Unstructured, options metav1.ApplyOptions, subresources ...string) (*unstructured.Unstructured, error) { + return &unstructured.Unstructured{}, nil +} + +func (m MockNamespaceableResourceInterface) ApplyStatus(ctx context.Context, name string, obj *unstructured.Unstructured, options metav1.ApplyOptions) (*unstructured.Unstructured, error) { + return &unstructured.Unstructured{}, nil +} + +func (m MockNamespaceableResourceInterface) List(ctx context.Context, opts metav1.ListOptions) (*unstructured.UnstructuredList, error) { + if m.err != nil { + return nil, m.err + } + result := &unstructured.UnstructuredList{} + for _, namespace := range m.namespaces { + result.Items = append(result.Items, unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": namespace, + }, + }, + }) + } + return result, nil +} + +type MockCluster struct { + dynamicClient dynamic.Interface +} + +func newMockCluster(dynamicClient dynamic.Interface) *MockCluster { + return &MockCluster{ + dynamicClient: dynamicClient, + } +} + +// GetDynamicClient returns dynamic.Interface +func (m *MockCluster) GetDynamicClient() dynamic.Interface { + return m.dynamicClient +} + +// Stub methods to satisfy the Cluster interface +func (m *MockCluster) GetCurrentContext() string { return "" } +func (m *MockCluster) GetCurrentNamespace() string { return "" } +func (m *MockCluster) GetK8sClientSet() *kubernetes.Clientset { return nil } +func (m *MockCluster) GetGVRs(bool, []string) ([]schema.GroupVersionResource, error) { return nil, nil } +func (m *MockCluster) GetGVR(string) (schema.GroupVersionResource, error) { + return schema.GroupVersionResource{}, nil +} +func (m *MockCluster) CreateClusterBom(ctx context.Context) (*bom.Result, error) { return nil, nil } +func (m *MockCluster) GetClusterVersion() string { return "" } +func (m *MockCluster) AuthByResource(resource unstructured.Unstructured) (map[string]docker.Auth, error) { + return nil, nil +} +func (m *MockCluster) Platform() k8s.Platform { return k8s.Platform{} } + +func TestGetNamespaces(t *testing.T) { + tests := []struct { + name string + includeNamespaces []string + excludeNamespaces []string + mockNamespaces []string + mockError error + expectedNamespaces []string + expectedError error + }{ + { + name: "No includeNamespaces, no excludeNamespaces", + includeNamespaces: nil, + excludeNamespaces: nil, + mockNamespaces: nil, + expectedNamespaces: []string{}, + expectedError: nil, + }, + { + name: "Include namespaces set", + includeNamespaces: []string{"namespace1", "namespace2"}, + excludeNamespaces: nil, + mockNamespaces: nil, + expectedNamespaces: []string{"namespace1", "namespace2"}, + expectedError: nil, + }, + { + name: "Exclude namespaces set but no namespaces in cluster", + includeNamespaces: nil, + excludeNamespaces: []string{"namespace3"}, + mockNamespaces: nil, + expectedNamespaces: []string{}, + expectedError: nil, + }, + { + name: "Exclude namespaces set with namespaces in cluster", + includeNamespaces: nil, + excludeNamespaces: []string{"namespace3"}, + mockNamespaces: []string{"namespace1", "namespace2", "namespace3"}, + expectedNamespaces: []string{"namespace1", "namespace2"}, + expectedError: nil, + }, + { + name: "Error in listing namespaces", + includeNamespaces: nil, + excludeNamespaces: []string{"namespace3"}, + mockError: fmt.Errorf("some error"), + expectedNamespaces: []string{}, + expectedError: fmt.Errorf("unable to list namespaces: %v", fmt.Errorf("some error")), + }, + { + name: "Forbidden error", + includeNamespaces: nil, + excludeNamespaces: []string{"namespace3"}, + mockError: errors.NewForbidden(schema.GroupResource{ + Group: "", + Resource: "namespaces", + }, "namespaces", fmt.Errorf("forbidden")), + expectedNamespaces: []string{}, + expectedError: fmt.Errorf("'exclude namespaces' option requires a cluster role with permissions to list namespaces"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := &client{ + includeNamespaces: tt.includeNamespaces, + excludeNamespaces: tt.excludeNamespaces, + cluster: newMockCluster(MockClusterDynamicClient{ + resource: MockNamespaceableResourceInterface{ + err: tt.mockError, + namespaces: tt.mockNamespaces, + }, + }), + } + + // Run the test + namespaces, err := client.getNamespaces() + + // Assert the expected values + assert.ElementsMatch(t, namespaces, tt.expectedNamespaces) + + if tt.expectedError != nil { + assert.EqualError(t, err, tt.expectedError.Error()) + } else { + assert.NoError(t, err) + } + }) + } +} + func TestIgnoreNodeByLabel(t *testing.T) { tests := []struct { name string @@ -87,3 +298,50 @@ func TestFilterResource(t *testing.T) { }) } } + +func TestInitResources(t *testing.T) { + tests := []struct { + name string + includeKinds []string + excludeKinds []string + want []string + }{ + { + name: "scan only pods", + includeKinds: []string{"pods"}, + excludeKinds: nil, + want: []string{k8s.Pods}, + }, + { + name: "skip ClusterRoles, Deployments and Ingresses", + includeKinds: nil, + excludeKinds: []string{"deployments", "ingresses", "clusterroles"}, + want: []string{ + k8s.ClusterRoleBindings, + k8s.Nodes, + k8s.Pods, + k8s.ReplicaSets, + k8s.ReplicationControllers, + k8s.StatefulSets, + k8s.DaemonSets, + k8s.CronJobs, + k8s.Jobs, + k8s.Services, + k8s.ServiceAccounts, + k8s.ConfigMaps, + k8s.Roles, + k8s.RoleBindings, + k8s.NetworkPolicies, + k8s.ResourceQuotas, + k8s.LimitRanges, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &client{excludeKinds: tt.excludeKinds, includeKinds: tt.includeKinds} + c.initResourceList() + assert.Equal(t, tt.want, c.resources) + }) + } +}