feat: do not log secret variables (#2514)

This commit is contained in:
Valentin Maerten
2026-06-29 14:50:08 +02:00
committed by GitHub
parent c73d53f4e9
commit 6abbbcb265
21 changed files with 557 additions and 38 deletions

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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,
}
}

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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 := "<none>"
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 {

83
testdata/secrets/Taskfile.yml vendored Normal file
View File

@@ -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}}"

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,2 @@
task: [test-multiple-secrets] echo "API=***** PWD=*****"
API=secret-api-key-123 PWD=my-super-secret-password

View File

@@ -0,0 +1,2 @@
task: [test-secret-key-order] echo "Value=***** Sh=*****"
Value=order-independent-secret Sh=sh-order-independent-secret

View File

@@ -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

View File

@@ -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"

View File

@@ -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)

View File

@@ -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.

View File

@@ -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:

View File

@@ -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