From b8b5014c6566fbb72e8ee657c39558c97deac5c9 Mon Sep 17 00:00:00 2001 From: eshalev Date: Tue, 26 May 2026 16:35:12 +0300 Subject: [PATCH 1/2] add _lastModifiedTime field to secret snapshots Extract the most recent time from metadata.managedFields and include it as a synthetic _lastModifiedTime field on Secret objects sent to the backend. This enables detection of stale or unrotated secrets. --- pkg/datagatherer/k8sdynamic/dynamic.go | 35 +++++ pkg/datagatherer/k8sdynamic/dynamic_test.go | 131 ++++++++++++++++++ pkg/datagatherer/k8sdynamic/fieldfilter.go | 1 + .../k8sdynamic/fieldfilter_test.go | 6 +- 4 files changed, 171 insertions(+), 2 deletions(-) diff --git a/pkg/datagatherer/k8sdynamic/dynamic.go b/pkg/datagatherer/k8sdynamic/dynamic.go index 0f2532f2..50ba6488 100644 --- a/pkg/datagatherer/k8sdynamic/dynamic.go +++ b/pkg/datagatherer/k8sdynamic/dynamic.go @@ -555,6 +555,8 @@ func (g *DataGathererDynamic) redactList(ctx context.Context, list []*api.Gather } } + setLastModifiedTime(resource) + // Redact to only selected fields if err := Select(secretSelectedFields, resource); err != nil { return err @@ -617,6 +619,39 @@ const encryptedDataFieldName = "_encryptedData" var encryptedDataField = FieldPath{encryptedDataFieldName} +const lastModifiedTimeFieldName = "_lastModifiedTime" + +// setLastModifiedTime extracts the most recent time from metadata.managedFields +// and sets it as a top-level synthetic field on the resource. +// This must be called before Select(), which removes managedFields. +func setLastModifiedTime(resource *unstructured.Unstructured) { + managedFieldsRaw, found, err := unstructured.NestedSlice(resource.Object, "metadata", "managedFields") + if err != nil || !found || len(managedFieldsRaw) == 0 { + return + } + + var latestTime string + for _, entry := range managedFieldsRaw { + entryMap, ok := entry.(map[string]any) + if !ok { + continue + } + timeVal, ok := entryMap["time"].(string) + if !ok || timeVal == "" { + continue + } + if timeVal > latestTime { + latestTime = timeVal + } + } + + if latestTime == "" { + return + } + + _ = unstructured.SetNestedField(resource.Object, latestTime, lastModifiedTimeFieldName) +} + // encryptDataField encrypts the `data` field of the given secret and stores the encrypted data // in a new field with the name of [encryptedDataFieldName]. The original `data` field is left unchanged, on the // assumption that it will be redacted after the encryption step. diff --git a/pkg/datagatherer/k8sdynamic/dynamic_test.go b/pkg/datagatherer/k8sdynamic/dynamic_test.go index b94d1d5b..e724730f 100644 --- a/pkg/datagatherer/k8sdynamic/dynamic_test.go +++ b/pkg/datagatherer/k8sdynamic/dynamic_test.go @@ -1828,3 +1828,134 @@ func TestValidate_CombinedSelectors(t *testing.T) { }) } } + +func TestSetLastModifiedTime(t *testing.T) { + tests := []struct { + name string + resource *unstructured.Unstructured + expected string + }{ + { + name: "picks latest time from multiple entries", + resource: &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": map[string]any{ + "name": "my-secret", + "namespace": "default", + "managedFields": []any{ + map[string]any{ + "manager": "kubectl", + "operation": "Apply", + "time": "2025-01-10T10:00:00Z", + }, + map[string]any{ + "manager": "kubectl", + "operation": "Update", + "time": "2026-05-19T17:06:59Z", + }, + map[string]any{ + "manager": "kube-controller-manager", + "operation": "Update", + "time": "2025-06-01T12:00:00Z", + }, + }, + }, + }, + }, + expected: "2026-05-19T17:06:59Z", + }, + { + name: "single entry", + resource: &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": map[string]any{ + "name": "my-secret", + "namespace": "default", + "managedFields": []any{ + map[string]any{ + "manager": "kubectl", + "operation": "Apply", + "time": "2025-03-15T08:30:00Z", + }, + }, + }, + }, + }, + expected: "2025-03-15T08:30:00Z", + }, + { + name: "no managedFields - field not set", + resource: &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": map[string]any{ + "name": "my-secret", + "namespace": "default", + }, + }, + }, + expected: "", + }, + { + name: "empty managedFields - field not set", + resource: &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": map[string]any{ + "name": "my-secret", + "namespace": "default", + "managedFields": []any{}, + }, + }, + }, + expected: "", + }, + { + name: "entries without time field are skipped", + resource: &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": map[string]any{ + "name": "my-secret", + "namespace": "default", + "managedFields": []any{ + map[string]any{ + "manager": "kubectl", + "operation": "Apply", + }, + map[string]any{ + "manager": "kubectl", + "operation": "Update", + "time": "2025-11-20T15:00:00Z", + }, + }, + }, + }, + }, + expected: "2025-11-20T15:00:00Z", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setLastModifiedTime(tt.resource) + + val, found, err := unstructured.NestedString(tt.resource.Object, lastModifiedTimeFieldName) + require.NoError(t, err) + + if tt.expected == "" { + assert.False(t, found, "expected _lastModifiedTime to not be set") + } else { + assert.True(t, found, "expected _lastModifiedTime to be set") + assert.Equal(t, tt.expected, val) + } + }) + } +} diff --git a/pkg/datagatherer/k8sdynamic/fieldfilter.go b/pkg/datagatherer/k8sdynamic/fieldfilter.go index 392c75fd..e9ea77b5 100644 --- a/pkg/datagatherer/k8sdynamic/fieldfilter.go +++ b/pkg/datagatherer/k8sdynamic/fieldfilter.go @@ -30,6 +30,7 @@ var SecretSelectedFields = []FieldPath{ {"data", "tls.crt"}, {"data", "ca.crt"}, {"data", "conjur-map"}, + {lastModifiedTimeFieldName}, } // RouteSelectedFields is the list of fields sent from OpenShift Route objects to the diff --git a/pkg/datagatherer/k8sdynamic/fieldfilter_test.go b/pkg/datagatherer/k8sdynamic/fieldfilter_test.go index 097e735f..b44d194e 100644 --- a/pkg/datagatherer/k8sdynamic/fieldfilter_test.go +++ b/pkg/datagatherer/k8sdynamic/fieldfilter_test.go @@ -33,7 +33,8 @@ func TestSelect(t *testing.T) { "finalizers": []string{"example.com/fake-finalizer"}, "generation": 11, }, - "type": "kubernetes.io/tls", + "type": "kubernetes.io/tls", + "_lastModifiedTime": "2025-08-15T00:00:01Z", "data": map[string]any{ "tls.crt": "cert data", "tls.key": "secret", @@ -60,7 +61,8 @@ func TestSelect(t *testing.T) { "creationTimestamp": "2025-08-15T00:00:01Z", "deletionTimestamp": "2025-08-15T00:00:02Z", }, - "type": "kubernetes.io/tls", + "type": "kubernetes.io/tls", + "_lastModifiedTime": "2025-08-15T00:00:01Z", "data": map[string]any{ // The "tls.key" is ignored. "tls.crt": "cert data", From fd1ebe3481d2d8eea59cb5384ab8355922cc9a20 Mon Sep 17 00:00:00 2001 From: eshalev Date: Tue, 26 May 2026 19:23:13 +0300 Subject: [PATCH 2/2] fix comments and gci --- pkg/agent/run.go | 5 ++++ pkg/datagatherer/k8sdynamic/dynamic.go | 24 ++++++++++++++----- .../k8sdynamic/fieldfilter_test.go | 4 ++-- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/pkg/agent/run.go b/pkg/agent/run.go index 6f3ce0b4..461cd03a 100644 --- a/pkg/agent/run.go +++ b/pkg/agent/run.go @@ -205,6 +205,11 @@ func Run(cmd *cobra.Command, args []string) (returnErr error) { log.Info("Secret encryption enabled for datagatherer") dynDg.Encryptor = encryptor } + + _, isCyberArk := preflightClient.(*client.CyberArkClient) + if isCyberArk && gvr.Resource == "secrets" && gvr.Group == "" { + dynDg.IncludeLastModifiedTime = true + } } log.V(logs.Debug).Info("Starting DataGatherer", "name", dgConfig.Name) diff --git a/pkg/datagatherer/k8sdynamic/dynamic.go b/pkg/datagatherer/k8sdynamic/dynamic.go index 50ba6488..6eed8db2 100644 --- a/pkg/datagatherer/k8sdynamic/dynamic.go +++ b/pkg/datagatherer/k8sdynamic/dynamic.go @@ -381,6 +381,10 @@ type DataGathererDynamic struct { // Encryptor, if non-nil, will be used to envelope encrypt Secret data. // If nil, Secret data will be redacted. Encryptor envelope.Encryptor + + // IncludeLastModifiedTime, if true, extracts the most recent time from + // metadata.managedFields and includes it as _lastModifiedTime on Secrets. + IncludeLastModifiedTime bool } func (g *DataGathererDynamic) GVR() schema.GroupVersionResource { @@ -555,7 +559,9 @@ func (g *DataGathererDynamic) redactList(ctx context.Context, list []*api.Gather } } - setLastModifiedTime(resource) + if g.IncludeLastModifiedTime { + setLastModifiedTime(resource) + } // Redact to only selected fields if err := Select(secretSelectedFields, resource); err != nil { @@ -630,7 +636,8 @@ func setLastModifiedTime(resource *unstructured.Unstructured) { return } - var latestTime string + var latestTime time.Time + var latestTimeStr string for _, entry := range managedFieldsRaw { entryMap, ok := entry.(map[string]any) if !ok { @@ -640,16 +647,21 @@ func setLastModifiedTime(resource *unstructured.Unstructured) { if !ok || timeVal == "" { continue } - if timeVal > latestTime { - latestTime = timeVal + parsed, err := time.Parse(time.RFC3339, timeVal) + if err != nil { + continue + } + if parsed.After(latestTime) { + latestTime = parsed + latestTimeStr = timeVal } } - if latestTime == "" { + if latestTimeStr == "" { return } - _ = unstructured.SetNestedField(resource.Object, latestTime, lastModifiedTimeFieldName) + _ = unstructured.SetNestedField(resource.Object, latestTimeStr, lastModifiedTimeFieldName) } // encryptDataField encrypts the `data` field of the given secret and stores the encrypted data diff --git a/pkg/datagatherer/k8sdynamic/fieldfilter_test.go b/pkg/datagatherer/k8sdynamic/fieldfilter_test.go index b44d194e..c7c9aaeb 100644 --- a/pkg/datagatherer/k8sdynamic/fieldfilter_test.go +++ b/pkg/datagatherer/k8sdynamic/fieldfilter_test.go @@ -33,7 +33,7 @@ func TestSelect(t *testing.T) { "finalizers": []string{"example.com/fake-finalizer"}, "generation": 11, }, - "type": "kubernetes.io/tls", + "type": "kubernetes.io/tls", "_lastModifiedTime": "2025-08-15T00:00:01Z", "data": map[string]any{ "tls.crt": "cert data", @@ -61,7 +61,7 @@ func TestSelect(t *testing.T) { "creationTimestamp": "2025-08-15T00:00:01Z", "deletionTimestamp": "2025-08-15T00:00:02Z", }, - "type": "kubernetes.io/tls", + "type": "kubernetes.io/tls", "_lastModifiedTime": "2025-08-15T00:00:01Z", "data": map[string]any{ // The "tls.key" is ignored.