diff --git a/modules/restic/cmd/restic-backups/backups.go b/modules/restic/cmd/restic-backups/backups.go index f0954581..bc8181e7 100644 --- a/modules/restic/cmd/restic-backups/backups.go +++ b/modules/restic/cmd/restic-backups/backups.go @@ -61,25 +61,37 @@ func execRunner(ctx context.Context, name string, args ...string) (string, error cmd := exec.CommandContext(ctx, program, argv...) cmd.Stdout, cmd.Stderr = &stdout, &stderr if err := cmd.Run(); err != nil { - said := strings.TrimSpace(stderr.String()) - if said == "" { - said = strings.TrimSpace(stdout.String()) - } - if program == "sudo" && strings.HasPrefix(said, "sudo:") { - return "", fmt.Errorf("%s needs root and the runtime's account may not run it without a prompt: %s", name, said) - } - lines := strings.Split(said, "\n") - if len(lines) > 3 { - lines = lines[len(lines)-3:] - } - if said == "" { - return "", fmt.Errorf("%s: %w", name, err) - } - return "", errors.New(strings.Join(lines, " / ")) + return "", failure(program, name, err, stdout.String(), stderr.String()) } return stdout.String(), nil } +// failureTail bounds what a failed command's output adds to the failure it reports: its last 2 KB. +const failureTail = 2048 + +var secretLooking = regexp.MustCompile(`(?i)((?:pass(?:word|wd)?|secret|token|api[_-]?key)\s*[=:]\s*)\S+`) + +// failure is the error of a command that exited badly: its last lines of stderr (stdout when stderr is +// empty), bounded to failureTail bytes, with what looks like a secret blotted out, so the report of a +// failed dump says why and not only that it exited. With nothing printed it keeps the exit status. +func failure(program, name string, err error, stdout, stderr string) error { + said := strings.TrimSpace(stderr) + if said == "" { + said = strings.TrimSpace(stdout) + } + if program == "sudo" && strings.HasPrefix(said, "sudo:") { + return fmt.Errorf("%s needs root and the runtime's account may not run it without a prompt: %s", name, said) + } + if said == "" { + return fmt.Errorf("%s: %w (it printed nothing)", name, err) + } + if len(said) > failureTail { + said = "..." + said[len(said)-failureTail:] + } + said = secretLooking.ReplaceAllString(said, "${1}***") + return fmt.Errorf("%s: %v: %s", name, err, strings.Join(strings.Split(said, "\n"), " / ")) +} + // Declared is what one module declared. type Declared struct { Module string `json:"module"` diff --git a/modules/restic/cmd/restic-backups/backups_test.go b/modules/restic/cmd/restic-backups/backups_test.go index 3a545185..64695cff 100644 --- a/modules/restic/cmd/restic-backups/backups_test.go +++ b/modules/restic/cmd/restic-backups/backups_test.go @@ -333,3 +333,20 @@ func TestARestoreBesideAnotherAccountsDirectoryGoesToTheState(t *testing.T) { } _ = ran } + +func TestAFailedCommandReportsTheTailOfWhatItPrintedWithSecretsBlotted(t *testing.T) { + exit := errors.New("exit status 1") + long := strings.Repeat("x", 5000) + "\npg_dump: error: password=hunter2 refused" + for _, c := range []struct{ name, program, out, errOut, want, not string }{ + {"stderr", "sh", "", "pg_dump: connection refused\nsecond", "sh: exit status 1: pg_dump: connection refused / second", ""}, + {"stdout when stderr is empty", "sh", "dump failed\n", "", "sh: exit status 1: dump failed", ""}, + {"nothing printed", "sh", "", "", "sh: exit status 1 (it printed nothing)", ""}, + {"bounded and blotted", "sh", "", long, "password=*** refused", "hunter2"}, + {"sudo prompt", "sudo", "", "sudo: a password is required", "needs root", ""}, + } { + got := failure(c.program, "sh", exit, c.out, c.errOut).Error() + if !strings.Contains(got, c.want) || (c.not != "" && strings.Contains(got, c.not)) || len(got) > failureTail+100 { + t.Errorf("%s: got %q", c.name, got) + } + } +}