Skip to content

Commit 137686f

Browse files
feat(k8s-dynamic): add filter to exclude TLS secrets without client certs
- Introduce ExcludeTLSSecretsWithoutClientCert filter for k8s-dynamic gatherer - Update config, docs, and examples to support and demonstrate filter usage - Implement filter logic to exclude TLS secrets lacking client certificates - Extend tests to cover filtering of TLS secrets based on certificate EKU - Improve logging for filter decisions and error cases Signed-off-by: Richard Wall <richard.wall@cyberark.com>
1 parent 7b58625 commit 137686f

File tree

6 files changed

+441
-3
lines changed

6 files changed

+441
-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+
- ExcludeTLSSecretsWithoutClientCert
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 `ExcludeTLSSecretsWithoutClientCert` 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+
- ExcludeTLSSecretsWithoutClientCert
124+
```
125+
126+
The available filters are:
127+
* `ExcludeTLSSecretsWithoutClientCert`: 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+
- ExcludeTLSSecretsWithoutClientCert
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 can be used to filter out objects
48+
// that should not be added to the cache. If the function returns true, the
49+
// object is filtered out.
50+
type cacheFilterFunction func(logr.Logger, interface{}) bool
51+
52+
// excludeTLSSecretsWithoutClientCert 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 excludeTLSSecretsWithoutClientCert(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: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ 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+
// - ExcludeTLSSecretsWithoutClientCert: ignores all TLS secrets that do not contain client certificates
50+
Filters []cacheFilterFunction `yaml:"filters"`
4651
}
4752

4853
// UnmarshalYAML unmarshals the ConfigDynamic resolving GroupVersionResource.
@@ -57,6 +62,7 @@ func (c *ConfigDynamic) UnmarshalYAML(unmarshal func(interface{}) error) error {
5762
ExcludeNamespaces []string `yaml:"exclude-namespaces"`
5863
IncludeNamespaces []string `yaml:"include-namespaces"`
5964
FieldSelectors []string `yaml:"field-selectors"`
65+
Filters []string `yaml:"filters"`
6066
}{}
6167
err := unmarshal(&aux)
6268
if err != nil {
@@ -71,6 +77,15 @@ func (c *ConfigDynamic) UnmarshalYAML(unmarshal func(interface{}) error) error {
7177
c.IncludeNamespaces = aux.IncludeNamespaces
7278
c.FieldSelectors = aux.FieldSelectors
7379

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

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

108123
// kubernetesNativeResources map of the native kubernetes resources, linking each resource to a sharedInformerFunc for that resource.
109124
// secrets are still treated as unstructured rather than corev1.Secret, for a faster unmarshaling
125+
//
126+
// TODO(wallrj): What does "faster unmarshaling" mean in this context? If
127+
// unstructured is faster then why not use it for all resources?
110128
var kubernetesNativeResources = map[schema.GroupVersionResource]sharedInformerFunc{
111129
corev1.SchemeGroupVersion.WithResource("pods"): func(sharedFactory informers.SharedInformerFactory) k8scache.SharedIndexInformer {
112130
return sharedFactory.Core().V1().Pods().Informer()
@@ -144,6 +162,15 @@ var kubernetesNativeResources = map[schema.GroupVersionResource]sharedInformerFu
144162
}
145163

146164
// NewDataGatherer constructs a new instance of the generic K8s data-gatherer for the provided
165+
// configuration.
166+
//
167+
// If the GroupVersionResource is a native Kubernetes resource, the data
168+
// gatherer will use a typed clientset and SharedInformerFactory, otherwise it
169+
// will use a dynamic client and dynamic informer factory, for CRDs like those
170+
// of cert-manager.
171+
//
172+
// Secret is a special case, it is a native resource but it will be treated as unstructured
173+
// rather than corev1.Secret, for "faster unmarshaling".
147174
func (c *ConfigDynamic) NewDataGatherer(ctx context.Context) (datagatherer.DataGatherer, error) {
148175
if isNativeResource(c.GroupVersionResource) {
149176
clientset, err := NewClientSet(c.KubeConfigPath)
@@ -218,7 +245,7 @@ func (c *ConfigDynamic) newDataGathererWithClient(ctx context.Context, cl dynami
218245

219246
registration, err := newDataGatherer.informer.AddEventHandlerWithOptions(k8scache.ResourceEventHandlerFuncs{
220247
AddFunc: func(obj interface{}) {
221-
onAdd(log, obj, dgCache)
248+
onAdd(log, obj, dgCache, c.Filters...)
222249
},
223250
UpdateFunc: func(oldObj, newObj interface{}) {
224251
onUpdate(log, oldObj, newObj, dgCache)

0 commit comments

Comments
 (0)