Skip to content

Commit 13d6167

Browse files
feat(k8s): add filter to ignore TLS secrets without client certificates
- Introduce IgnoreTLSSecretsContainingNonClientCertificates filter - Update dynamic gatherer to support configurable resource filters - Extend config and examples to demonstrate filter usage - Add tests for TLS secret filtering based on client certificate presence Signed-off-by: Richard Wall <richard.wall@cyberark.com>
1 parent 7b58625 commit 13d6167

File tree

6 files changed

+326
-3
lines changed

6 files changed

+326
-3
lines changed

deploy/charts/cyberark-disco-agent/templates/configmap.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ data:
3030
- type!=kubernetes.io/dockerconfigjson
3131
- type!=bootstrap.kubernetes.io/token
3232
- type!=helm.sh/release.v1
33+
filters:
34+
- IgnoreTLSSecretsContainingNonClientCertificates
3335
- kind: k8s-dynamic
3436
name: ark/serviceaccounts
3537
config:

docs/datagatherers/k8s-dynamic.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,26 @@ when listing Secrets.
106106
- type!=bootstrap.kubernetes.io/token
107107
- type!=helm.sh/release.v1
108108
```
109+
110+
## Filters
111+
112+
You can use filters to drop certain resources based on custom logic.
113+
For example, you can drop TLS secrets that do not contain any client certificates using the `IgnoreTLSSecretsContainingNonClientCertificates` filter, as shown below:
114+
115+
```yaml
116+
- kind: "k8s-dynamic"
117+
name: "k8s/secrets"
118+
config:
119+
resource-type:
120+
version: v1
121+
resource: secrets
122+
filters:
123+
- IgnoreTLSSecretsContainingNonClientCertificates
124+
```
125+
126+
The available filters are:
127+
* `IgnoreTLSSecretsContainingNonClientCertificates`: Drops TLS secrets that do not contain any client certificates.
128+
129+
If you find that the filters are not having the desired effect, you can enable
130+
debug logging by setting the log-level to `debug`. This will log info about why
131+
certain resources are not being gathered.

examples/machinehub.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ data-gatherers:
2828
- type!=kubernetes.io/dockerconfigjson
2929
- type!=bootstrap.kubernetes.io/token
3030
- type!=helm.sh/release.v1
31+
filters:
32+
- IgnoreTLSSecretsContainingNonClientCertificates
3133

3234
# Gather Kubernetes service accounts
3335
- name: ark/serviceaccounts

pkg/datagatherer/k8s/cache.go

Lines changed: 138 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
package k8s
22

33
import (
4+
"crypto/x509"
5+
"encoding/base64"
6+
"encoding/pem"
47
"fmt"
58
"time"
69

710
"github.com/go-logr/logr"
811
"github.com/pmylund/go-cache"
12+
corev1 "k8s.io/api/core/v1"
13+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
914
"k8s.io/apimachinery/pkg/types"
1015

1116
"github.com/jetstack/preflight/api"
@@ -39,9 +44,141 @@ func logCacheUpdateFailure(log logr.Logger, obj interface{}, operation string) {
3944
log.Error(err, "Cache update failure", "operation", operation)
4045
}
4146

47+
// cacheFilterFunction is a function that takes an object and returns true if the
48+
// object should not be added to the cache, false otherwise.
49+
// This can be used to filter out objects that are not relevant for the data gatherer.
50+
type cacheFilterFunction func(logr.Logger, interface{}) bool
51+
52+
// IgnoreTLSSecretsContainingNonClientCertificates filters out all TLS secrets that do not
53+
// contain a client certificate in the `tls.crt` key.
54+
// Secrets are obtained by a DynamicClient, so they have type
55+
// *unstructured.Unstructured.
56+
func IgnoreTLSSecretsContainingNonClientCertificates(log logr.Logger, obj interface{}) bool {
57+
// Fast path: type assertion and kind/type checks
58+
unstructuredObj, ok := obj.(*unstructured.Unstructured)
59+
if !ok {
60+
log.V(4).Info("Object is not a Unstructured", "type", fmt.Sprintf("%T", obj))
61+
return false
62+
}
63+
if unstructuredObj.GetKind() != "Secret" || unstructuredObj.GetAPIVersion() != "v1" {
64+
return false
65+
}
66+
67+
log = log.WithValues("namespace", unstructuredObj.GetNamespace(), "name", unstructuredObj.GetName())
68+
69+
secretType, found, err := unstructured.NestedString(unstructuredObj.Object, "type")
70+
if err != nil || !found || secretType != string(corev1.SecretTypeTLS) {
71+
log.V(4).Info("Object is not a TLS Secret", "type", secretType)
72+
return false
73+
}
74+
75+
// Directly extract tls.crt from unstructured data (avoid conversion if possible)
76+
dataMap, found, err := unstructured.NestedMap(unstructuredObj.Object, "data")
77+
if err != nil || !found {
78+
log.V(4).Info("Secret data missing or not a map")
79+
return true
80+
}
81+
tlsCrtRaw, found := dataMap[corev1.TLSCertKey]
82+
if !found {
83+
log.V(4).Info("TLS Secret does not contain tls.crt key")
84+
return true
85+
}
86+
87+
// Decode base64 if necessary (K8s secrets store data as base64-encoded strings)
88+
var tlsCrtBytes []byte
89+
switch v := tlsCrtRaw.(type) {
90+
case string:
91+
decoded, err := base64.StdEncoding.DecodeString(v)
92+
if err != nil {
93+
log.V(4).Info("Failed to decode tls.crt base64", "error", err.Error())
94+
return true
95+
}
96+
tlsCrtBytes = decoded
97+
case []byte:
98+
tlsCrtBytes = v
99+
default:
100+
log.V(4).Info("tls.crt is not a string or byte slice", "type", fmt.Sprintf("%T", v))
101+
return true
102+
}
103+
104+
// Parse PEM certificate chain
105+
certs, err := parsePEMCertificateChain(tlsCrtBytes)
106+
if err != nil || len(certs) == 0 {
107+
log.V(4).Info("Failed to parse tls.crt as PEM encoded X.509 certificate chain", "error", err.Error())
108+
return true
109+
}
110+
111+
// Check if the leaf certificate is a client certificate
112+
if isClientCertificate(certs[0]) {
113+
log.V(4).Info("TLS Secret contains a client certificate")
114+
return false
115+
}
116+
117+
log.V(4).Info("TLS Secret does not contain a client certificate")
118+
return true
119+
}
120+
121+
// isClientCertificate checks if the given certificate is a client certificate
122+
// by checking if it has the ClientAuth EKU.
123+
func isClientCertificate(cert *x509.Certificate) bool {
124+
if cert == nil {
125+
return false
126+
}
127+
// Check if the certificate has the ClientAuth EKU
128+
for _, eku := range cert.ExtKeyUsage {
129+
if eku == x509.ExtKeyUsageClientAuth {
130+
return true
131+
}
132+
}
133+
return false
134+
}
135+
136+
// parsePEMCertificateChain parses a PEM encoded certificate chain and returns
137+
// a slice of x509.Certificate pointers. It returns an error if the data cannot
138+
// be parsed as a certificate chain.
139+
// The supplied data can contain multiple PEM blocks, the function will parse
140+
// all of them and return a slice of certificates.
141+
func parsePEMCertificateChain(data []byte) ([]*x509.Certificate, error) {
142+
// Parse the PEM encoded certificate chain
143+
var certs []*x509.Certificate
144+
var block *pem.Block
145+
rest := data
146+
for {
147+
block, rest = pem.Decode(rest)
148+
if block == nil {
149+
break
150+
}
151+
if block.Type != "CERTIFICATE" || len(block.Bytes) == 0 {
152+
continue
153+
}
154+
cert, err := x509.ParseCertificate(block.Bytes)
155+
if err != nil {
156+
return nil, fmt.Errorf("failed to parse certificate: %w", err)
157+
}
158+
certs = append(certs, cert)
159+
}
160+
if len(certs) == 0 {
161+
return nil, fmt.Errorf("no certificates found")
162+
}
163+
return certs, nil
164+
}
165+
42166
// onAdd handles the informer creation events, adding the created runtime.Object
43167
// to the data gatherer's cache. The cache key is the uid of the object
44-
func onAdd(log logr.Logger, obj interface{}, dgCache *cache.Cache) {
168+
// The object is wrapped in a GatheredResource struct.
169+
// If the object is already present in the cache, it gets replaced.
170+
// The cache key is the uid of the object
171+
// The supplied filter functions can be used to filter out objects that
172+
// should not be added to the cache.
173+
// If multiple filter functions are supplied, the object is filtered out
174+
// if any of the filter functions returns true.
175+
func onAdd(log logr.Logger, obj interface{}, dgCache *cache.Cache, filters ...cacheFilterFunction) {
176+
for _, filter := range filters {
177+
if filter != nil && filter(log, obj) {
178+
return
179+
}
180+
}
181+
45182
item, ok := obj.(cacheResource)
46183
if ok {
47184
cacheObject := &api.GatheredResource{

pkg/datagatherer/k8s/dynamic.go

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ type ConfigDynamic struct {
4343
IncludeNamespaces []string `yaml:"include-namespaces"`
4444
// FieldSelectors is a list of field selectors to use when listing this resource
4545
FieldSelectors []string `yaml:"field-selectors"`
46+
// Filters is a list of filter functions to apply to the resources before adding them to the cache.
47+
// Each filter function should return true if the resource should be excluded, false otherwise.
48+
// Available filter functions:
49+
// - IgnoreTLSSecretsContainingNonClientCertificates: ignores all TLS
50+
// secrets that do not contain client certificates
51+
Filters []cacheFilterFunction `yaml:"filters"`
4652
}
4753

4854
// UnmarshalYAML unmarshals the ConfigDynamic resolving GroupVersionResource.
@@ -57,6 +63,7 @@ func (c *ConfigDynamic) UnmarshalYAML(unmarshal func(interface{}) error) error {
5763
ExcludeNamespaces []string `yaml:"exclude-namespaces"`
5864
IncludeNamespaces []string `yaml:"include-namespaces"`
5965
FieldSelectors []string `yaml:"field-selectors"`
66+
Filters []string `yaml:"filters"`
6067
}{}
6168
err := unmarshal(&aux)
6269
if err != nil {
@@ -71,6 +78,15 @@ func (c *ConfigDynamic) UnmarshalYAML(unmarshal func(interface{}) error) error {
7178
c.IncludeNamespaces = aux.IncludeNamespaces
7279
c.FieldSelectors = aux.FieldSelectors
7380

81+
for _, filterName := range aux.Filters {
82+
switch filterName {
83+
case "IgnoreTLSSecretsContainingNonClientCertificates":
84+
c.Filters = append(c.Filters, IgnoreTLSSecretsContainingNonClientCertificates)
85+
default:
86+
return fmt.Errorf("filters contains an unknown filter function: %s. Must be one of: IgnoreTLSSecretsContainingNonClientCertificates", filterName)
87+
}
88+
}
89+
7490
return nil
7591
}
7692

@@ -107,6 +123,9 @@ type sharedInformerFunc func(informers.SharedInformerFactory) k8scache.SharedInd
107123

108124
// kubernetesNativeResources map of the native kubernetes resources, linking each resource to a sharedInformerFunc for that resource.
109125
// secrets are still treated as unstructured rather than corev1.Secret, for a faster unmarshaling
126+
//
127+
// TODO(wallrj): What does "faster unmarshaling" mean in this context? Is it
128+
// actually faster? If so, how much faster? Is it worth the loss of type safety?
110129
var kubernetesNativeResources = map[schema.GroupVersionResource]sharedInformerFunc{
111130
corev1.SchemeGroupVersion.WithResource("pods"): func(sharedFactory informers.SharedInformerFactory) k8scache.SharedIndexInformer {
112131
return sharedFactory.Core().V1().Pods().Informer()
@@ -144,6 +163,16 @@ var kubernetesNativeResources = map[schema.GroupVersionResource]sharedInformerFu
144163
}
145164

146165
// NewDataGatherer constructs a new instance of the generic K8s data-gatherer for the provided
166+
// configuration. Returns an error if the configuration is invalid or
167+
// the data-gatherer cannot be constructed.
168+
// If the GroupVersionResource is a native Kubernetes resource, the data gatherer will use
169+
// a typed clientset and SharedInformerFactory, otherwise it will use a dynamic client and
170+
// dynamic informer factory.
171+
// Secret is a special case, it is a native resource but it will be treated as unstructured
172+
// rather than corev1.Secret, for a faster unmarshaling.
173+
//
174+
// TODO(wallrj): What does "faster unmarshaling" mean in this context? Is it
175+
// actually faster? If so, how much faster? Is it worth the loss of type safety?
147176
func (c *ConfigDynamic) NewDataGatherer(ctx context.Context) (datagatherer.DataGatherer, error) {
148177
if isNativeResource(c.GroupVersionResource) {
149178
clientset, err := NewClientSet(c.KubeConfigPath)
@@ -167,6 +196,7 @@ func (c *ConfigDynamic) newDataGathererWithClient(ctx context.Context, cl dynami
167196
if err := c.validate(); err != nil {
168197
return nil, err
169198
}
199+
170200
// init shared informer for selected namespaces
171201
fieldSelector := generateExcludedNamespacesFieldSelector(c.ExcludeNamespaces)
172202

@@ -218,7 +248,7 @@ func (c *ConfigDynamic) newDataGathererWithClient(ctx context.Context, cl dynami
218248

219249
registration, err := newDataGatherer.informer.AddEventHandlerWithOptions(k8scache.ResourceEventHandlerFuncs{
220250
AddFunc: func(obj interface{}) {
221-
onAdd(log, obj, dgCache)
251+
onAdd(log, obj, dgCache, c.Filters...)
222252
},
223253
UpdateFunc: func(oldObj, newObj interface{}) {
224254
onUpdate(log, oldObj, newObj, dgCache)

0 commit comments

Comments
 (0)