Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions pkg/agent/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
47 changes: 47 additions & 0 deletions pkg/datagatherer/k8sdynamic/dynamic.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -555,6 +559,10 @@ func (g *DataGathererDynamic) redactList(ctx context.Context, list []*api.Gather
}
}

if g.IncludeLastModifiedTime {
setLastModifiedTime(resource)
}

// Redact to only selected fields
if err := Select(secretSelectedFields, resource); err != nil {
return err
Expand Down Expand Up @@ -617,6 +625,45 @@ 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 time.Time
var latestTimeStr string
for _, entry := range managedFieldsRaw {
entryMap, ok := entry.(map[string]any)
if !ok {
continue
}
timeVal, ok := entryMap["time"].(string)
if !ok || timeVal == "" {
continue
}
parsed, err := time.Parse(time.RFC3339, timeVal)
if err != nil {
continue
}
if parsed.After(latestTime) {
latestTime = parsed
latestTimeStr = timeVal
}
}

if latestTimeStr == "" {
return
}

_ = unstructured.SetNestedField(resource.Object, latestTimeStr, 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.
Expand Down
131 changes: 131 additions & 0 deletions pkg/datagatherer/k8sdynamic/dynamic_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
}
1 change: 1 addition & 0 deletions pkg/datagatherer/k8sdynamic/fieldfilter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions pkg/datagatherer/k8sdynamic/fieldfilter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
Loading