fix(summer): mask secret flag values in a delegate's error (WR-10)

This commit is contained in:
Jakub Zych
2026-10-22 04:00:31 +02:00
parent 707372bf65
commit 17c722da46
7 changed files with 147 additions and 65 deletions

View File

@@ -46,7 +46,7 @@ Use cases:
- Hidden commands (`bonfire.Command.Hidden`) run but stay out of the list unless `list --all`.
- JSON catalog: `list --format=json` prints every command, hidden ones flagged, with arguments, options, defaults and examples as a `bonfire.Listing` (`bonfire.Describe` builds it from a command slice).
- Documentation coverage: `bonfire.CheckDocs` reports commands lacking a description, long help, examples, or argument and option descriptions.
- Error block and exit codes: `bonfire.Execute` runs the root through fang and returns `bonfire.ExitOK`, `bonfire.ExitFailure`, or `bonfire.ExitUsage` for an unknown command or flag and a wrong number of arguments (`bonfire.UsageError`). Errors print on the error stream as a block (`bonfire.RenderError`): the ERROR badge and the message, the explanation and Try lines of `bonfire.WithHint` (`{bin}` becomes `bin/<name>` or `summer`), cobra's "did you mean" suggestions as Try lines, and "Run with -v for details." or, at `-v`, the wrapped error chain; every message passes through `sunscreen.Scrub` and every line through `bonfire.Sanitize`, which strips control characters. Errors wrapped by `bonfire.Reported` print nothing. An error carrying a code through `bonfire.WithExitCode` exits with that code; summer uses it to exit with the code of an app binary it ran. `bonfire.Fail` prints errors that happen before the root exists. For an application binary (any root other than `summer`) that fails, other than with a usage error, while the working directory has no `config/` directory and a parent directory holds `summer.yaml`, both add the hint "This command runs from the application root." with `cd <relative path>; bin/<name> <args>` (only the message changes); the values of flags named like a password, secret, token or key are masked as `***` in that line.
- Error block and exit codes: `bonfire.Execute` runs the root through fang and returns `bonfire.ExitOK`, `bonfire.ExitFailure`, or `bonfire.ExitUsage` for an unknown command or flag and a wrong number of arguments (`bonfire.UsageError`). Errors print on the error stream as a block (`bonfire.RenderError`): the ERROR badge and the message, the explanation and Try lines of `bonfire.WithHint` (`{bin}` becomes `bin/<name>` or `summer`), cobra's "did you mean" suggestions as Try lines, and "Run with -v for details." or, at `-v`, the wrapped error chain; every message passes through `sunscreen.Scrub` and every line through `bonfire.Sanitize`, which strips control characters. Errors wrapped by `bonfire.Reported` print nothing. An error carrying a code through `bonfire.WithExitCode` exits with that code; summer uses it to exit with the code of an app binary it ran. `bonfire.Fail` prints errors that happen before the root exists. For an application binary (any root other than `summer`) that fails, other than with a usage error, while the working directory has no `config/` directory and a parent directory holds `summer.yaml`, both add the hint "This command runs from the application root." with `cd <relative path>; bin/<name> <args>` (only the message changes); the values of flags named like a password, secret, token or key are masked as `***` in that line, also a value that starts with `-`.
- Name checks at boot: two commands with the same name, or a command named `list` or `help`, make `bonfire.NewRoot` fail with `bonfire.ErrDuplicateCommand`; a command flag that reuses a global option's name or shorthand fails with `bonfire.ErrReservedFlag` instead of panicking.
- Injectable streams (`bonfire.NewRootIO`, `bonfire.NewOutput`) so commands can be tested against buffers.
- In-process calls: `bonfire.Call` runs a named command with arguments against any writer (Laravel `Artisan::call`), and `bonfire.Catalog` holds an application binary's final command list so code outside the Cobra root, such as the conga scheduler, can call any registered command.
@@ -191,7 +191,7 @@ Without these variables, color is enabled only when stdout is a terminal. `--ans
## Dependencies
- SummerCMS modules: `sunscreen` (scrubs error messages).
- SummerCMS modules: `sunscreen` (scrubs error messages); the framework-internal package `internal/argmask` (masks secret flag values in the Try line, shared with summer's delegate errors).
- Third-party: `github.com/spf13/cobra`, `github.com/spf13/pflag`, `golang.org/x/term`, `charm.land/lipgloss/v2` (with `lipgloss/v2/table`), `charm.land/bubbletea/v2`, `charm.land/bubbles/v2` (spinner, progress, key), `charm.land/huh/v2`, `charm.land/fang/v2` and `github.com/charmbracelet/colorprofile` (decided in `.planning/notes/2026-10-21-console-charm-stack.md`).
- Standard library: `bufio`, `context`, `encoding/json`, `errors`, `fmt`, `io`, `os`, `path/filepath`, `runtime/debug`, `slices`, `strconv`, `strings`, `sync`, `time`, `unicode/utf8`.

View File

@@ -8,6 +8,7 @@ import (
"strings"
"unicode"
"git.golem15.com/golem15/summercms/internal/argmask"
"git.golem15.com/golem15/summercms/modules/sunscreen"
"github.com/charmbracelet/colorprofile"
)
@@ -284,63 +285,20 @@ func wrongDirectory(name string, err error, args []string, dir string) error {
if rerr != nil {
return err
}
try := "cd " + shellQuote(rel) + "; {bin}" + tryArgs(args)
try := "cd " + argmask.Quote(rel) + "; {bin}" + tryArgs(args)
return &HintError{Err: err, Explanation: wrongDirectoryExplanation, Try: try}
}
// secretWords mark a long flag whose value the Try line masks.
var secretWords = []string{"password", "passwd", "secret", "token", "key"}
// secretFlag reports whether the long flag name (without "--") looks like it
// carries a secret.
func secretFlag(name string) bool {
name = strings.ToLower(name)
for _, w := range secretWords {
if strings.Contains(name, w) {
return true
}
}
return false
}
// tryArgs renders args for the wrong-directory Try line, each preceded by a
// space and quoted for a shell. The value of a long flag named like a
// secret becomes ***, in the "--flag=value" form and in the "--flag value"
// form, so a password typed on the command line does not reach stderr and
// CI logs. In the "--flag value" form the next argument is masked whatever
// its first character (pflag takes "-Xk9" as the value too); only "--"
// is left alone. tryArgs cannot see flag types, so a secret-named boolean
// flag over-masks the argument after it, which is the safe side.
// tryArgs renders args for the wrong-directory Try line: empty for no
// args, otherwise a space and argmask.Join(args), which quotes each
// argument for a shell and masks the values of secret-named long flags
// (also a value that starts with "-"; only "--" is left alone), so a
// password typed on the command line does not reach stderr and CI logs.
func tryArgs(args []string) string {
var b strings.Builder
for i := 0; i < len(args); i++ {
a := args[i]
b.WriteByte(' ')
name, long := strings.CutPrefix(a, "--")
flag, _, inline := strings.Cut(name, "=")
if !long || flag == "" || !secretFlag(flag) {
b.WriteString(shellQuote(a))
continue
}
if inline {
b.WriteString(shellQuote("--"+flag) + "=***")
continue
}
b.WriteString(shellQuote(a))
if i+1 < len(args) && args[i+1] != "--" {
b.WriteString(" ***")
i++
}
if len(args) == 0 {
return ""
}
return b.String()
}
// shellQuote single-quotes s when a shell would split or expand it.
func shellQuote(s string) string {
if s != "" && !strings.ContainsAny(s, " \t\n'\"\\$`*?[]{}()<>|&;#~!") {
return s
}
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
return " " + argmask.Join(args)
}
// parseUnknownCommand reads the command name and cobra's "Did you mean