-
Notifications
You must be signed in to change notification settings - Fork 25
[VC-43403] Add filter for excluding TLS secrets without client certificates #713
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
b9ff5ef
to
13d6167
Compare
…erts - 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 <[email protected]>
13d6167
to
137686f
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
High‑level: This is a solid addition — the filter is clearly scoped, docs and examples are updated, and tests cover both config parsing and core behaviour for TLS secrets with/without client-auth EKU. Nice work tying this to least‑privilege goals.
Major behaviour issue to address
- Filters only apply on Add events. In
newDataGathererWithClient
the filter is passed toonAdd(...)
but not enforced on updates. TodayonUpdate(...)
will (re)insert the object into the cache even if it would have been excluded by the filter, because it doesn’t check filters and will add the object when not present.- Impact: a TLS secret excluded at create time can be added later on any Update event (e.g., label change) even if it still lacks ClientAuth EKU; conversely, if a secret becomes acceptable later (e.g., cert change), we want it to be included — but this should be done by evaluating the filter on the new object.
- Suggested fix:
- Apply
c.Filters
in the Update handler as well, using the new object. If any filter returns true, remove the entry from the cache if present and return; otherwise, update it. For example:- In the informer registration:
UpdateFunc: func(oldObj, newObj interface{}) { onUpdate(log, oldObj, newObj, dgCache, c.Filters...) }
- Adjust
onUpdate(...)
to accept the variadic filters; if excluded,dgCache.Delete(string(uid))
and return; else proceed to set.
- In the informer registration:
- Consider evaluating filters in Delete only for completeness (likely not necessary) but Update needs it.
- Apply
- Please also add a test that verifies: (1) a non‑client TLS secret is excluded on add; (2) an Update still keeps it excluded; (3) flipping the certificate to one with ClientAuth EKU on Update makes it appear in cache.
Logging/UX
- The filter logs at
V(4)
(e.g., "TLS Secret contains a client certificate" / "does not contain a client certificate"). The PR body suggests--log-level 2
to observe them. Confirm that log level 2 surfacesV(4)
in your logger wiring; if not, either drop to a lower V level (e.g.,V(2)
) or adjust the docs to match the effective verbosity.
Config/Marshalling
- Using an internal
[]cacheFilterFunction
inConfigDynamic
and mapping from YAML strings inUnmarshalYAML
is clean. Returning an error for unknown filter names is good for fail‑fast configuration.
Minor nits
- Typo in a comment: "eliptic" -> "elliptic" in test helper docstring.
- Consider a slightly clearer name for the helper (
hasClientAuthEKU
or similar), butisClientCertificate
is acceptable given the docstring.
Unrelated but noticed while here (optional follow‑up PR)
updateCacheGatheredResource(...)
: the condition
if deletedAt.IsZero() && !deletedAt.IsZero() { cacheObject.DeletedAt = deletedAt }
can never be true; I believe the intent was to copy a non‑zeroDeletedAt
from the existing cache entry. Probably should beif !deletedAt.IsZero() { cacheObject.DeletedAt = deletedAt }
.
Overall this looks close — the main functional gap is filtering on Update. Happy to take another look after that change and the accompanying tests.
@@ -218,7 +245,7 @@ func (c *ConfigDynamic) newDataGathererWithClient(ctx context.Context, cl dynami | |||
|
|||
registration, err := newDataGatherer.informer.AddEventHandlerWithOptions(k8scache.ResourceEventHandlerFuncs{ | |||
AddFunc: func(obj interface{}) { | |||
onAdd(log, obj, dgCache) | |||
onAdd(log, obj, dgCache, c.Filters...) | |||
}, | |||
UpdateFunc: func(oldObj, newObj interface{}) { | |||
onUpdate(log, oldObj, newObj, dgCache) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Shouldn't the filter also apply the the update func? I'm not sure, since I don't know how these AddFunc, UpdateFunc, DeleteFunc relate to the underlying client-go informer.
If it's not required, would be nice to have a comment explaining why the filtering only applies to AddFunc.
Also a test for that?
// Fast path: type assertion and kind/type checks | ||
unstructuredObj, ok := obj.(*unstructured.Unstructured) | ||
if !ok { | ||
log.V(4).Info("Object is not a Unstructured", "type", fmt.Sprintf("%T", obj)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Shouldn't this be at level 1 or 2? The Helm chart says:
# The log levels are: 0=Info, 1=Debug, 2=Trace.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the thorough implementation and tests — this is a nice addition. I left a few inline comments:
- Guard a possible nil dereference in the filter’s error logging.
- Apply filters on updates as well (and add tests) so cache stays consistent when Secret EKUs change.
- Config shape nit: prefer storing filter names on the config and mapping to funcs at construction for easier logging/marshalling.
- Small docs fix for
--log-level
to use numeric values.
Happy to iterate on any of these!
@@ -218,7 +245,7 @@ func (c *ConfigDynamic) newDataGathererWithClient(ctx context.Context, cl dynami | |||
|
|||
registration, err := newDataGatherer.informer.AddEventHandlerWithOptions(k8scache.ResourceEventHandlerFuncs{ | |||
AddFunc: func(obj interface{}) { | |||
onAdd(log, obj, dgCache) | |||
onAdd(log, obj, dgCache, c.Filters...) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Filters are applied on add events but not on updates. If a TLS Secret’s EKU changes (e.g., client → server-only), the cache will retain an entry that should now be excluded.
Proposal:
- Pass filters to
onUpdate
as well. If any filter excludesnewObj
, remove it from the cache; if it was previously excluded but now passes, add/replace it. - Add tests for both transitions (client→non-client and non-client→client) to ensure cache consistency.
// Each filter function should return true if the resource should be excluded, false otherwise. | ||
// Available filter functions: | ||
// - ExcludeTLSSecretsWithoutClientCert: ignores all TLS secrets that do not contain client certificates | ||
Filters []cacheFilterFunction `yaml:"filters"` |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Config shape nit: Filters
on ConfigDynamic
stores function pointers. That works after unmarshalling, but it makes the config awkward to marshal/log and couples config with runtime state. Consider storing []string
on the config (the names), and mapping them to functions during construction (e.g., in NewDataGatherer
).
* `ExcludeTLSSecretsWithoutClientCert`: Drops TLS secrets that do not contain any client certificates. | ||
|
||
If you find that the filters are not having the desired effect, you can enable | ||
debug logging by setting the log-level to `debug`. This will log info about why |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Minor doc fix: the project uses numeric --log-level
(0=Info, 1=Debug, 2=Trace; 6–9 HTTP trace). Suggest rewording to something like: "set --log-level=1
(Debug)" so users don’t try --log-level=debug
.
// Parse PEM certificate chain | ||
certs, err := parsePEMCertificateChain(tlsCrtBytes) | ||
if err != nil || len(certs) == 0 { | ||
log.V(4).Info("Failed to parse tls.crt as PEM encoded X.509 certificate chain", "error", err.Error()) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
possible nil dereference here: when parsePEMCertificateChain
returns (nil, nil)
(or len(certs) == 0
), err
is nil and err.Error()
will panic
This PR introduces a configurable filter for the k8s-dynamic data gatherer that excludes some Kubernetes TLS secrets;
those which do not contain a client certificate.
This is in support of the CyberArk Discovery and Context service which wants various Secret types, but in the case of X.509 certificates, they only need certificates which allow Client Authentication. So in the interests of least privilege we try to only send data that is strictly needed.
Part of: https://venafi.atlassian.net/browse/VC-43403
Testing
Run the agent and watch it log trace messages as you create TLS secrets
Run the agent in --machine-hub mode and use mitmproxy to examine the snapshot that it uploads:
Export the request (via mitmproxy)