Skip to content

Commit af264b1

Browse files
committed
feat: do not log secret variables
1 parent a927ffb commit af264b1

15 files changed

+371
-25
lines changed

compiler.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (*
5151
return nil, err
5252
}
5353
for k, v := range specialVars {
54-
result.Set(k, ast.Var{Value: v})
54+
result.Set(k, ast.Var{Value: v, Secret: false})
5555
}
5656

5757
getRangeFunc := func(dir string) func(k string, v ast.Var) error {
@@ -62,12 +62,12 @@ func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (*
6262
// If the variable should not be evaluated, but is nil, set it to an empty string
6363
// This stops empty interface errors when using the templater to replace values later
6464
if !evaluateShVars && newVar.Value == nil {
65-
result.Set(k, ast.Var{Value: ""})
65+
result.Set(k, ast.Var{Value: "", Secret: v.Secret})
6666
return nil
6767
}
6868
// If the variable should not be evaluated and it is set, we can set it and return
6969
if !evaluateShVars {
70-
result.Set(k, ast.Var{Value: newVar.Value})
70+
result.Set(k, ast.Var{Value: newVar.Value, Secret: v.Secret})
7171
return nil
7272
}
7373
// Now we can check for errors since we've handled all the cases when we don't want to evaluate
@@ -76,15 +76,15 @@ func (c *Compiler) getVariables(t *ast.Task, call *Call, evaluateShVars bool) (*
7676
}
7777
// If the variable is already set, we can set it and return
7878
if newVar.Value != nil || newVar.Sh == nil {
79-
result.Set(k, ast.Var{Value: newVar.Value})
79+
result.Set(k, ast.Var{Value: newVar.Value, Secret: v.Secret})
8080
return nil
8181
}
8282
// If the variable is dynamic, we need to resolve it first
8383
static, err := c.HandleDynamicVar(newVar, dir, env.GetFromVars(result))
8484
if err != nil {
8585
return err
8686
}
87-
result.Set(k, ast.Var{Value: static})
87+
result.Set(k, ast.Var{Value: static, Secret: v.Secret})
8888
return nil
8989
}
9090
}

executor_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,38 @@ func TestVars(t *testing.T) {
265265
)
266266
}
267267

