Skip to content
This repository was archived by the owner on May 14, 2026. It is now read-only.

Commit f70cbd1

Browse files
authored
fix: apply HideSecretData to server-side diff results for Secrets (cherry-pick argoproj/argo-cd#27598) (#804)
Signed-off-by: Peter Jiang <peterjiang823@gmail.com>
1 parent 5baed56 commit f70cbd1

2 files changed

Lines changed: 141 additions & 2 deletions

File tree

pkg/diff/diff.go

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,13 +189,23 @@ func serverSideDiff(config, live *unstructured.Unstructured, opts ...Option) (*D
189189
Normalize(predictedLive, opts...)
190190
unstructured.RemoveNestedField(predictedLive.Object, "metadata", "managedFields")
191191

192+
Normalize(live, opts...)
193+
unstructured.RemoveNestedField(live.Object, "metadata", "managedFields")
194+
195+
if isCoreSecret(config) {
196+
// Mask Secret data symmetrically before comparison.
197+
// Equal values get equal placeholders, different values get different placeholders.
198+
predictedLive, live, err = HideSecretData(predictedLive, live, nil)
199+
if err != nil {
200+
return nil, fmt.Errorf("error hiding secret data for resource %s/%s: %w", config.GetKind(), config.GetName(), err)
201+
}
202+
}
203+
192204
predictedLiveBytes, err := json.Marshal(predictedLive)
193205
if err != nil {
194206
return nil, fmt.Errorf("error marshaling predicted live for resource %s/%s: %w", config.GetKind(), config.GetName(), err)
195207
}
196208

197-
Normalize(live, opts...)
198-
unstructured.RemoveNestedField(live.Object, "metadata", "managedFields")
199209
liveBytes, err := json.Marshal(live)
200210
if err != nil {
201211
return nil, fmt.Errorf("error marshaling live resource %s/%s: %w", config.GetKind(), config.GetName(), err)
@@ -355,6 +365,15 @@ func jsonStrToUnstructured(jsonString string) (*unstructured.Unstructured, error
355365
return &unstructured.Unstructured{Object: res}, nil
356366
}
357367

368+
// isCoreSecret reports whether obj is a core/v1 Secret (Group="" and Kind="Secret").
369+
func isCoreSecret(obj *unstructured.Unstructured) bool {
370+
if obj == nil {
371+
return false
372+
}
373+
gvk := obj.GroupVersionKind()
374+
return gvk.Group == "" && gvk.Kind == "Secret"
375+
}
376+
358377
// StructuredMergeDiff will calculate the diff using the structured-merge-diff
359378
// k8s library (https://github.com/kubernetes-sigs/structured-merge-diff).
360379
func StructuredMergeDiff(config, live *unstructured.Unstructured, gvkParser *managedfields.GvkParser, manager string) (*DiffResult, error) {

pkg/diff/diff_test.go

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1166,6 +1166,126 @@ func TestServerSideDiff(t *testing.T) {
11661166
assert.Empty(t, predictedDeploy.Annotations[AnnotationLastAppliedConfig])
11671167
assert.Empty(t, liveDeploy.Annotations[AnnotationLastAppliedConfig])
11681168
})
1169+
1170+
t.Run("will mask Secret data symmetrically so identical values do not produce a spurious diff", func(t *testing.T) {
1171+
t.Parallel()
1172+
1173+
desired := buildSecret("test-secret", "default", map[string]string{"password": "vault:secret/foo"}, nil)
1174+
live := buildSecret("test-secret", "default", map[string]string{"password": "injected-by-webhook"}, nil)
1175+
predictedLiveJSON := mustMarshalJSON(t, buildSecret("test-secret", "default", map[string]string{"password": "injected-by-webhook"}, nil))
1176+
1177+
opts := append(buildOpts(predictedLiveJSON), WithIgnoreMutationWebhook(false))
1178+
result, err := serverSideDiff(desired, live, opts...)
1179+
require.NoError(t, err)
1180+
require.NotNil(t, result)
1181+
1182+
assert.False(t, result.Modified, "identical secret values on both sides must not be flagged as modified after masking")
1183+
1184+
predictedData := mustGetSecretData(t, result.PredictedLive)
1185+
liveData := mustGetSecretData(t, result.NormalizedLive)
1186+
assert.Equal(t, "++++++++", predictedData["password"], "predicted data must be masked, not raw")
1187+
assert.Equal(t, "++++++++", liveData["password"], "live data must be masked, not raw")
1188+
})
1189+
1190+
t.Run("will keep Secret data masked but still detect genuine value differences", func(t *testing.T) {
1191+
t.Parallel()
1192+
1193+
desired := buildSecret("test-secret", "default", map[string]string{"password": "vault:secret/foo"}, nil)
1194+
live := buildSecret("test-secret", "default", map[string]string{"password": "old-value"}, nil)
1195+
predictedLiveJSON := mustMarshalJSON(t, buildSecret("test-secret", "default", map[string]string{"password": "new-value"}, nil))
1196+
1197+
opts := append(buildOpts(predictedLiveJSON), WithIgnoreMutationWebhook(false))
1198+
result, err := serverSideDiff(desired, live, opts...)
1199+
require.NoError(t, err)
1200+
require.NotNil(t, result)
1201+
1202+
assert.True(t, result.Modified, "different secret values must still be flagged as modified")
1203+
1204+
predictedData := mustGetSecretData(t, result.PredictedLive)
1205+
liveData := mustGetSecretData(t, result.NormalizedLive)
1206+
// HideSecretData yields different placeholder lengths for different values, so the
1207+
// data field is masked on both sides and the two placeholders differ.
1208+
assert.NotEqual(t, "new-value", predictedData["password"], "raw new value must not leak into PredictedLive")
1209+
assert.NotEqual(t, "old-value", liveData["password"], "raw old value must not leak into NormalizedLive")
1210+
assert.NotEqual(t, predictedData["password"], liveData["password"], "differing values must yield differing placeholders")
1211+
})
1212+
1213+
t.Run("will detect Secret key additions and removals", func(t *testing.T) {
1214+
t.Parallel()
1215+
1216+
desired := buildSecret("test-secret", "default", map[string]string{"password": "x", "token": "y"}, nil)
1217+
live := buildSecret("test-secret", "default", map[string]string{"password": "x"}, nil)
1218+
predictedLiveJSON := mustMarshalJSON(t, buildSecret("test-secret", "default", map[string]string{"password": "x", "token": "y"}, nil))
1219+
1220+
opts := append(buildOpts(predictedLiveJSON), WithIgnoreMutationWebhook(false))
1221+
result, err := serverSideDiff(desired, live, opts...)
1222+
require.NoError(t, err)
1223+
require.NotNil(t, result)
1224+
1225+
assert.True(t, result.Modified, "added Secret keys must still be flagged as modified after masking")
1226+
})
1227+
1228+
t.Run("will not mask non-core Secret resources", func(t *testing.T) {
1229+
// Resources whose Kind is "Secret" but whose Group is non-empty (e.g. CRDs)
1230+
// must not be touched by the core/v1 Secret masking path.
1231+
t.Parallel()
1232+
1233+
desired := buildSecret("test-secret", "default", map[string]string{"password": "raw-value"}, nil)
1234+
desired.SetAPIVersion("custom.io/v1")
1235+
live := buildSecret("test-secret", "default", map[string]string{"password": "raw-value"}, nil)
1236+
live.SetAPIVersion("custom.io/v1")
1237+
predictedLiveJSON := mustMarshalJSON(t, desired)
1238+
1239+
opts := append(buildOpts(predictedLiveJSON), WithIgnoreMutationWebhook(false))
1240+
result, err := serverSideDiff(desired, live, opts...)
1241+
require.NoError(t, err)
1242+
require.NotNil(t, result)
1243+
1244+
predictedData := mustGetSecretData(t, result.PredictedLive)
1245+
assert.Equal(t, "raw-value", predictedData["password"], "non-core Secret data must be left untouched")
1246+
})
1247+
}
1248+
1249+
// buildSecret returns a core/v1 Secret as an *unstructured.Unstructured.
1250+
func buildSecret(name, namespace string, data map[string]string, annotations map[string]string) *unstructured.Unstructured {
1251+
dataField := make(map[string]any, len(data))
1252+
for k, v := range data {
1253+
dataField[k] = v
1254+
}
1255+
metadata := map[string]any{
1256+
"name": name,
1257+
"namespace": namespace,
1258+
}
1259+
if len(annotations) > 0 {
1260+
annField := make(map[string]any, len(annotations))
1261+
for k, v := range annotations {
1262+
annField[k] = v
1263+
}
1264+
metadata["annotations"] = annField
1265+
}
1266+
return &unstructured.Unstructured{Object: map[string]any{
1267+
"apiVersion": "v1",
1268+
"kind": "Secret",
1269+
"metadata": metadata,
1270+
"type": "Opaque",
1271+
"data": dataField,
1272+
}}
1273+
}
1274+
1275+
func mustMarshalJSON(t *testing.T, obj *unstructured.Unstructured) string {
1276+
t.Helper()
1277+
bytes, err := json.Marshal(obj)
1278+
require.NoError(t, err)
1279+
return string(bytes)
1280+
}
1281+
1282+
func mustGetSecretData(t *testing.T, secretBytes []byte) map[string]any {
1283+
t.Helper()
1284+
var obj map[string]any
1285+
require.NoError(t, json.Unmarshal(secretBytes, &obj))
1286+
data, ok := obj["data"].(map[string]any)
1287+
require.True(t, ok, "expected data field to be a map")
1288+
return data
11691289
}
11701290

11711291
// testIgnoreDifferencesNormalizer implements a simple normalizer that removes specified fields

0 commit comments

Comments
 (0)