Fix GitOps dry run issue with validating profiles with secrets (#32104)

Fixes #31477 

Docs PR: https://github.com/fleetdm/fleet/pull/32116

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

## Testing

- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- New Features
- GitOps now supports FLEET_SECRET_ placeholders in macOS
(.mobileconfig/.xml) profiles. Secrets are expanded only for validation,
while remaining unexpanded in uploaded content.
- Improved environment variable handling: non-secret vars expand as
before; server-side secrets are preserved.
- Validation enforces that profile display names cannot contain
FLEET_SECRET_ values.

- Bug Fixes
- Resolves validation issues when FLEET_SECRET_ appears in <data> tags
by performing safe client-side expansion for validation.
  - More accurate error reporting during profile parsing and validation.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Lucas Manuel Rodriguez <lucas@fleetdm.com>
This commit is contained in:
Victor Lyuboslavsky
2025-08-22 09:37:12 -05:00
committed by GitHub
co-authored by Lucas Manuel Rodriguez
parent 53b7a0628a
commit 1d7aab04ab
12 changed files with 702 additions and 33 deletions
@@ -1270,3 +1270,240 @@ queries:
require.Equal(t, secretVariables[0].Name, secretName)
require.Equal(t, secretVariables[0].Value, secretValue)
}
// TestEnvSubstitutionInProfiles tests that only FLEET_SECRET_ prefixed env vars are saved as secrets
func (s *enterpriseIntegrationGitopsTestSuite) TestEnvSubstitutionInProfiles() {
t := s.T()
ctx := t.Context()
tempDir := t.TempDir()
// Create a test configuration profile with both valid and invalid secret references
profileContent := `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadDisplayName</key>
<string>Test Profile</string>
<key>PayloadIdentifier</key>
<string>com.fleet.test.env</string>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>12345678-1234-1234-1234-123456789012</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>TestSecretValue</key>
<string>$FLEET_SECRET_TEST_SECRET</string>
<key>TestInvalidSecret</key>
<string>$FLEET_DUO_CERTIFICATE_SECRET</string>
<key>TestPlainValue</key>
<string>$HOME</string>
</dict>
</array>
<key>PayloadDisplayName</key>
<string>Test Profile</string>
<key>PayloadIdentifier</key>
<string>com.fleet.test.env</string>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>12345678-1234-1234-1234-123456789012</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</plist>`
// Write the profile to a file
profilePath := filepath.Join(tempDir, "test-profile.mobileconfig")
err := os.WriteFile(profilePath, []byte(profileContent), 0644) //nolint:gosec // test code
require.NoError(t, err)
// Create a GitOps config file that references the profile
// Note: Environment variables in the YAML config itself get expanded,
// but not in the referenced profile files
gitopsConfig := fmt.Sprintf(`
org_settings:
server_settings:
server_url: %s
secrets:
- secret: test_secret
agent_options:
config:
decorators:
load:
- SELECT uuid AS host_uuid FROM system_info;
controls:
macos_settings:
custom_settings:
- path: %s
queries: []
policies: []
`, s.Server.URL, profilePath)
configPath := filepath.Join(tempDir, "gitops.yml")
err = os.WriteFile(configPath, []byte(gitopsConfig), 0644) //nolint:gosec // test code
require.NoError(t, err)
// Create a GitOps user
gitOpsUser := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, gitOpsUser)
// Set the environment variable for the valid secret
t.Setenv("FLEET_SECRET_TEST_SECRET", "super_secret_value_123")
t.Setenv("FLEET_DUO_CERTIFICATE_SECRET", "should_not_be_saved")
t.Setenv("HOME", "also_not_saved")
// Run GitOps dry-run - should fail without the required secret
// First, unset the environment variable to trigger the error
_ = os.Unsetenv("FLEET_SECRET_TEST_SECRET")
_, err = fleetctl.RunAppNoChecks([]string{"gitops", "--config", fleetctlConfig.Name(), "-f", configPath, "--dry-run"})
require.ErrorContains(t, err, "FLEET_SECRET_TEST_SECRET")
// Set the env var again and run for real
t.Setenv("FLEET_SECRET_TEST_SECRET", "super_secret_value_123")
_ = fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", configPath})
// Verify that the secret was saved to the server
secrets, err := s.DS.GetSecretVariables(ctx, []string{"TEST_SECRET"})
require.NoError(t, err)
require.Len(t, secrets, 1)
assert.Equal(t, "TEST_SECRET", secrets[0].Name)
assert.Equal(t, "super_secret_value_123", secrets[0].Value)
// Verify that non-FLEET_SECRET_ variables were NOT saved
notSaved, err := s.DS.GetSecretVariables(ctx, []string{"DUO_CERTIFICATE_SECRET", "HOME"})
require.NoError(t, err)
assert.Empty(t, notSaved, "Non-FLEET_SECRET_ variables should not be saved")
// Verify that the profile content has the expected substitutions:
// - $FLEET_SECRET_* variables should remain as-is (substituted at delivery time)
// - Other env vars should be expanded during GitOps
profiles, err := s.DS.ListMDMAppleConfigProfiles(ctx, nil)
require.NoError(t, err)
foundProfile := false
for _, profile := range profiles {
t.Logf("Found profile: %s", profile.Name)
if strings.Contains(profile.Name, "test-profile") || strings.Contains(profile.Identifier, "com.fleet.test.env") {
foundProfile = true
// $FLEET_SECRET_* variables should NOT be expanded (they're expanded at delivery time)
assert.Contains(t, string(profile.Mobileconfig), "$FLEET_SECRET_TEST_SECRET")
// Non-FLEET_SECRET_/FLEET_VAR_ variables SHOULD be expanded during GitOps
assert.Contains(t, string(profile.Mobileconfig), "should_not_be_saved") // Value of $FLEET_DUO_CERTIFICATE_SECRET
assert.Contains(t, string(profile.Mobileconfig), "also_not_saved") // Value of $HOME
// The original variable names should NOT be present
assert.NotContains(t, string(profile.Mobileconfig), "$FLEET_DUO_CERTIFICATE_SECRET")
assert.NotContains(t, string(profile.Mobileconfig), "$HOME")
break
}
}
assert.True(t, foundProfile, "Profile should be uploaded to the server")
}
// TestFleetSecretInDataTag tests that FLEET_SECRET_ variables in <data> tags of Apple profiles
// are handled properly.
func (s *enterpriseIntegrationGitopsTestSuite) TestFleetSecretInDataTag() {
t := s.T()
tempDir := t.TempDir()
ctx := context.Background()
// Sample certificate in base64 format (this is a dummy test certificate)
testCertBase64 := `MIIDaTCCAlGgAwIBAgIUNQLezMUpmZK18DcLKt/XTRcLlK8wDQYJKoZIhvcNAQELBQAwRDEfMB0GA1UEAwwWRHVtbXkgVGVzdCBDZXJ0aWZpY2F0ZTEUMBIGA1UECgwLRXhhbXBsZSBPcmcxCzAJBgNVBAYTAlVTMB4XDTI1MDgxOTE3NDMwN1oXDTI2MDgxOTE3NDMwN1owRDEfMB0GA1UEAwwWRHVtbXkgVGVzdCBDZXJ0aWZpY2F0ZTEUMBIGA1UECgwLRXhhbXBsZSBPcmcxCzAJBgNVBAYTAlVTMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA07Np/w5WpFVLlMKX3dZSxwo+c2uwP2glTN0HA5c/6UOQRR9c91yoGGJsD4pfqhtIMSTFw7po3n/PjhGDe/WH+utK+ZIcD0nGD6SvmOggyoohHs81eIOjJAEJjxzhk7eLTVpUI2EnPe/24ei/dgkK59As9qQyH/y+CoR8JIYbNCJH5YLC2Pa44V84QWa2I5DHKUKrUXo9WsrRp1N1JjyaG/6hxLBJZ69e0QTrxxScboreRqVUR6oIEJRTchB+rDG5dxXzCQE6/F8N3qR76t23wd3CLmrcXoEc1P2P331Qzi0KXNXjdJFf0plmfRkT/IWgfM81Vfon1QwENwRSBNmPfQIDAQABo1MwUTAdBgNVHQ4EFgQU9q7SDfQRbJ31snRt2sZzx5sdEpYwHwYDVR0jBBgwFoAU9q7SDfQRbJ31snRt2sZzx5sdEpYwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAYwH42JP45SnZejSF74OcYt8fp08jCWHOiFC3QEo3cROXVn6AWjEbzuQpOxRWF9EizNA83c4E6I+kQVztiuv9bUKGuLFeYb9lUZe8HOvH+j22MtGvZrDPygsJc8TavdkxAsu6OiNQZrYiCFzixkKS9b5p/1B93GBh62OFnV1nUBS8PzAZhOAyJ8UcEhr+GNzZG99/wOkcB0uwxmIb8x8sB3KnQ0qef/qnmgeWxlJlDc/SZ2/4PgtaluZ+noDfNPzaQn4eJNnBz0OTqZ9yuKALeE1WHk8U13zSdc1GNVLhXOrEHegPK5bBmA/lpIQ6HrkwUX7MJ3vK0AD3LjaTzXltDQ==`
// Create a test team first
team, err := s.DS.NewTeam(ctx, &fleet.Team{
Name: "Test Team for Secret in Data Tag",
})
require.NoError(t, err)
// Create a profile with $FLEET_SECRET_DUO_CERTIFICATE in a <data> tag
// This mimics the real-world scenario where the certificate should be base64 encoded
profileContent := `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadType</key>
<string>com.apple.security.root</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadIdentifier</key>
<string>com.example.test.cert</string>
<key>PayloadUUID</key>
<string>11111111-2222-3333-4444-555555555555</string>
<key>PayloadDisplayName</key>
<string>Test Root Certificate</string>
<key>PayloadContent</key>
<data>$FLEET_SECRET_DUO_CERTIFICATE</data>
</dict>
</array>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadIdentifier</key>
<string>com.example.test.profile</string>
<key>PayloadUUID</key>
<string>aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee</string>
<key>PayloadDisplayName</key>
<string>Test MDM Profile with Base64</string>
</dict>
</plist>`
// Write the profile to a file
profilePath := filepath.Join(tempDir, "rootcert-secret.mobileconfig")
err = os.WriteFile(profilePath, []byte(profileContent), 0644) //nolint:gosec
require.NoError(t, err)
// Create a team GitOps config file that references the profile
teamConfig := fmt.Sprintf(`
name: %s
team_settings:
secrets:
- secret: test_secret
agent_options:
config:
decorators:
load:
- SELECT uuid AS host_uuid FROM system_info;
controls:
macos_settings:
custom_settings:
- path: %s
queries:
policies:
software:
`, team.Name, profilePath)
configPath := filepath.Join(tempDir, "team-gitops.yml")
err = os.WriteFile(configPath, []byte(teamConfig), 0644) //nolint:gosec
require.NoError(t, err)
// Create a GitOps user
gitOpsUser := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, gitOpsUser)
// Set the environment variable with the base64-encoded certificate
t.Setenv("FLEET_SECRET_DUO_CERTIFICATE", testCertBase64)
// The fix expands FLEET_SECRET_ variables for validation only, allowing the profile to be parsed
_, err = fleetctl.RunAppNoChecks([]string{"gitops", "--config", fleetctlConfig.Name(), "-f", configPath, "--dry-run"})
require.NoError(t, err, "GitOps dry-run should succeed with the fix")
// Also test without dry-run to confirm it works
_, err = fleetctl.RunAppNoChecks([]string{"gitops", "--config", fleetctlConfig.Name(), "-f", configPath})
require.NoError(t, err, "GitOps should succeed with the fix")
// Verify that the profile stored on the server still has the unexpanded variable
profiles, err := s.DS.ListMDMAppleConfigProfiles(ctx, &team.ID)
require.NoError(t, err)
require.Len(t, profiles, 1)
// The stored profile should still contain the unexpanded variable, not the actual secret
require.Contains(t, string(profiles[0].Mobileconfig), "$FLEET_SECRET_DUO_CERTIFICATE",
"Profile should still contain unexpanded FLEET_SECRET variable")
}