From 7d905b75fc0d26456abfadad0bef96e4e1d40aa5 Mon Sep 17 00:00:00 2001 From: wankhede04 Date: Fri, 24 Jul 2026 13:04:32 +0530 Subject: [PATCH 1/2] feat(secrets): add secrets diff to compare environments/paths Adds `infisical secrets diff --env= --env2=` to compare secrets between two environments (and optionally two paths via --path/--path2). Prints a table of added/removed/changed keys; values are masked by default and revealed with --show-values. Supports --output json/yaml/ dotenv for scripting. This reuses the existing GetAllEnvironmentVariables fetch path (same one used by `run`/`export`) and the GenericTable rendering helper, so no new API surface or storage is introduced. Fixes #330 --- packages/cmd/secrets_diff.go | 259 ++++++++++++++++++++++++++++++ packages/cmd/secrets_diff_test.go | 108 +++++++++++++ 2 files changed, 367 insertions(+) create mode 100644 packages/cmd/secrets_diff.go create mode 100644 packages/cmd/secrets_diff_test.go diff --git a/packages/cmd/secrets_diff.go b/packages/cmd/secrets_diff.go new file mode 100644 index 00000000..e944ddc2 --- /dev/null +++ b/packages/cmd/secrets_diff.go @@ -0,0 +1,259 @@ +/* +Copyright (c) 2023 Infisical Inc. +*/ +package cmd + +import ( + "fmt" + "sort" + + "github.com/Infisical/infisical-merge/packages/models" + "github.com/Infisical/infisical-merge/packages/util" + "github.com/Infisical/infisical-merge/packages/visualize" + "github.com/posthog/posthog-go" + "github.com/spf13/cobra" +) + +const ( + secretDiffStatusAdded = "added" + secretDiffStatusRemoved = "removed" + secretDiffStatusChanged = "changed" +) + +// secretDiffEntry represents a single key's comparison result between two environments/paths. +type secretDiffEntry struct { + Key string + Status string + LeftValue string + // RightValue is unused when Status is secretDiffStatusRemoved, but kept for + // completeness/output-formatting purposes. + RightValue string +} + +var secretsDiffCmd = &cobra.Command{ + Example: `secrets diff --env=staging --env2=production`, + Short: "Used to compare secrets between two environments and/or paths", + Use: "diff", + DisableFlagsInUseLine: true, + Args: cobra.NoArgs, + Run: diffSecrets, +} + +func diffSecrets(cmd *cobra.Command, args []string) { + token, err := util.GetInfisicalToken(cmd) + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + projectId, err := util.GetCmdFlagOrEnvWithDefaultValue(cmd, "projectId", []string{util.INFISICAL_PROJECT_ID_NAME}, "") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + env1, err := cmd.Flags().GetString("env") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + if !cmd.Flags().Changed("env") { + if environmentFromWorkspace := util.GetEnvFromWorkspaceFile(); environmentFromWorkspace != "" { + env1 = environmentFromWorkspace + } + } + + env2, err := cmd.Flags().GetString("env2") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + if env2 == "" { + util.PrintErrorMessageAndExit("The --env2 flag is required to specify the environment to compare against") + } + if env2 == env1 { + util.PrintErrorMessageAndExit("--env and --env2 must be different environments") + } + + path1, err := cmd.Flags().GetString("path") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + path2, err := cmd.Flags().GetString("path2") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + if path2 == "" { + path2 = path1 + } + + includeImports, err := cmd.Flags().GetBool("include-imports") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + recursive, err := cmd.Flags().GetBool("recursive") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + shouldExpandSecrets, err := cmd.Flags().GetBool("expand") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + showValues, err := cmd.Flags().GetBool("show-values") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + baseRequest := models.GetAllSecretsParameters{ + WorkspaceId: projectId, + SecretsPath: path1, + IncludeImport: includeImports, + Recursive: recursive, + ExpandSecretReferences: shouldExpandSecrets, + IncludePersonalOverrides: true, + } + + if token != nil && token.Type == util.SERVICE_TOKEN_IDENTIFIER { + baseRequest.InfisicalToken = token.Token + } else if token != nil && token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER { + baseRequest.UniversalAuthAccessToken = token.Token + } + + leftRequest := baseRequest + leftRequest.Environment = env1 + leftRequest.SecretsPath = path1 + + rightRequest := baseRequest + rightRequest.Environment = env2 + rightRequest.SecretsPath = path2 + + leftSecrets, err := util.GetAllEnvironmentVariables(leftRequest, "") + if err != nil { + util.HandleError(err, fmt.Sprintf("Unable to fetch secrets for environment %q", env1)) + } + + rightSecrets, err := util.GetAllEnvironmentVariables(rightRequest, "") + if err != nil { + util.HandleError(err, fmt.Sprintf("Unable to fetch secrets for environment %q", env2)) + } + + diff := computeSecretDiff(leftSecrets, rightSecrets) + + outputFormat, err := cmd.Flags().GetString("output") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + if outputFormat != "" { + var outputStructure []map[string]any + for _, entry := range diff { + row := map[string]any{ + "secretKey": entry.Key, + "status": entry.Status, + } + if showValues { + row["leftValue"] = entry.LeftValue + row["rightValue"] = entry.RightValue + } + outputStructure = append(outputStructure, row) + } + + output, err := util.FormatOutput(outputFormat, outputStructure, &util.FormatOutputOptions{ + DotEnvArrayKeyAttribute: "secretKey", + DotEnvArrayValueAttribute: "status", + }) + if err != nil { + util.HandleError(err, "Unable to format output") + } + util.PrintStdout(output) + } else { + printSecretDiffTable(diff, env1, env2, showValues) + } + + Telemetry.CaptureEvent("cli-command:secrets diff", posthog.NewProperties().Set("changedCount", len(diff)).Set("version", util.CLI_VERSION)) +} + +// computeSecretDiff compares two sets of secrets by key and returns entries for +// every key that was added, removed, or has a different value. Keys present in +// both sets with identical values are omitted. Results are sorted by key. +func computeSecretDiff(left, right []models.SingleEnvironmentVariable) []secretDiffEntry { + leftByKey := make(map[string]models.SingleEnvironmentVariable, len(left)) + for _, secret := range left { + leftByKey[secret.Key] = secret + } + + rightByKey := make(map[string]models.SingleEnvironmentVariable, len(right)) + for _, secret := range right { + rightByKey[secret.Key] = secret + } + + var diff []secretDiffEntry + + for key, leftSecret := range leftByKey { + rightSecret, existsInRight := rightByKey[key] + if !existsInRight { + diff = append(diff, secretDiffEntry{Key: key, Status: secretDiffStatusRemoved, LeftValue: leftSecret.Value}) + continue + } + if leftSecret.Value != rightSecret.Value { + diff = append(diff, secretDiffEntry{Key: key, Status: secretDiffStatusChanged, LeftValue: leftSecret.Value, RightValue: rightSecret.Value}) + } + } + + for key, rightSecret := range rightByKey { + if _, existsInLeft := leftByKey[key]; !existsInLeft { + diff = append(diff, secretDiffEntry{Key: key, Status: secretDiffStatusAdded, RightValue: rightSecret.Value}) + } + } + + sort.Slice(diff, func(i, j int) bool { + return diff[i].Key < diff[j].Key + }) + + return diff +} + +func printSecretDiffTable(diff []secretDiffEntry, env1, env2 string, showValues bool) { + if len(diff) == 0 { + util.PrintlnStdout(fmt.Sprintf("No differences found between %q and %q.", env1, env2)) + return + } + + headers := []string{"SECRET NAME", "STATUS", env1, env2} + rows := [][]string{} + for _, entry := range diff { + leftValue := entry.LeftValue + rightValue := entry.RightValue + if !showValues { + leftValue = maskDiffValue(entry.Status, secretDiffStatusRemoved, leftValue) + rightValue = maskDiffValue(entry.Status, secretDiffStatusAdded, rightValue) + } + rows = append(rows, []string{entry.Key, entry.Status, leftValue, rightValue}) + } + + visualize.GenericTable(headers, rows) +} + +// maskDiffValue masks a value unless it's meant to be empty (i.e. the key +// doesn't exist on that side of the diff). +func maskDiffValue(status, emptyWhenStatus, value string) string { + if status == emptyWhenStatus { + return "" + } + return maskSecretValue(value) +} + +func init() { + secretsDiffCmd.Flags().String("token", "", "Fetch secrets using service token or machine identity access token") + secretsDiffCmd.Flags().String("projectId", "", "manually set the projectId when using machine identity based auth") + secretsDiffCmd.Flags().String("env2", "", "the second environment to compare against (required)") + secretsDiffCmd.Flags().String("path", "/", "path to fetch secrets from for --env") + secretsDiffCmd.Flags().String("path2", "", "path to fetch secrets from for --env2 (defaults to --path)") + secretsDiffCmd.Flags().Bool("include-imports", true, "Include imported linked secrets") + secretsDiffCmd.Flags().Bool("recursive", false, "Fetch secrets from all sub-folders") + secretsDiffCmd.Flags().Bool("expand", true, "Parse shell parameter expansions in your secrets, and process your referenced secrets") + secretsDiffCmd.Flags().Bool("show-values", false, "reveal secret values in the diff output instead of masking them") + util.AddOutputFlagsToCmd(secretsDiffCmd, "The output to format the diff in.") + + secretsCmd.AddCommand(secretsDiffCmd) +} diff --git a/packages/cmd/secrets_diff_test.go b/packages/cmd/secrets_diff_test.go new file mode 100644 index 00000000..5479fe73 --- /dev/null +++ b/packages/cmd/secrets_diff_test.go @@ -0,0 +1,108 @@ +package cmd + +import ( + "testing" + + "github.com/Infisical/infisical-merge/packages/models" +) + +func TestComputeSecretDiff_DetectsAddedRemovedChangedAndUnchanged(t *testing.T) { + left := []models.SingleEnvironmentVariable{ + {Key: "SAME", Value: "same-value"}, + {Key: "CHANGED", Value: "old-value"}, + {Key: "ONLY_LEFT", Value: "left-only"}, + } + right := []models.SingleEnvironmentVariable{ + {Key: "SAME", Value: "same-value"}, + {Key: "CHANGED", Value: "new-value"}, + {Key: "ONLY_RIGHT", Value: "right-only"}, + } + + diff := computeSecretDiff(left, right) + + if len(diff) != 3 { + t.Fatalf("expected 3 diff entries (unchanged key excluded), got %d: %+v", len(diff), diff) + } + + byKey := make(map[string]secretDiffEntry, len(diff)) + for _, entry := range diff { + byKey[entry.Key] = entry + } + + if _, ok := byKey["SAME"]; ok { + t.Errorf("expected unchanged key SAME to be excluded from diff") + } + + changed, ok := byKey["CHANGED"] + if !ok { + t.Fatalf("expected CHANGED key in diff") + } + if changed.Status != secretDiffStatusChanged { + t.Errorf("expected CHANGED status, got %s", changed.Status) + } + if changed.LeftValue != "old-value" || changed.RightValue != "new-value" { + t.Errorf("unexpected values for CHANGED: %+v", changed) + } + + onlyLeft, ok := byKey["ONLY_LEFT"] + if !ok { + t.Fatalf("expected ONLY_LEFT key in diff") + } + if onlyLeft.Status != secretDiffStatusRemoved { + t.Errorf("expected removed status for ONLY_LEFT, got %s", onlyLeft.Status) + } + + onlyRight, ok := byKey["ONLY_RIGHT"] + if !ok { + t.Fatalf("expected ONLY_RIGHT key in diff") + } + if onlyRight.Status != secretDiffStatusAdded { + t.Errorf("expected added status for ONLY_RIGHT, got %s", onlyRight.Status) + } +} + +func TestComputeSecretDiff_EmptyWhenIdentical(t *testing.T) { + secrets := []models.SingleEnvironmentVariable{ + {Key: "A", Value: "1"}, + {Key: "B", Value: "2"}, + } + + diff := computeSecretDiff(secrets, secrets) + + if len(diff) != 0 { + t.Errorf("expected no diff entries for identical secret sets, got %d: %+v", len(diff), diff) + } +} + +func TestComputeSecretDiff_ResultsSortedByKey(t *testing.T) { + left := []models.SingleEnvironmentVariable{} + right := []models.SingleEnvironmentVariable{ + {Key: "ZEBRA", Value: "z"}, + {Key: "ALPHA", Value: "a"}, + {Key: "MID", Value: "m"}, + } + + diff := computeSecretDiff(left, right) + + if len(diff) != 3 { + t.Fatalf("expected 3 entries, got %d", len(diff)) + } + want := []string{"ALPHA", "MID", "ZEBRA"} + for i, key := range want { + if diff[i].Key != key { + t.Errorf("expected sorted key %q at index %d, got %q", key, i, diff[i].Key) + } + } +} + +func TestMaskDiffValue_MasksNonEmptySide(t *testing.T) { + if got := maskDiffValue(secretDiffStatusChanged, secretDiffStatusRemoved, "secret"); got != "******" { + t.Errorf("expected masked value for changed status, got %q", got) + } +} + +func TestMaskDiffValue_LeavesEmptyForMissingSide(t *testing.T) { + if got := maskDiffValue(secretDiffStatusRemoved, secretDiffStatusRemoved, "secret"); got != "" { + t.Errorf("expected empty value when status matches emptyWhenStatus, got %q", got) + } +} From 254559f00175782f803074547acd86a7be9da4bb Mon Sep 17 00:00:00 2001 From: wankhede04 Date: Fri, 24 Jul 2026 13:48:44 +0530 Subject: [PATCH 2/2] fix(secrets): correct diff masking and same-env path comparison Greptile P1s on #333: - Swap emptyWhenStatus args so removed keys mask the left column and added keys mask the right column (instead of blanking both sides). - Allow same-environment diffs when --path and --path2 differ; only reject when both env and path are identical. --- packages/cmd/secrets_diff.go | 44 +++++++++++++++++++++---- packages/cmd/secrets_diff_test.go | 54 +++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 7 deletions(-) diff --git a/packages/cmd/secrets_diff.go b/packages/cmd/secrets_diff.go index e944ddc2..c27afc2e 100644 --- a/packages/cmd/secrets_diff.go +++ b/packages/cmd/secrets_diff.go @@ -67,9 +67,6 @@ func diffSecrets(cmd *cobra.Command, args []string) { if env2 == "" { util.PrintErrorMessageAndExit("The --env2 flag is required to specify the environment to compare against") } - if env2 == env1 { - util.PrintErrorMessageAndExit("--env and --env2 must be different environments") - } path1, err := cmd.Flags().GetString("path") if err != nil { @@ -80,8 +77,12 @@ func diffSecrets(cmd *cobra.Command, args []string) { if err != nil { util.HandleError(err, "Unable to parse flag") } - if path2 == "" { - path2 = path1 + path2 = resolveDiffPath2(path1, path2) + + // Same environment is allowed when comparing different paths + // (e.g. --env=staging --env2=staging --path=/app --path2=/db). + if err := validateDiffTargets(env1, env2, path1, path2); err != nil { + util.PrintErrorMessageAndExit(err.Error()) } includeImports, err := cmd.Flags().GetBool("include-imports") @@ -225,8 +226,7 @@ func printSecretDiffTable(diff []secretDiffEntry, env1, env2 string, showValues leftValue := entry.LeftValue rightValue := entry.RightValue if !showValues { - leftValue = maskDiffValue(entry.Status, secretDiffStatusRemoved, leftValue) - rightValue = maskDiffValue(entry.Status, secretDiffStatusAdded, rightValue) + leftValue, rightValue = maskDiffColumns(entry.Status, leftValue, rightValue) } rows = append(rows, []string{entry.Key, entry.Status, leftValue, rightValue}) } @@ -234,6 +234,36 @@ func printSecretDiffTable(diff []secretDiffEntry, env1, env2 string, showValues visualize.GenericTable(headers, rows) } +// resolveDiffPath2 defaults --path2 to --path when --path2 is omitted. +func resolveDiffPath2(path1, path2 string) string { + if path2 == "" { + return path1 + } + return path2 +} + +// validateDiffTargets rejects comparisons where both environment and path are +// identical (there would be nothing meaningful to compare). +func validateDiffTargets(env1, env2, path1, path2 string) error { + if env1 == env2 && path1 == path2 { + return fmt.Errorf("--env/--env2 and --path/--path2 must not both be identical; use different environments or different paths") + } + return nil +} + +// maskDiffColumns returns masked left/right display values for a table row. +// +// Semantics when masking: +// - removed: key exists only on the left → left masked, right empty +// - added: key exists only on the right → left empty, right masked +// - changed: both sides exist → both masked +func maskDiffColumns(status, leftValue, rightValue string) (string, string) { + // Left is empty when the key was added on the right (absent on the left). + // Right is empty when the key was removed from the left (absent on the right). + return maskDiffValue(status, secretDiffStatusAdded, leftValue), + maskDiffValue(status, secretDiffStatusRemoved, rightValue) +} + // maskDiffValue masks a value unless it's meant to be empty (i.e. the key // doesn't exist on that side of the diff). func maskDiffValue(status, emptyWhenStatus, value string) string { diff --git a/packages/cmd/secrets_diff_test.go b/packages/cmd/secrets_diff_test.go index 5479fe73..2be3dd05 100644 --- a/packages/cmd/secrets_diff_test.go +++ b/packages/cmd/secrets_diff_test.go @@ -106,3 +106,57 @@ func TestMaskDiffValue_LeavesEmptyForMissingSide(t *testing.T) { t.Errorf("expected empty value when status matches emptyWhenStatus, got %q", got) } } + +func TestMaskDiffColumns_RemovedShowsMaskedLeftAndEmptyRight(t *testing.T) { + left, right := maskDiffColumns(secretDiffStatusRemoved, "left-secret", "") + if left != "******" { + t.Errorf("expected masked left for removed key, got %q", left) + } + if right != "" { + t.Errorf("expected empty right for removed key, got %q", right) + } +} + +func TestMaskDiffColumns_AddedShowsEmptyLeftAndMaskedRight(t *testing.T) { + left, right := maskDiffColumns(secretDiffStatusAdded, "", "right-secret") + if left != "" { + t.Errorf("expected empty left for added key, got %q", left) + } + if right != "******" { + t.Errorf("expected masked right for added key, got %q", right) + } +} + +func TestMaskDiffColumns_ChangedMasksBothSides(t *testing.T) { + left, right := maskDiffColumns(secretDiffStatusChanged, "old", "new") + if left != "******" || right != "******" { + t.Errorf("expected both sides masked for changed key, got left=%q right=%q", left, right) + } +} + +func TestResolveDiffPath2_DefaultsToPath1(t *testing.T) { + if got := resolveDiffPath2("/app", ""); got != "/app" { + t.Errorf("expected path2 to default to path1, got %q", got) + } + if got := resolveDiffPath2("/app", "/db"); got != "/db" { + t.Errorf("expected explicit path2 to be preserved, got %q", got) + } +} + +func TestValidateDiffTargets_AllowsSameEnvDifferentPath(t *testing.T) { + if err := validateDiffTargets("staging", "staging", "/app", "/db"); err != nil { + t.Errorf("expected same-env different-path comparison to be allowed, got %v", err) + } +} + +func TestValidateDiffTargets_RejectsIdenticalEnvAndPath(t *testing.T) { + if err := validateDiffTargets("staging", "staging", "/app", "/app"); err == nil { + t.Errorf("expected identical env+path comparison to be rejected") + } +} + +func TestValidateDiffTargets_AllowsDifferentEnvSamePath(t *testing.T) { + if err := validateDiffTargets("staging", "production", "/app", "/app"); err != nil { + t.Errorf("expected different-env same-path comparison to be allowed, got %v", err) + } +}