diff --git a/compiler.go b/compiler.go index 311fd58423..9b7bc1f95c 100644 --- a/compiler.go +++ b/compiler.go @@ -51,7 +51,7 @@ func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (* return nil, err } for k, v := range specialVars { - result.Set(k, ast.Var{Value: v}) + result.Set(k, ast.Var{Value: v, Secret: false}) } getRangeFunc := func(dir string) func(k string, v ast.Var) error { @@ -63,12 +63,12 @@ func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (* // This stops empty interface errors when using the templater to replace values later // Preserve the Sh field so it can be displayed in summary if !evaluateShVars && newVar.Value == nil { - result.Set(k, ast.Var{Value: "", Sh: newVar.Sh}) + result.Set(k, ast.Var{Value: "", Sh: newVar.Sh, Secret: v.Secret}) return nil } // If the variable should not be evaluated and it is set, we can set it and return if !evaluateShVars { - result.Set(k, ast.Var{Value: newVar.Value, Sh: newVar.Sh}) + result.Set(k, ast.Var{Value: newVar.Value, Sh: newVar.Sh, Secret: v.Secret}) return nil } // Now we can check for errors since we've handled all the cases when we don't want to evaluate @@ -77,7 +77,7 @@ func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (* } // If the variable is already set, we can set it and return if newVar.Value != nil || newVar.Sh == nil { - result.Set(k, ast.Var{Value: newVar.Value}) + result.Set(k, ast.Var{Value: newVar.Value, Secret: v.Secret}) return nil } // If the variable is dynamic, we need to resolve it first @@ -85,7 +85,7 @@ func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (* if err != nil { return err } - result.Set(k, ast.Var{Value: static}) + result.Set(k, ast.Var{Value: static, Secret: v.Secret}) return nil } } diff --git a/executor_test.go b/executor_test.go index 087a317084..ba5b84add4 100644 --- a/executor_test.go +++ b/executor_test.go @@ -265,6 +265,45 @@ func TestVars(t *testing.T) { ) } +func TestSecretVars(t *testing.T) { + t.Parallel() + NewExecutorTest(t, + WithName("secret vars are masked in logs"), + WithExecutorOptions( + task.WithDir("testdata/secrets"), + ), + WithTask("test-secret-masking"), + ) + NewExecutorTest(t, + WithName("multiple secrets masked"), + WithExecutorOptions( + task.WithDir("testdata/secrets"), + ), + WithTask("test-multiple-secrets"), + ) + NewExecutorTest(t, + WithName("mixed secret and public vars"), + WithExecutorOptions( + task.WithDir("testdata/secrets"), + ), + WithTask("test-mixed"), + ) + NewExecutorTest(t, + WithName("deferred command with secrets"), + WithExecutorOptions( + task.WithDir("testdata/secrets"), + ), + WithTask("test-deferred-secret"), + ) + NewExecutorTest(t, + WithName("env secret limitation"), + WithExecutorOptions( + task.WithDir("testdata/secrets"), + ), + WithTask("test-env-secret-limitation"), + ) +} + func TestRequires(t *testing.T) { t.Parallel() NewExecutorTest(t, diff --git a/internal/templater/secrets.go b/internal/templater/secrets.go new file mode 100644 index 0000000000..8420c5835d --- /dev/null +++ b/internal/templater/secrets.go @@ -0,0 +1,37 @@ +package templater + +import ( + "github.com/go-task/task/v3/taskfile/ast" +) + +// MaskSecrets replaces template placeholders with their values, masking secrets. +// This function uses the Go templater to resolve all variables ({{.VAR}}) while +// masking secret ones as "*****". +func MaskSecrets(cmdTemplate string, vars *ast.Vars) string { + if vars == nil || vars.Len() == 0 { + return cmdTemplate + } + + // Create a cache map with secrets masked + maskedVars := vars.DeepCopy() + for name, v := range maskedVars.All() { + if v.Secret { + // Replace secret value with mask + maskedVars.Set(name, ast.Var{ + Value: "*****", + Secret: true, + }) + } + } + + // Use the templater to resolve the template with masked secrets + cache := &Cache{Vars: maskedVars} + result := Replace(cmdTemplate, cache) + + // If there was an error, return the original template + if cache.Err() != nil { + return cmdTemplate + } + + return result +} diff --git a/internal/templater/templater.go b/internal/templater/templater.go index 896cba23c1..3de897b9a4 100644 --- a/internal/templater/templater.go +++ b/internal/templater/templater.go @@ -121,14 +121,15 @@ func ReplaceVar(v ast.Var, cache *Cache) ast.Var { func ReplaceVarWithExtra(v ast.Var, cache *Cache, extra map[string]any) ast.Var { if v.Ref != "" { - return ast.Var{Value: ResolveRef(v.Ref, cache)} + return ast.Var{Value: ResolveRef(v.Ref, cache), Secret: v.Secret} } return ast.Var{ - Value: ReplaceWithExtra(v.Value, cache, extra), - Sh: ReplaceWithExtra(v.Sh, cache, extra), - Live: v.Live, - Ref: v.Ref, - Dir: v.Dir, + Value: ReplaceWithExtra(v.Value, cache, extra), + Sh: ReplaceWithExtra(v.Sh, cache, extra), + Live: v.Live, + Ref: v.Ref, + Dir: v.Dir, + Secret: v.Secret, } } diff --git a/task.go b/task.go index 79bc36ac59..3ddda9b981 100644 --- a/task.go +++ b/task.go @@ -287,6 +287,8 @@ func (e *Executor) runDeferred(t *ast.Task, call *Call, i int, vars *ast.Vars, d extra["EXIT_CODE"] = fmt.Sprintf("%d", *deferredExitCode) } + // Save template before resolving for secret masking in logs + cmd.CmdTemplate = cmd.Cmd cmd.Cmd = templater.ReplaceWithExtra(cmd.Cmd, cache, extra) cmd.Task = templater.ReplaceWithExtra(cmd.Task, cache, extra) cmd.Vars = templater.ReplaceVarsWithExtra(cmd.Vars, cache, extra) @@ -316,7 +318,15 @@ func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i in } if e.Verbose || (!call.Silent && !cmd.Silent && !t.Silent && !e.Taskfile.Silent && !e.Silent) { - e.Logger.Errf(logger.Green, "task: [%s] %s\n", t.Name(), cmd.Cmd) + // Get runtime vars for masking + varsForMasking, err := e.Compiler.FastGetVariables(t, call) + if err != nil { + return fmt.Errorf("task: failed to get variables: %w", err) + } + + // Mask secret variables in the command template before logging + cmdToLog := templater.MaskSecrets(cmd.CmdTemplate, varsForMasking) + e.Logger.Errf(logger.Green, "task: [%s] %s\n", t.Name(), cmdToLog) } if e.Dry { diff --git a/taskfile/ast/cmd.go b/taskfile/ast/cmd.go index b590e6bb96..2ae74c1b1b 100644 --- a/taskfile/ast/cmd.go +++ b/taskfile/ast/cmd.go @@ -9,7 +9,8 @@ import ( // Cmd is a task command type Cmd struct { - Cmd string + Cmd string // Resolved command (used for execution and fingerprinting) + CmdTemplate string // Original template before variable resolution (used for secret masking) Task string For *For Silent bool @@ -27,6 +28,7 @@ func (c *Cmd) DeepCopy() *Cmd { } return &Cmd{ Cmd: c.Cmd, + CmdTemplate: c.CmdTemplate, Task: c.Task, For: c.For.DeepCopy(), Silent: c.Silent, diff --git a/taskfile/ast/var.go b/taskfile/ast/var.go index ac32c8d57c..c89c867e61 100644 --- a/taskfile/ast/var.go +++ b/taskfile/ast/var.go @@ -8,11 +8,12 @@ import ( // Var represents either a static or dynamic variable. type Var struct { - Value any - Live any - Sh *string - Ref string - Dir string + Value any + Live any + Sh *string + Ref string + Dir string + Secret bool } func (v *Var) UnmarshalYAML(node *yaml.Node) error { @@ -23,21 +24,29 @@ func (v *Var) UnmarshalYAML(node *yaml.Node) error { key = node.Content[0].Value } switch key { - case "sh", "ref", "map": + case "sh", "ref", "map", "value": var m struct { - Sh *string - Ref string - Map any + Sh *string + Ref string + Map any + Value any + Secret bool } if err := node.Decode(&m); err != nil { return errors.NewTaskfileDecodeError(err, node) } v.Sh = m.Sh v.Ref = m.Ref - v.Value = m.Map + v.Secret = m.Secret + // Handle both "map" and "value" keys + if m.Map != nil { + v.Value = m.Map + } else if m.Value != nil { + v.Value = m.Value + } return nil default: - return errors.NewTaskfileDecodeError(nil, node).WithMessage(`%q is not a valid variable type. Try "sh", "ref", "map" or using a scalar value`, key) + return errors.NewTaskfileDecodeError(nil, node).WithMessage(`%q is not a valid variable type. Try "sh", "ref", "map", "value" or using a scalar value`, key) } default: var value any diff --git a/testdata/secrets/Taskfile.yml b/testdata/secrets/Taskfile.yml new file mode 100644 index 0000000000..115ec4dfe3 --- /dev/null +++ b/testdata/secrets/Taskfile.yml @@ -0,0 +1,65 @@ +version: '3' + +vars: + # Public variable + APP_NAME: myapp + + # Secret variable with value + API_KEY: + value: "secret-api-key-123" + secret: true + + # Secret variable from shell command + PASSWORD: + sh: "echo 'my-super-secret-password'" + secret: true + + # Non-secret variable + PUBLIC_URL: https://example.com + +tasks: + test-secret-masking: + desc: Test that secret variables are masked in logs + cmds: + - echo "Deploying {{.APP_NAME}} to {{.PUBLIC_URL}}" + - echo "Using API key {{.API_KEY}}" + - echo "Password is {{.PASSWORD}}" + - echo "Public app name is {{.APP_NAME}}" + + test-multiple-secrets: + desc: Test multiple secrets in one command + cmds: + - echo "API={{.API_KEY}} PWD={{.PASSWORD}}" + + test-mixed: + desc: Test mix of secret and public vars + vars: + LOCAL_SECRET: + value: "task-level-secret" + secret: true + cmds: + - echo "App={{.APP_NAME}} Secret={{.LOCAL_SECRET}} URL={{.PUBLIC_URL}}" + + test-deferred-secret: + desc: Test that deferred commands mask secrets + vars: + DEFERRED_SECRET: + value: "deferred-secret-value" + secret: true + cmds: + - echo "Starting task" + - defer: echo "Cleanup with secret={{.DEFERRED_SECRET}} and app={{.APP_NAME}}" + - echo "Main command executed" + + test-env-secret-limitation: + desc: Test showing that env vars with secret flag are NOT masked (limitation) + env: + SECRET_TOKEN: + value: "env-secret-token-123" + PUBLIC_ENV: "public-value" + cmds: + # Templates {{.VAR}} don't work with env - they're empty + - echo "Token via template is {{.SECRET_TOKEN}}" + # Shell $VAR works but is NOT masked (env vars not in template system) + - echo "Token via shell is $SECRET_TOKEN" + - echo "Public env is {{.PUBLIC_ENV}}" diff --git a/testdata/secrets/testdata/TestSecretVars-deferred_command_with_secrets.golden b/testdata/secrets/testdata/TestSecretVars-deferred_command_with_secrets.golden new file mode 100644 index 0000000000..a1d9ba882b --- /dev/null +++ b/testdata/secrets/testdata/TestSecretVars-deferred_command_with_secrets.golden @@ -0,0 +1,6 @@ +task: [test-deferred-secret] echo "Starting task" +Starting task +task: [test-deferred-secret] echo "Main command executed" +Main command executed +task: [test-deferred-secret] echo "Cleanup with secret=***** and app=myapp" +Cleanup with secret=deferred-secret-value and app=myapp diff --git a/testdata/secrets/testdata/TestSecretVars-env_secret_limitation.golden b/testdata/secrets/testdata/TestSecretVars-env_secret_limitation.golden new file mode 100644 index 0000000000..3c015fa31d --- /dev/null +++ b/testdata/secrets/testdata/TestSecretVars-env_secret_limitation.golden @@ -0,0 +1,6 @@ +task: [test-env-secret-limitation] echo "Token via template is " +Token via template is +task: [test-env-secret-limitation] echo "Token via shell is $SECRET_TOKEN" +Token via shell is env-secret-token-123 +task: [test-env-secret-limitation] echo "Public env is " +Public env is diff --git a/testdata/secrets/testdata/TestSecretVars-mixed_secret_and_public_vars.golden b/testdata/secrets/testdata/TestSecretVars-mixed_secret_and_public_vars.golden new file mode 100644 index 0000000000..fc58e29a4c --- /dev/null +++ b/testdata/secrets/testdata/TestSecretVars-mixed_secret_and_public_vars.golden @@ -0,0 +1,2 @@ +task: [test-mixed] echo "App=myapp Secret=***** URL=https://example.com" +App=myapp Secret=task-level-secret URL=https://example.com diff --git a/testdata/secrets/testdata/TestSecretVars-multiple_secrets_masked.golden b/testdata/secrets/testdata/TestSecretVars-multiple_secrets_masked.golden new file mode 100644 index 0000000000..011f3343f5 --- /dev/null +++ b/testdata/secrets/testdata/TestSecretVars-multiple_secrets_masked.golden @@ -0,0 +1,2 @@ +task: [test-multiple-secrets] echo "API=***** PWD=*****" +API=secret-api-key-123 PWD=my-super-secret-password diff --git a/testdata/secrets/testdata/TestSecretVars-secret_vars_are_masked_in_logs.golden b/testdata/secrets/testdata/TestSecretVars-secret_vars_are_masked_in_logs.golden new file mode 100644 index 0000000000..0a06f95379 --- /dev/null +++ b/testdata/secrets/testdata/TestSecretVars-secret_vars_are_masked_in_logs.golden @@ -0,0 +1,8 @@ +task: [test-secret-masking] echo "Deploying myapp to https://example.com" +Deploying myapp to https://example.com +task: [test-secret-masking] echo "Using API key *****" +Using API key secret-api-key-123 +task: [test-secret-masking] echo "Password is *****" +Password is my-super-secret-password +task: [test-secret-masking] echo "Public app name is myapp" +Public app name is myapp diff --git a/variables.go b/variables.go index bec946bdbf..0abd5bc1af 100644 --- a/variables.go +++ b/variables.go @@ -238,6 +238,7 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err continue } newCmd := cmd.DeepCopy() + newCmd.CmdTemplate = cmd.Cmd newCmd.Cmd = templater.Replace(cmd.Cmd, cache) newCmd.Task = templater.Replace(cmd.Task, cache) newCmd.Vars = templater.ReplaceVars(cmd.Vars, cache) diff --git a/website/src/docs/guide.md b/website/src/docs/guide.md index 1fcee419f3..224c80bbc5 100644 --- a/website/src/docs/guide.md +++ b/website/src/docs/guide.md @@ -1360,6 +1360,163 @@ tasks: map[a:1 b:2 c:3] ``` +### Secret variables + +Task supports marking variables as `secret` to prevent their values from being +displayed in command logs. When a variable is marked as secret, its value will +be replaced with `*****` in the task output logs. + +::: warning + +**Security Notice**: This feature helps prevent accidental exposure of secrets +in logs, but is **not a substitute** for proper secret management practices. + +**What this protects:** + +- ✅ Secret values in console/terminal logs +- ✅ Secret values in CI/CD logs +- ✅ Accidental copy-paste of logs containing secrets + +**What this does NOT protect:** + +- ❌ Secrets visible in process inspection (e.g., `ps aux`) +- ❌ Secrets in shell history +- ❌ Secrets in command output (stdout/stderr) + +Always use proper secret management tools (HashiCorp Vault, AWS Secrets +Manager, etc.) for production environments. + +::: + +To mark a variable as secret, add `secret: true` to the variable definition: + +```yaml +version: '3' + +vars: + API_KEY: + value: 'sk-1234567890abcdef' + secret: true + +tasks: + deploy: + cmds: + - curl -H "Authorization: {{.API_KEY}}" api.example.com + # Logged as: task: [deploy] curl -H "Authorization: *****" api.example.com +``` + +Secret variables work with all variable types: + +::: code-group + +```yaml [Simple Value] +version: '3' + +vars: + PASSWORD: + value: 'my-secret-password' + secret: true + +tasks: + connect: + cmds: + - psql -U user -p {{.PASSWORD}} mydb + # Logged as: psql -U user -p ***** mydb +``` + +```yaml [Shell Command] +version: '3' + +vars: + DB_PASSWORD: + sh: vault read -field=password secret/db + secret: true + +tasks: + migrate: + cmds: + - psql -U admin -p {{.DB_PASSWORD}} mydb + # Password from vault is masked in logs +``` + +```yaml [Task-Level Secret] +version: '3' + +vars: + PUBLIC_URL: https://example.com + +tasks: + deploy: + vars: + DEPLOY_TOKEN: + value: 'secret-token-123' + secret: true + cmds: + - echo "Deploying to {{.PUBLIC_URL}} with token {{.DEPLOY_TOKEN}}" + # Logged as: echo "Deploying to https://example.com with token *****" +``` + +::: + +Multiple secrets in the same command are all masked: + +```yaml +version: '3' + +vars: + API_KEY: + value: 'api-key-123' + secret: true + PASSWORD: + value: 'password-456' + secret: true + +tasks: + setup: + cmds: + - ./setup.sh --api {{.API_KEY}} --pwd {{.PASSWORD}} + # Logged as: ./setup.sh --api ***** --pwd ***** +``` + +::: tip + +**Best practices for secret variables:** + +1. **Use shell commands to load secrets**, not hardcoded values: + + ```yaml + # ❌ BAD - Secret visible in Taskfile + vars: + API_KEY: + value: 'hardcoded-secret' + secret: true + + # ✅ GOOD - Secret loaded from external source + vars: + API_KEY: + sh: vault kv get -field=api_key secret/myapp + secret: true + ``` + +2. **Combine with environment variables:** + + ```yaml + vars: + API_KEY: + sh: echo $MY_API_KEY + secret: true + ``` + +3. **Use .gitignore for secret files:** + + If you use dotenv files, add them to `.gitignore`: + + ```yaml + dotenv: ['.env.local'] # Load from .env.local (in .gitignore) + ``` + +::: + ## Looping over values Task allows you to loop over certain values and execute a command for each. diff --git a/website/src/docs/reference/schema.md b/website/src/docs/reference/schema.md index f28b299857..cfaeecd2c3 100644 --- a/website/src/docs/reference/schema.md +++ b/website/src/docs/reference/schema.md @@ -377,6 +377,33 @@ vars: ttl: 3600 ``` +### Secret Variables (`secret`) + +Mark variables as secret to mask their values in command logs. + +```yaml +vars: + API_KEY: + value: 'sk-1234567890abcdef' + secret: true # This variable will be masked in logs + + DB_PASSWORD: + sh: vault read -field=password secret/db + secret: true # Works with dynamic variables too +``` + +When a variable is marked as `secret: true`, Task will replace its value with +`*****` in command logs. The actual command execution still receives the real +value. + +::: info + +For complete documentation on secret variables, including security +considerations and best practices, see the +[Secret variables](/docs/guide#secret-variables) section in the Guide. + +::: + ### Variable Ordering Variables can reference previously defined variables: diff --git a/website/src/public/schema.json b/website/src/public/schema.json index 8605d98b44..40c59a8349 100644 --- a/website/src/public/schema.json +++ b/website/src/public/schema.json @@ -306,6 +306,10 @@ "map": { "type": "object", "description": "The value will be treated as a literal map type and stored in the variable" + }, + "secret": { + "type": "boolean", + "description": "Marks the variable as secret. Secret values will be masked as ***** in command logs to prevent accidental exposure of sensitive information." } }, "additionalProperties": false