268+
func TestSecretVars(t *testing.T) {
269+
t.Parallel()
270+
NewExecutorTest(t,
271+
WithName("secret vars are masked in logs"),
272+
WithExecutorOptions(
273+
task.WithDir("testdata/secrets"),
274+
),
275+
WithTask("test-secret-masking"),
276+
)
277+
NewExecutorTest(t,
278+
WithName("multiple secrets masked"),
279+
WithExecutorOptions(
280+
task.WithDir("testdata/secrets"),
281+
),
282+
WithTask("test-multiple-secrets"),
283+
)
284+
NewExecutorTest(t,
285+
WithName("mixed secret and public vars"),
286+
WithExecutorOptions(
287+
task.WithDir("testdata/secrets"),
288+
),
289+
WithTask("test-mixed"),
290+
)
291+
NewExecutorTest(t,
292+
WithName("deferred command with secrets"),
293+
WithExecutorOptions(
294+
task.WithDir("testdata/secrets"),
295+
),
296+
WithTask("test-deferred-secret"),
297+
)
298+
}
299+
268300
func TestRequires(t *testing.T) {
269301
t.Parallel()
270302
NewExecutorTest(t,

internal/templater/secrets.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package templater
2+
3+
import (
4+
"github.com/go-task/task/v3/taskfile/ast"
5+
)
6+
7+
// MaskSecrets replaces template placeholders with their values, masking secrets.
8+
// This function uses the Go templater to resolve all variables ({{.VAR}}) while
9+
// masking secret ones as "*****".
10+
func MaskSecrets(cmdTemplate string, vars *ast.Vars) string {
11+
if vars == nil || vars.Len() == 0 {
12+
return cmdTemplate
13+
}
14+
15+
// Create a cache map with secrets masked
16+
maskedVars := vars.DeepCopy()
17+
for name, v := range maskedVars.All() {
18+
if v.Secret {
19+
// Replace secret value with mask
20+
maskedVars.Set(name, ast.Var{
21+
Value: "*****",
22+
Secret: true,
23+
})
24+
}
25+
}
26+
27+
// Use the templater to resolve the template with masked secrets
28+
cache := &Cache{Vars: maskedVars}
29+
result := Replace(cmdTemplate, cache)
30+
31+
// If there was an error, return the original template
32+
if cache.Err() != nil {
33+
return cmdTemplate
34+
}
35+
36+
return result
37+
}

internal/templater/templater.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -121,14 +121,15 @@ func ReplaceVar(v ast.Var, cache *Cache) ast.Var {
121121

122122
func ReplaceVarWithExtra(v ast.Var, cache *Cache, extra map[string]any) ast.Var {
123123
if v.Ref != "" {
124-
return ast.Var{Value: ResolveRef(v.Ref, cache)}
124+
return ast.Var{Value: ResolveRef(v.Ref, cache), Secret: v.Secret}
125125
}
126126
return ast.Var{
127-
Value: ReplaceWithExtra(v.Value, cache, extra),
128-
Sh: ReplaceWithExtra(v.Sh, cache, extra),
129-
Live: v.Live,
130-
Ref: v.Ref,
131-
Dir: v.Dir,
127+
Value: ReplaceWithExtra(v.Value, cache, extra),
128+
Sh: ReplaceWithExtra(v.Sh, cache, extra),
129+
Live: v.Live,
130+
Ref: v.Ref,
131+
Dir: v.Dir,
132+
Secret: v.Secret,
132133
}
133134
}
134135

task.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,8 @@ func (e *Executor) runDeferred(t *ast.Task, call *Call, i int, vars *ast.Vars, d
289289
extra["EXIT_CODE"] = fmt.Sprintf("%d", *deferredExitCode)
290290
}
291291

292+
// Save template before resolving for secret masking in logs
293+
cmd.CmdTemplate = cmd.Cmd
292294
cmd.Cmd = templater.ReplaceWithExtra(cmd.Cmd, cache, extra)
293295
cmd.Task = templater.ReplaceWithExtra(cmd.Task, cache, extra)
294296
cmd.Vars = templater.ReplaceVarsWithExtra(cmd.Vars, cache, extra)
@@ -318,7 +320,15 @@ func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i in
318320
}
319321

320322
if e.Verbose || (!call.Silent && !cmd.Silent && !t.Silent && !e.Taskfile.Silent && !e.Silent) {
321-
e.Logger.Errf(logger.Green, "task: [%s] %s\n", t.Name(), cmd.Cmd)
323+
// Get runtime vars for masking
324+
varsForMasking, err := e.Compiler.FastGetVariables(t, call)
325+
if err != nil {
326+
return fmt.Errorf("task: failed to get variables: %w", err)
327+
}
328+
329+
// Mask secret variables in the command template before logging
330+
cmdToLog := templater.MaskSecrets(cmd.CmdTemplate, varsForMasking)
331+
e.Logger.Errf(logger.Green, "task: [%s] %s\n", t.Name(), cmdToLog)
322332
}
323333

324334
if e.Dry {
@@ -335,7 +345,7 @@ func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i in
335345
return fmt.Errorf("task: failed to get variables: %w", err)
336346
}
337347
stdOut, stdErr, closer := outputWrapper.WrapWriter(e.Stdout, e.Stderr, t.Prefix, outputTemplater)
338-
348+
339349
err = execext.RunCommand(ctx, &execext.RunCommandOptions{
340350
Command: cmd.Cmd,
341351
Dir: t.Dir,

taskfile/ast/cmd.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ import (
99

1010
// Cmd is a task command
1111
type Cmd struct {
12-
Cmd string
12+
Cmd string // Resolved command (used for execution and fingerprinting)
13+
CmdTemplate string // Original template before variable resolution (used for secret masking)
1314
Task string
1415
For *For
1516
Silent bool
@@ -27,6 +28,7 @@ func (c *Cmd) DeepCopy() *Cmd {
2728
}
2829
return &Cmd{
2930
Cmd: c.Cmd,
31+
CmdTemplate: c.CmdTemplate,
3032
Task: c.Task,
3133
For: c.For.DeepCopy(),
3234
Silent: c.Silent,

taskfile/ast/var.go

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@ import (
88

99
// Var represents either a static or dynamic variable.
1010
type Var struct {
11-
Value any
12-
Live any
13-
Sh *string
14-
Ref string
15-
Dir string
11+
Value any
12+
Live any
13+
Sh *string
14+
Ref string
15+
Dir string
16+
Secret bool
1617
}
1718

1819
func (v *Var) UnmarshalYAML(node *yaml.Node) error {
@@ -23,21 +24,29 @@ func (v *Var) UnmarshalYAML(node *yaml.Node) error {
2324
key = node.Content[0].Value
2425
}
2526
switch key {
26-
case "sh", "ref", "map":
27+
case "sh", "ref", "map", "value":
2728
var m struct {
28-
Sh *string
29-
Ref string
30-
Map any
29+
Sh *string
30+
Ref string
31+
Map any
32+
Value any
33+
Secret bool
3134
}
3235
if err := node.Decode(&m); err != nil {
3336
return errors.NewTaskfileDecodeError(err, node)
3437
}
3538
v.Sh = m.Sh
3639
v.Ref = m.Ref
37-
v.Value = m.Map
40+
v.Secret = m.Secret
41+
// Handle both "map" and "value" keys
42+
if m.Map != nil {
43+
v.Value = m.Map
44+
} else if m.Value != nil {
45+
v.Value = m.Value
46+
}
3847
return nil
3948
default:
40-
return errors.NewTaskfileDecodeError(nil, node).WithMessage(`%q is not a valid variable type. Try "sh", "ref", "map" or using a scalar value`, key)
49+
return errors.NewTaskfileDecodeError(nil, node).WithMessage(`%q is not a valid variable type. Try "sh", "ref", "map", "value" or using a scalar value`, key)
4150
}
4251
default:
4352
var value any

testdata/secrets/Taskfile.yml

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
version: '3'
2+
3+
vars:
4+
# Public variable
5+
APP_NAME: myapp
6+
7+
# Secret variable with value
8+
API_KEY:
9+
value: "secret-api-key-123"
10+
secret: true
11+
12+
# Secret variable from shell command
13+
PASSWORD:
14+
sh: "echo 'my-super-secret-password'"
15+
secret: true
16+
17+
# Non-secret variable
18+
PUBLIC_URL: https://example.com
19+
20+
tasks:
21+
test-secret-masking:
22+
desc: Test that secret variables are masked in logs
23+
cmds:
24+
- echo "Deploying {{.APP_NAME}} to {{.PUBLIC_URL}}"
25+
- echo "Using API key {{.API_KEY}}"
26+
- echo "Password is {{.PASSWORD}}"
27+
- echo "Public app name is {{.APP_NAME}}"
28+
29+
test-multiple-secrets:
30+
desc: Test multiple secrets in one command
31+
cmds:
32+
- echo "API={{.API_KEY}} PWD={{.PASSWORD}}"
33+
34+
test-mixed:
35+
desc: Test mix of secret and public vars
36+
vars:
37+
LOCAL_SECRET:
38+
value: "task-level-secret"
39+
secret: true
40+
cmds:
41+
- echo "App={{.APP_NAME}} Secret={{.LOCAL_SECRET}} URL={{.PUBLIC_URL}}"
42+
43+
test-deferred-secret:
44+
desc: Test that deferred commands mask secrets
45+
vars:
46+
DEFERRED_SECRET:
47+
value: "deferred-secret-value"
48+
secret: true
49+
cmds:
50+
- echo "Starting task"
51+
- defer: echo "Cleanup with secret={{.DEFERRED_SECRET}} and app={{.APP_NAME}}"
52+
- echo "Main command executed"
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
task: [test-deferred-secret] echo "Starting task"
2+
Starting task
3+
task: [test-deferred-secret] echo "Main command executed"
4+
Main command executed
5+
task: [test-deferred-secret] echo "Cleanup with secret=***** and app=myapp"
6+
Cleanup with secret=deferred-secret-value and app=myapp
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
task: [test-mixed] echo "App=myapp Secret=***** URL=https://example.com"
2+
App=myapp Secret=task-level-secret URL=https://example.com

0 commit comments

Comments
 (0)