Skip to content

Commit a7b70b5

Browse files
pjiang-devdownfa11
andauthored
fix(gitops-engine): apply HideSecretData to server-side diff results for Secrets (#27598)
Signed-off-by: downfa11 <downfa11@naver.com> Signed-off-by: Peter Jiang <peterjiang823@gmail.com> Signed-off-by: Peter Jiang <35584807+pjiang-dev@users.noreply.github.com> Co-authored-by: downfa11 <downfa11@naver.com>
1 parent 5b87aa8 commit a7b70b5

2 files changed

Lines changed: 141 additions & 3 deletions

File tree

gitops-engine/pkg/diff/diff.go

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -190,14 +190,24 @@ func serverSideDiff(config, live *unstructured.Unstructured, opts ...Option) (*D
190190
unstructured.RemoveNestedField(predictedLive.Object, "metadata", "managedFields")
191191
unstructured.RemoveNestedField(predictedLive.Object, "metadata", "resourceVersion")
192192

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

198-
Normalize(live, opts...)
199-
unstructured.RemoveNestedField(live.Object, "metadata", "managedFields")
200-
unstructured.RemoveNestedField(live.Object, "metadata", "resourceVersion")
201211
liveBytes, err := json.Marshal(live)
202212
if err != nil {
203213
return nil, fmt.Errorf("error marshaling live resource %s/%s: %w", config.GetKind(), config.GetName(), err)
@@ -357,6 +367,15 @@ func jsonStrToUnstructured(jsonString string) (*unstructured.Unstructured, error
357367
return &unstructured.Unstructured{Object: res}, nil
358368
}
359369

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

gitops-engine/pkg/diff/diff_test.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1254,6 +1254,125 @@ func TestServerSideDiff(t *testing.T) {
12541254
assert.Contains(t, liveData, "key3", "key3 should still be in live state")
12551255
})
12561256

1257+
t.Run("will mask Secret data symmetrically so identical values do not produce a spurious diff", func(t *testing.T) {
1258+
t.Parallel()
1259+
1260+
desired := buildSecret("test-secret", "default", map[string]string{"password": "vault:secret/foo"}, nil)
1261+
live := buildSecret("test-secret", "default", map[string]string{"password": "injected-by-webhook"}, nil)
1262+
predictedLiveJSON := mustMarshalJSON(t, buildSecret("test-secret", "default", map[string]string{"password": "injected-by-webhook"}, nil))
1263+
1264+
opts := append(buildOpts(predictedLiveJSON), WithIgnoreMutationWebhook(false))
1265+
result, err := serverSideDiff(desired, live, opts...)
1266+
require.NoError(t, err)
1267+
require.NotNil(t, result)
1268+
1269+
assert.False(t, result.Modified, "identical secret values on both sides must not be flagged as modified after masking")
1270+
1271+
predictedData := mustGetSecretData(t, result.PredictedLive)
1272+
liveData := mustGetSecretData(t, result.NormalizedLive)
1273+
assert.Equal(t, "++++++++", predictedData["password"], "predicted data must be masked, not raw")
1274+
assert.Equal(t, "++++++++", liveData["password"], "live data must be masked, not raw")
1275+
})
1276+
1277+
t.Run("will keep Secret data masked but still detect genuine value differences", func(t *testing.T) {
1278+
t.Parallel()
1279+
1280+
desired := buildSecret("test-secret", "default", map[string]string{"password": "vault:secret/foo"}, nil)
1281+
live := buildSecret("test-secret", "default", map[string]string{"password": "old-value"}, nil)
1282+
predictedLiveJSON := mustMarshalJSON(t, buildSecret("test-secret", "default", map[string]string{"password": "new-value"}, nil))
1283+
1284+
opts := append(buildOpts(predictedLiveJSON), WithIgnoreMutationWebhook(false))
1285+
result, err := serverSideDiff(desired, live, opts...)
1286+
require.NoError(t, err)
1287+
require.NotNil(t, result)
1288+
1289+
assert.True(t, result.Modified, "different secret values must still be flagged as modified")
1290+
1291+
predictedData := mustGetSecretData(t, result.PredictedLive)
1292+
liveData := mustGetSecretData(t, result.NormalizedLive)
1293+
// HideSecretData yields different placeholder lengths for different values, so the
1294+
// data field is masked on both sides and the two placeholders differ.
1295+
assert.NotEqual(t, "new-value", predictedData["password"], "raw new value must not leak into PredictedLive")
1296+
assert.NotEqual(t, "old-value", liveData["password"], "raw old value must not leak into NormalizedLive")
1297+
assert.NotEqual(t, predictedData["password"], liveData["password"], "differing values must yield differing placeholders")
1298+
})
1299+
1300+
t.Run("will detect Secret key additions and removals", func(t *testing.T) {
1301+
t.Parallel()
1302+
1303+
desired := buildSecret("test-secret", "default", map[string]string{"password": "x", "token": "y"}, nil)
1304+
live := buildSecret("test-secret", "default", map[string]string{"password": "x"}, nil)
1305+
predictedLiveJSON := mustMarshalJSON(t, buildSecret("test-secret", "default", map[string]string{"password": "x", "token": "y"}, nil))
1306+
1307+
opts := append(buildOpts(predictedLiveJSON), WithIgnoreMutationWebhook(false))
1308+
result, err := serverSideDiff(desired, live, opts...)
1309+
require.NoError(t, err)
1310+
require.NotNil(t, result)
1311+
1312+
assert.True(t, result.Modified, "added Secret keys must still be flagged as modified after masking")
1313+
})
1314+
1315+
t.Run("will not mask non-core Secret resources", func(t *testing.T) {
1316+
// Resources whose Kind is "Secret" but whose Group is non-empty (e.g. CRDs)
1317+
// must not be touched by the core/v1 Secret masking path.
1318+
t.Parallel()
1319+
1320+
desired := buildSecret("test-secret", "default", map[string]string{"password": "raw-value"}, nil)
1321+
desired.SetAPIVersion("custom.io/v1")
1322+
live := buildSecret("test-secret", "default", map[string]string{"password": "raw-value"}, nil)
1323+
live.SetAPIVersion("custom.io/v1")
1324+
predictedLiveJSON := mustMarshalJSON(t, desired)
1325+
1326+
opts := append(buildOpts(predictedLiveJSON), WithIgnoreMutationWebhook(false))
1327+
result, err := serverSideDiff(desired, live, opts...)
1328+
require.NoError(t, err)
1329+
require.NotNil(t, result)
1330+
1331+
predictedData := mustGetSecretData(t, result.PredictedLive)
1332+
assert.Equal(t, "raw-value", predictedData["password"], "non-core Secret data must be left untouched")
1333+
})
1334+
}
1335+
1336+
// buildSecret returns a core/v1 Secret as an *unstructured.Unstructured.
1337+
func buildSecret(name, namespace string, data map[string]string, annotations map[string]string) *unstructured.Unstructured {
1338+
dataField := make(map[string]any, len(data))
1339+
for k, v := range data {
1340+
dataField[k] = v
1341+
}
1342+
metadata := map[string]any{
1343+
"name": name,
1344+
"namespace": namespace,
1345+
}
1346+
if len(annotations) > 0 {
1347+
annField := make(map[string]any, len(annotations))
1348+
for k, v := range annotations {
1349+
annField[k] = v
1350+
}
1351+
metadata["annotations"] = annField
1352+
}
1353+
return &unstructured.Unstructured{Object: map[string]any{
1354+
"apiVersion": "v1",
1355+
"kind": "Secret",
1356+
"metadata": metadata,
1357+
"type": "Opaque",
1358+
"data": dataField,
1359+
}}
1360+
}
1361+
1362+
func mustMarshalJSON(t *testing.T, obj *unstructured.Unstructured) string {
1363+
t.Helper()
1364+
bytes, err := json.Marshal(obj)
1365+
require.NoError(t, err)
1366+
return string(bytes)
1367+
}
1368+
1369+
func mustGetSecretData(t *testing.T, secretBytes []byte) map[string]any {
1370+
t.Helper()
1371+
var obj map[string]any
1372+
require.NoError(t, json.Unmarshal(secretBytes, &obj))
1373+
data, ok := obj["data"].(map[string]any)
1374+
require.True(t, ok, "expected data field to be a map")
1375+
return data
12571376
}
12581377

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

0 commit comments

Comments
 (0)