fix: prevent secret variable leaks in summary, verbose and key ordering

- mask secret values in `task --summary` (commands and vars listing)
- mask resolved value of dynamic (sh) secrets in verbose logs
- use masked command for platform-skipped verbose log
- allow `secret` key in any position in a var definition (not only first)
- add `value` to the JSON schema var definition
- skip masking pass when no secret is present and dedup mask helpers
- document that the `secret` flag is not propagated to derived variables
This commit is contained in:
Valentin Maerten
2026-06-29 12:36:56 +02:00
parent 8545e02e5e
commit da90ecd083
12 changed files with 175 additions and 60 deletions

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

@@ -8,39 +8,19 @@ import (
// 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
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.Len() == 0 {
// Still need to resolve extra vars even if no vars
cache := &Cache{Vars: ast.NewVars()}
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
@@ -48,7 +28,7 @@ func MaskSecretsWithExtra(cmdTemplate string, vars *ast.Vars, extra map[string]a
return result
}
// Create a cache map with secrets masked
// Create a copy with secret values masked, leaving the originals untouched.
maskedVars := vars.DeepCopy()
for name, v := range maskedVars.All() {
if v.Secret {
@@ -62,9 +42,20 @@ func MaskSecretsWithExtra(cmdTemplate string, vars *ast.Vars, extra map[string]a
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
}