mirror of
https://github.com/go-task/task.git
synced 2026-02-24 12:10:47 +01:00
feat: do not log secret variables
This commit is contained in:
10
compiler.go
10
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,6 +282,38 @@ 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"),
|
||||
)
|
||||
}
|
||||
|
||||
func TestRequires(t *testing.T) {
|
||||
t.Parallel()
|
||||
NewExecutorTest(t,
|
||||
|
||||
37
internal/templater/secrets.go
Normal file
37
internal/templater/secrets.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
14
task.go
14
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)
|
||||
}
|
||||
|
||||
// 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.If = templater.ReplaceWithExtra(cmd.If, cache, extra)
|
||||
@@ -393,7 +395,15 @@ func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i in
|
||||
}
|
||||
|
||||
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)
|
||||
// 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 {
|
||||
@@ -410,7 +420,7 @@ func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i in
|
||||
return fmt.Errorf("task: failed to get variables: %w", err)
|
||||
}
|
||||
stdOut, stdErr, closer := outputWrapper.WrapWriter(e.Stdout, e.Stderr, t.Prefix, outputTemplater)
|
||||
|
||||
|
||||
err = execext.RunCommand(ctx, &execext.RunCommandOptions{
|
||||
Command: cmd.Cmd,
|
||||
Dir: t.Dir,
|
||||
|
||||
@@ -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
|
||||
If string
|
||||
@@ -28,6 +29,7 @@ func (c *Cmd) DeepCopy() *Cmd {
|
||||
}
|
||||
return &Cmd{
|
||||
Cmd: c.Cmd,
|
||||
CmdTemplate: c.CmdTemplate,
|
||||
Task: c.Task,
|
||||
For: c.For.DeepCopy(),
|
||||
If: c.If,
|
||||
|
||||
@@ -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
|
||||
|
||||
52
testdata/secrets/Taskfile.yml
vendored
Normal file
52
testdata/secrets/Taskfile.yml
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
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"
|
||||
6
testdata/secrets/testdata/TestSecretVars-deferred_command_with_secrets.golden
vendored
Normal file
6
testdata/secrets/testdata/TestSecretVars-deferred_command_with_secrets.golden
vendored
Normal 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
|
||||
2
testdata/secrets/testdata/TestSecretVars-mixed_secret_and_public_vars.golden
vendored
Normal file
2
testdata/secrets/testdata/TestSecretVars-mixed_secret_and_public_vars.golden
vendored
Normal 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
|
||||
2
testdata/secrets/testdata/TestSecretVars-multiple_secrets_masked.golden
vendored
Normal file
2
testdata/secrets/testdata/TestSecretVars-multiple_secrets_masked.golden
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
task: [test-multiple-secrets] echo "API=***** PWD=*****"
|
||||
API=secret-api-key-123 PWD=my-super-secret-password
|
||||
8
testdata/secrets/testdata/TestSecretVars-secret_vars_are_masked_in_logs.golden
vendored
Normal file
8
testdata/secrets/testdata/TestSecretVars-secret_vars_are_masked_in_logs.golden
vendored
Normal 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
|
||||
@@ -243,6 +243,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.If = templater.Replace(cmd.If, cache)
|
||||
|
||||
@@ -1549,6 +1549,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.
|
||||
|
||||
@@ -379,6 +379,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:
|
||||
|
||||
Reference in New Issue
Block a user