diff --git a/compiler.go b/compiler.go index 68aa3cd3..9c4f8a07 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 } } @@ -184,7 +184,12 @@ func (c *Compiler) HandleDynamicVar(v ast.Var, dir string, e []string) (string, result = strings.TrimSuffix(result, "\n") c.dynamicCache[*v.Sh] = result - c.Logger.VerboseErrf(logger.Magenta, "task: dynamic variable: %q result: %q\n", *v.Sh, result) + // Never print the resolved value of a secret variable, even in verbose mode + logResult := result + if v.Secret { + logResult = "*****" + } + c.Logger.VerboseErrf(logger.Magenta, "task: dynamic variable: %q result: %q\n", *v.Sh, logResult) return result, nil } diff --git a/executor_test.go b/executor_test.go index d963fa6d..60172b87 100644 --- a/executor_test.go +++ b/executor_test.go @@ -283,6 +283,68 @@ 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"), + ) + NewExecutorTest(t, + WithName("secret vars are masked in summary"), + WithExecutorOptions( + task.WithDir("testdata/secrets"), + task.WithSummary(true), + ), + WithTask("test-secret-masking"), + ) + NewExecutorTest(t, + WithName("dynamic secret masked in verbose"), + WithExecutorOptions( + task.WithDir("testdata/secrets"), + task.WithVerbose(true), + ), + WithTask("test-dynamic-secret-verbose"), + ) + NewExecutorTest(t, + WithName("secret key order independent"), + WithExecutorOptions( + task.WithDir("testdata/secrets"), + ), + WithTask("test-secret-key-order"), + ) +} + func TestRequires(t *testing.T) { t.Parallel() NewExecutorTest(t, diff --git a/internal/summary/summary.go b/internal/summary/summary.go index 9edd9511..573e874a 100644 --- a/internal/summary/summary.go +++ b/internal/summary/summary.go @@ -117,7 +117,12 @@ func printTaskCommands(l *logger.Logger, t *ast.Task) { isCommand := c.Cmd != "" l.Outf(logger.Default, " - ") if isCommand { - l.Outf(logger.Yellow, "%s\n", c.Cmd) + // Use the masked command so secret values are not leaked in summaries + logCmd := c.LogCmd + if logCmd == "" { + logCmd = c.Cmd + } + l.Outf(logger.Yellow, "%s\n", logCmd) } else { l.Outf(logger.Green, "Task: %s\n", c.Task) } @@ -196,6 +201,11 @@ func printTaskEnv(l *logger.Logger, t *ast.Task) { // formatVarValue formats a variable value based on its type. // Handles static values, shell commands (sh:), references (ref:), and maps. func formatVarValue(v ast.Var) string { + // Never expose secret variables in the summary, whatever their type + if v.Secret { + return "*****" + } + // Shell command - check this first before Value // because dynamic vars may have both Sh and an empty Value if v.Sh != nil { diff --git a/internal/templater/secrets.go b/internal/templater/secrets.go new file mode 100644 index 00000000..2338254c --- /dev/null +++ b/internal/templater/secrets.go @@ -0,0 +1,61 @@ +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 { + return MaskSecretsWithExtra(cmdTemplate, vars, nil) +} + +// MaskSecretsWithExtra is like MaskSecrets but also resolves extra variables (e.g., loop vars). +func MaskSecretsWithExtra(cmdTemplate string, vars *ast.Vars, extra map[string]any) string { + if vars == nil { + vars = ast.NewVars() + } + + // Fast path: if there are no secrets to mask, resolve the template directly + // without the extra DeepCopy + masking pass. + if !hasSecrets(vars) { + cache := &Cache{Vars: vars} + result := ReplaceWithExtra(cmdTemplate, cache, extra) + if cache.Err() != nil { + return cmdTemplate + } + return result + } + + // Create a copy with secret values masked, leaving the originals untouched. + maskedVars := vars.DeepCopy() + for name, v := range maskedVars.All() { + if v.Secret { + maskedVars.Set(name, ast.Var{ + Value: "*****", + Secret: true, + }) + } + } + + cache := &Cache{Vars: maskedVars} + result := ReplaceWithExtra(cmdTemplate, cache, extra) + + // If there was an error, return the original template + if cache.Err() != nil { + return cmdTemplate + } + + return result +} + +// hasSecrets reports whether any variable is marked as secret. +func hasSecrets(vars *ast.Vars) bool { + for _, v := range vars.All() { + if v.Secret { + return true + } + } + return false +} diff --git a/internal/templater/templater.go b/internal/templater/templater.go index c37bae36..eb4579be 100644 --- a/internal/templater/templater.go +++ b/internal/templater/templater.go @@ -132,14 +132,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 54cda927..98d340c9 100644 --- a/task.go +++ b/task.go @@ -349,6 +349,8 @@ func (e *Executor) runDeferred(t *ast.Task, call *Call, i int, vars *ast.Vars, d extra["EXIT_CODE"] = fmt.Sprintf("%d", *deferredExitCode) } + // Resolve template with secrets masked for logging + cmd.LogCmd = templater.MaskSecretsWithExtra(cmd.Cmd, vars, extra) cmd.Cmd = templater.ReplaceWithExtra(cmd.Cmd, cache, extra) cmd.Task = templater.ReplaceWithExtra(cmd.Task, cache, extra) cmd.If = templater.ReplaceWithExtra(cmd.If, cache, extra) @@ -388,12 +390,12 @@ func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i in return err case cmd.Cmd != "": if !shouldRunOnCurrentPlatform(cmd.Platforms) { - e.Logger.VerboseOutf(logger.Yellow, "task: [%s] %s not for current platform - ignored\n", t.Name(), cmd.Cmd) + e.Logger.VerboseOutf(logger.Yellow, "task: [%s] %s not for current platform - ignored\n", t.Name(), cmd.LogCmd) return nil } if e.Verbose || (!call.Silent && !cmd.Silent && !t.IsSilent() && !e.Taskfile.Silent && !e.Silent) { - e.Logger.Errf(logger.Green, "task: [%s] %s\n", t.Name(), cmd.Cmd) + e.Logger.Errf(logger.Green, "task: [%s] %s\n", t.Name(), cmd.LogCmd) } if e.Dry { diff --git a/taskfile/ast/cmd.go b/taskfile/ast/cmd.go index 36ff3a5f..84023480 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) + LogCmd string // Command with secrets masked (used for logging) Task string For *For If string @@ -28,6 +29,7 @@ func (c *Cmd) DeepCopy() *Cmd { } return &Cmd{ Cmd: c.Cmd, + LogCmd: c.LogCmd, Task: c.Task, For: c.For.DeepCopy(), If: c.If, diff --git a/taskfile/ast/var.go b/taskfile/ast/var.go index a826663c..f02bda64 100644 --- a/taskfile/ast/var.go +++ b/taskfile/ast/var.go @@ -8,37 +8,54 @@ 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 { switch node.Kind { case yaml.MappingNode: - key := "" - if len(node.Content) > 0 { - key = node.Content[0].Value + var m struct { + Sh *string + Ref string + Map any + Value any + Secret bool } - switch key { - case "sh", "ref", "map": - var m struct { - Sh *string - Ref string - Map any + if err := node.Decode(&m); err != nil { + return errors.NewTaskfileDecodeError(err, node) + } + // Validate the keys regardless of their order: every key must be known + // and at least one type-defining key must be present. "secret" is a + // modifier, not a type, so it can appear in any position. + hasType := false + for i := 0; i+1 < len(node.Content); i += 2 { + switch node.Content[i].Value { + case "sh", "ref", "map", "value": + hasType = true + case "secret": + // modifier, not a type + default: + return errors.NewTaskfileDecodeError(nil, node).WithMessage(`%q is not a valid variable type. Try "sh", "ref", "map", "value" or using a scalar value`, node.Content[i].Value) } - if err := node.Decode(&m); err != nil { - return errors.NewTaskfileDecodeError(err, node) - } - v.Sh = m.Sh - v.Ref = m.Ref + } + if !hasType { + return errors.NewTaskfileDecodeError(nil, node).WithMessage(`a variable must define one of "sh", "ref", "map", "value" or be a scalar value`) + } + v.Sh = m.Sh + v.Ref = m.Ref + v.Secret = m.Secret + // Handle both "map" and "value" keys + if m.Map != nil { v.Value = m.Map - 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) + } else if m.Value != nil { + v.Value = m.Value } + return nil default: var value any if err := node.Decode(&value); err != nil { diff --git a/testdata/secrets/Taskfile.yml b/testdata/secrets/Taskfile.yml new file mode 100644 index 00000000..27abae21 --- /dev/null +++ b/testdata/secrets/Taskfile.yml @@ -0,0 +1,83 @@ +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-dynamic-secret-verbose: + desc: Test that dynamic (sh) secrets are masked even in verbose logs + cmds: + - echo "Password is {{.PASSWORD}}" + + test-secret-key-order: + desc: Test that "secret" may be declared before the value/sh key + vars: + SECRET_FIRST: + secret: true + value: "order-independent-secret" + SH_SECRET_FIRST: + secret: true + sh: "echo 'sh-order-independent-secret'" + cmds: + - echo "Value={{.SECRET_FIRST}} Sh={{.SH_SECRET_FIRST}}" + + 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" + secret: true + 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 00000000..a1d9ba88 --- /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-dynamic_secret_masked_in_verbose.golden b/testdata/secrets/testdata/TestSecretVars-dynamic_secret_masked_in_verbose.golden new file mode 100644 index 00000000..c49253e7 --- /dev/null +++ b/testdata/secrets/testdata/TestSecretVars-dynamic_secret_masked_in_verbose.golden @@ -0,0 +1,5 @@ +task: dynamic variable: "echo 'my-super-secret-password'" result: "*****" +task: "test-dynamic-secret-verbose" started +task: [test-dynamic-secret-verbose] echo "Password is *****" +Password is my-super-secret-password +task: "test-dynamic-secret-verbose" finished 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 00000000..3c015fa3 --- /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 00000000..fc58e29a --- /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 00000000..011f3343 --- /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_key_order_independent.golden b/testdata/secrets/testdata/TestSecretVars-secret_key_order_independent.golden new file mode 100644 index 00000000..86770630 --- /dev/null +++ b/testdata/secrets/testdata/TestSecretVars-secret_key_order_independent.golden @@ -0,0 +1,2 @@ +task: [test-secret-key-order] echo "Value=***** Sh=*****" +Value=order-independent-secret Sh=sh-order-independent-secret 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 00000000..0a06f953 --- /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/testdata/secrets/testdata/TestSecretVars-secret_vars_are_masked_in_summary.golden b/testdata/secrets/testdata/TestSecretVars-secret_vars_are_masked_in_summary.golden new file mode 100644 index 00000000..8ab12636 --- /dev/null +++ b/testdata/secrets/testdata/TestSecretVars-secret_vars_are_masked_in_summary.golden @@ -0,0 +1,15 @@ +task: test-secret-masking + +Test that secret variables are masked in logs + +vars: + APP_NAME: "myapp" + API_KEY: ***** + PASSWORD: ***** + PUBLIC_URL: "https://example.com" + +commands: + - echo "Deploying myapp to https://example.com" + - echo "Using API key *****" + - echo "Password is *****" + - echo "Public app name is myapp" diff --git a/variables.go b/variables.go index 733cfaf4..0f8fcb73 100644 --- a/variables.go +++ b/variables.go @@ -239,6 +239,8 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err extra["KEY"] = keys[i] } newCmd := cmd.DeepCopy() + // Resolve template with secrets masked + loop vars for logging + newCmd.LogCmd = templater.MaskSecretsWithExtra(cmd.Cmd, cache.Vars, extra) newCmd.Cmd = templater.ReplaceWithExtra(cmd.Cmd, cache, extra) newCmd.Task = templater.ReplaceWithExtra(cmd.Task, cache, extra) newCmd.If = templater.ReplaceWithExtra(cmd.If, cache, extra) @@ -254,6 +256,8 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err continue } newCmd := cmd.DeepCopy() + // Resolve template with secrets masked for logging + newCmd.LogCmd = templater.MaskSecrets(cmd.Cmd, cache.Vars) newCmd.Cmd = templater.Replace(cmd.Cmd, cache) newCmd.Task = templater.Replace(cmd.Task, cache) newCmd.If = templater.Replace(cmd.If, cache) diff --git a/website/src/docs/guide.md b/website/src/docs/guide.md index f18cee34..8a06d38a 100644 --- a/website/src/docs/guide.md +++ b/website/src/docs/guide.md @@ -1614,6 +1614,198 @@ 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) +- ❌ Secret values copied into derived (non-secret) variables + +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) + ``` + +::: + +::: warning + +**Secrets are not propagated to derived variables.** The `secret` flag only +masks the variable it is set on. A non-secret variable that references a secret +will expose the resolved value in logs: + +```yaml +version: '3' + +vars: + API_KEY: + value: 'secret-api-key-123' + secret: true + HEADER: + value: 'Bearer {{.API_KEY}}' # ❌ not marked as secret + +tasks: + call: + cmds: + - curl -H "{{.HEADER}}" api.example.com + # Logged as: curl -H "Bearer secret-api-key-123" api.example.com (LEAK) +``` + +Mark every variable that carries a secret value as `secret: true`: + +```yaml +vars: + HEADER: + value: 'Bearer {{.API_KEY}}' + secret: true # ✅ masked +``` + +::: + ## 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 2aa51eb3..71ead075 100644 --- a/website/src/docs/reference/schema.md +++ b/website/src/docs/reference/schema.md @@ -385,6 +385,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 2bbe6e5f..71e5b3b2 100644 --- a/website/src/public/schema.json +++ b/website/src/public/schema.json @@ -318,6 +318,13 @@ "map": { "type": "object", "description": "The value will be treated as a literal map type and stored in the variable" + }, + "value": { + "description": "A literal value assigned to the variable. Useful together with other keys such as 'secret'" + }, + "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