-
-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Fix: Prevent secrets from being visible in process list and encrypt temporary files with Ansible Vault #3271
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
d275566
5f7e2ff
18ab774
9d06dcf
7913fd7
309d10a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -46,4 +46,7 @@ __debug_bin* | |
/pro_impl/ | ||
/go.work.x | ||
/go.work | ||
/go.work.sum | ||
/go.work.sum*.log | ||
config.json | ||
database.boltdb | ||
admin |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,11 +1,13 @@ | ||
package tasks | ||
|
||
import ( | ||
"crypto/rand" | ||
"encoding/hex" | ||
"encoding/json" | ||
"fmt" | ||
"github.com/semaphoreui/semaphore/pkg/ssh" | ||
"maps" | ||
"os" | ||
"os/exec" | ||
"strings" | ||
|
||
"path" | ||
|
@@ -35,7 +37,10 @@ type LocalJob struct { | |
becomeKeyInstallation ssh.AccessKeyInstallation | ||
vaultFileInstallations map[string]ssh.AccessKeyInstallation | ||
|
||
KeyInstaller db_lib.AccessKeyInstaller | ||
KeyInstaller db_lib.AccessKeyInstaller | ||
secretVarFile string // temporary file for secret variables (Ansible) | ||
secretVaultPassword string // vault password for encrypting secret files | ||
secretVaultPasswordFile string // temporary file containing vault password | ||
} | ||
|
||
func (t *LocalJob) IsKilled() bool { | ||
|
@@ -106,25 +111,21 @@ func (t *LocalJob) getEnvironmentExtraVars(username string, incomingVersion *str | |
return | ||
} | ||
|
||
// getEnvironmentExtraVarsJSON returns JSON for public extra vars only, secrets are handled separately | ||
func (t *LocalJob) getEnvironmentExtraVarsJSON(username string, incomingVersion *string) (str string, err error) { | ||
extraVars := make(map[string]any) | ||
extraSecretVars := make(map[string]any) | ||
|
||
// Only include public variables from Environment.JSON | ||
if t.Environment.JSON != "" { | ||
err = json.Unmarshal([]byte(t.Environment.JSON), &extraVars) | ||
if err != nil { | ||
return | ||
} | ||
} | ||
if t.Secret != "" { | ||
err = json.Unmarshal([]byte(t.Secret), &extraSecretVars) | ||
if err != nil { | ||
return | ||
} | ||
} | ||
t.Secret = "{}" | ||
|
||
maps.Copy(extraVars, extraSecretVars) | ||
// Do not include secrets from t.Secret - they will be handled in separate temp file | ||
// Clear the secret to avoid reprocessing | ||
t.Secret = "{}" | ||
|
||
taskDetails := make(map[string]any) | ||
|
||
|
@@ -185,6 +186,148 @@ func (t *LocalJob) getEnvironmentENV() (res []string, err error) { | |
return | ||
} | ||
|
||
// getSecretEnvironmentVars returns environment variables for secrets, avoiding command line exposure | ||
func (t *LocalJob) getSecretEnvironmentVars(prefix string) (secretEnvVars []string, err error) { | ||
// Add environment secrets | ||
for _, secret := range t.Environment.Secrets { | ||
if secret.Type == db.EnvironmentSecretVar { | ||
secretEnvVars = append(secretEnvVars, fmt.Sprintf("%s%s=%s", prefix, secret.Name, secret.Secret)) | ||
} | ||
} | ||
|
||
// Add survey secrets from t.Secret | ||
if t.Secret != "" { | ||
var extraSecretVars map[string]any | ||
err = json.Unmarshal([]byte(t.Secret), &extraSecretVars) | ||
if err != nil { | ||
return | ||
} | ||
for name, value := range extraSecretVars { | ||
if strValue, ok := value.(string); ok { | ||
secretEnvVars = append(secretEnvVars, fmt.Sprintf("%s%s=%s", prefix, name, strValue)) | ||
} | ||
} | ||
} | ||
|
||
return | ||
} | ||
|
||
// generateVaultPassword creates a secure random vault password for encrypting secret files | ||
func (t *LocalJob) generateVaultPassword() (string, error) { | ||
// Generate 32 bytes of random data and encode as hex | ||
bytes := make([]byte, 32) | ||
_, err := rand.Read(bytes) | ||
if err != nil { | ||
return "", fmt.Errorf("failed to generate random vault password: %w", err) | ||
} | ||
return hex.EncodeToString(bytes), nil | ||
} | ||
|
||
// createVaultPasswordFile creates a temporary file containing the vault password | ||
func (t *LocalJob) createVaultPasswordFile() (string, error) { | ||
if t.secretVaultPassword == "" { | ||
var err error | ||
t.secretVaultPassword, err = t.generateVaultPassword() | ||
if err != nil { | ||
return "", err | ||
} | ||
} | ||
|
||
tmpDir := util.Config.GetProjectTmpDir(t.Template.ProjectID) | ||
vaultPasswordFile := path.Join(tmpDir, fmt.Sprintf("vault_pass_%d", t.Task.ID)) | ||
|
||
err := os.WriteFile(vaultPasswordFile, []byte(t.secretVaultPassword), 0600) | ||
if err != nil { | ||
return "", fmt.Errorf("failed to create vault password file: %w", err) | ||
} | ||
|
||
t.secretVaultPasswordFile = vaultPasswordFile | ||
return vaultPasswordFile, nil | ||
} | ||
|
||
// createSecretExtraVarsFile creates a temporary file with secret variables for Ansible, encrypted with ansible-vault | ||
func (t *LocalJob) createSecretExtraVarsFile() (tempFile string, err error) { | ||
secretVars := make(map[string]any) | ||
|
||
// Add environment secrets | ||
for _, secret := range t.Environment.Secrets { | ||
if secret.Type == db.EnvironmentSecretVar { | ||
secretVars[secret.Name] = secret.Secret | ||
} | ||
} | ||
|
||
// Add survey secrets from t.Secret (before it's cleared) | ||
if t.Secret != "" { | ||
var extraSecretVars map[string]any | ||
err = json.Unmarshal([]byte(t.Secret), &extraSecretVars) | ||
if err != nil { | ||
return | ||
} | ||
for name, value := range extraSecretVars { | ||
secretVars[name] = value | ||
} | ||
} | ||
|
||
// If no secrets, don't create file | ||
if len(secretVars) == 0 { | ||
return "", nil | ||
} | ||
|
||
// Create temporary file | ||
tmpDir := util.Config.GetProjectTmpDir(t.Template.ProjectID) | ||
tempFile = path.Join(tmpDir, fmt.Sprintf("secret_vars_%d.json", t.Task.ID)) | ||
|
||
jsonData, err := json.Marshal(secretVars) | ||
if err != nil { | ||
return | ||
} | ||
|
||
// Write unencrypted data first | ||
err = os.WriteFile(tempFile, jsonData, 0600) | ||
if err != nil { | ||
return | ||
} | ||
|
||
// Create vault password file for encryption | ||
vaultPasswordFile, err := t.createVaultPasswordFile() | ||
if err != nil { | ||
// Clean up the secret file if vault password creation fails | ||
os.Remove(tempFile) | ||
return "", fmt.Errorf("failed to create vault password file: %w", err) | ||
} | ||
|
||
// Encrypt the file using ansible-vault | ||
cmd := exec.Command("ansible-vault", "encrypt", "--vault-password-file", vaultPasswordFile, tempFile) | ||
output, err := cmd.CombinedOutput() | ||
if err != nil { | ||
// Clean up files if encryption fails | ||
os.Remove(tempFile) | ||
os.Remove(vaultPasswordFile) | ||
return "", fmt.Errorf("failed to encrypt secret vars file with ansible-vault: %w, output: %s", err, string(output)) | ||
} | ||
|
||
// Store for cleanup | ||
t.secretVarFile = tempFile | ||
|
||
return | ||
} | ||
|
||
// cleanupSecretFile removes the temporary secret file and vault password file if they exist | ||
func (t *LocalJob) cleanupSecretFile() { | ||
if t.secretVarFile != "" { | ||
if err := os.Remove(t.secretVarFile); err != nil { | ||
t.Log("Warning: Could not remove secret vars file: " + err.Error()) | ||
} | ||
t.secretVarFile = "" | ||
} | ||
if t.secretVaultPasswordFile != "" { | ||
if err := os.Remove(t.secretVaultPasswordFile); err != nil { | ||
t.Log("Warning: Could not remove vault password file: " + err.Error()) | ||
} | ||
t.secretVaultPasswordFile = "" | ||
} | ||
} | ||
|
||
// nolint: gocyclo | ||
func (t *LocalJob) getShellArgs(username string, incomingVersion *string) (args []string, err error) { | ||
extraVars, err := t.getEnvironmentExtraVars(username, incomingVersion) | ||
|
@@ -204,13 +347,6 @@ func (t *LocalJob) getShellArgs(username string, incomingVersion *string) (args | |
// Script to run | ||
args = append(args, t.Template.Playbook) | ||
|
||
// Include Environment Secret Vars | ||
for _, secret := range t.Environment.Secrets { | ||
if secret.Type == db.EnvironmentSecretVar { | ||
args = append(args, fmt.Sprintf("%s=%s", secret.Name, secret.Secret)) | ||
} | ||
} | ||
|
||
// Include extra args from template | ||
args = append(args, templateArgs...) | ||
|
||
|
@@ -266,13 +402,6 @@ func (t *LocalJob) getTerraformArgs(username string, incomingVersion *string) (a | |
args = append(args, templateArgs...) | ||
args = append(args, taskArgs...) | ||
|
||
for _, secret := range t.Environment.Secrets { | ||
if secret.Type != db.EnvironmentSecretVar { | ||
continue | ||
} | ||
args = append(args, "-var", fmt.Sprintf("%s=%s", secret.Name, secret.Secret)) | ||
} | ||
|
||
return | ||
} | ||
|
||
|
@@ -396,11 +525,16 @@ func (t *LocalJob) getPlaybookArgs(username string, incomingVersion *string) (ar | |
args = append(args, "--extra-vars", extraVars) | ||
} | ||
|
||
for _, secret := range t.Environment.Secrets { | ||
if secret.Type != db.EnvironmentSecretVar { | ||
continue | ||
// Create temporary file for secrets to avoid exposing them in command line | ||
secretFile, err := t.createSecretExtraVarsFile() | ||
if err != nil { | ||
t.Log("Warning: Could not create secret vars file: " + err.Error()) | ||
} else if secretFile != "" { | ||
// Add vault-id for decrypting the secret vars file | ||
if t.secretVaultPasswordFile != "" { | ||
args = append(args, fmt.Sprintf("--vault-id=secrets@%s", t.secretVaultPasswordFile)) | ||
} | ||
args = append(args, "--extra-vars", fmt.Sprintf("%s=%s", secret.Name, secret.Secret)) | ||
args = append(args, "--extra-vars", "@"+secretFile) | ||
} | ||
|
||
templateArgs, taskArgs, err := t.getCLIArgs() | ||
|
@@ -533,6 +667,7 @@ func (t *LocalJob) Run(username string, incomingVersion *string, alias string) ( | |
defer func() { | ||
t.destroyKeys() | ||
t.destroyInventoryFile() | ||
t.cleanupSecretFile() | ||
t.App.Clear() | ||
}() | ||
|
||
|
@@ -584,6 +719,26 @@ func (t *LocalJob) Run(username string, incomingVersion *string, alias string) ( | |
return | ||
} | ||
|
||
// Add secret environment variables to avoid exposing them in command line arguments | ||
switch t.Template.App { | ||
case db.AppTerraform, db.AppTofu, db.AppTerragrunt: | ||
// For Terraform, use TF_VAR_ prefix for variables | ||
secretEnvVars, secretErr := t.getSecretEnvironmentVars("TF_VAR_") | ||
if secretErr != nil { | ||
t.Log("Warning: Failed to process secret environment variables: " + secretErr.Error()) | ||
} else { | ||
environmentVariables = append(environmentVariables, secretEnvVars...) | ||
} | ||
default: | ||
// For Ansible and Shell, add secrets as regular environment variables | ||
secretEnvVars, secretErr := t.getSecretEnvironmentVars("") | ||
if secretErr != nil { | ||
t.Log("Warning: Failed to process secret environment variables: " + secretErr.Error()) | ||
} else { | ||
environmentVariables = append(environmentVariables, secretEnvVars...) | ||
} | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: Survey Secrets Lost Before EncryptionSurvey secrets are lost because Additional Locations (2) |
||
|
||
if t.Inventory.SSHKey.Type == db.AccessKeySSH && t.Inventory.SSHKeyID != nil { | ||
environmentVariables = append(environmentVariables, fmt.Sprintf("SSH_AUTH_SOCK=%s", t.sshKeyInstallation.SSHAgent.SocketFile)) | ||
} | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[P1] Clearing t.Secret drops survey secrets for Ansible runs
Inside
getEnvironmentExtraVarsJSON
the code now setst.Secret = "{}"
to keep secrets out of public--extra-vars
.getPlaybookArgs
calls this method before creating the encrypted secret file and beforeRun
injects secrets into the environment, and both later steps read fromt.Secret
. Because the field is replaced with an empty object here, any survey-provided secrets are silently discarded – they are not written to the vault-encrypted temp file and never show up in the environment variables, so Ansible playbooks lose access to these values. The sanitization should avoid mutatingt.Secret
or should copy its contents before clearing.Useful? React with 👍 / 👎.