fix: harden thothctl diagnostics
This commit is contained in:
@@ -8,12 +8,15 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const installationFileName = "thothii-installation.yaml"
|
||||
|
||||
const maxEnvironmentFileBytes = 1 << 20
|
||||
|
||||
type descriptor struct {
|
||||
Profile string `yaml:"profile"`
|
||||
ProjectDirectory string `yaml:"projectDirectory"`
|
||||
@@ -116,6 +119,62 @@ func (i Installation) ComposeArgs(command ...string) []string {
|
||||
return append(args, command...)
|
||||
}
|
||||
|
||||
// SecretFiles returns only existing, absolute regular files declared in the installation env file
|
||||
// through *_FILE or *_SOURCE variables. Missing paths are allowed because /run/secrets paths are
|
||||
// container-local declarations, not host files thothctl can read.
|
||||
func (i Installation) SecretFiles() ([]string, error) {
|
||||
info, err := os.Stat(i.EnvFile)
|
||||
if err != nil || info.Size() > maxEnvironmentFileBytes {
|
||||
return nil, errors.New("installation secret declarations could not be read")
|
||||
}
|
||||
contents, err := os.ReadFile(i.EnvFile)
|
||||
if err != nil || len(contents) > maxEnvironmentFileBytes {
|
||||
return nil, errors.New("installation secret declarations could not be read")
|
||||
}
|
||||
|
||||
files := make([]string, 0)
|
||||
seen := make(map[string]struct{})
|
||||
for _, line := range strings.Split(string(contents), "\n") {
|
||||
key, value, ok := environmentAssignment(line)
|
||||
if !ok || (!strings.HasSuffix(key, "_FILE") && !strings.HasSuffix(key, "_SOURCE")) || !filepath.IsAbs(value) {
|
||||
continue
|
||||
}
|
||||
fileInfo, err := os.Lstat(value)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
if err != nil || !fileInfo.Mode().IsRegular() {
|
||||
return nil, errors.New("installation secret declarations could not be read")
|
||||
}
|
||||
if _, exists := seen[value]; !exists {
|
||||
files = append(files, value)
|
||||
seen[value] = struct{}{}
|
||||
}
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func environmentAssignment(line string) (string, string, bool) {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
return "", "", false
|
||||
}
|
||||
line = strings.TrimPrefix(line, "export ")
|
||||
key, value, found := strings.Cut(line, "=")
|
||||
if !found {
|
||||
return "", "", false
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return "", "", false
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) >= 2 && ((value[0] == '"' && value[len(value)-1] == '"') || (value[0] == '\'' && value[len(value)-1] == '\'')) {
|
||||
value = value[1 : len(value)-1]
|
||||
}
|
||||
return strings.ToUpper(key), value, true
|
||||
}
|
||||
|
||||
func ensureOnlyOneDocument(decoder *yaml.Decoder) error {
|
||||
var extra any
|
||||
err := decoder.Decode(&extra)
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
@@ -11,6 +12,8 @@ import (
|
||||
|
||||
var credentialField = regexp.MustCompile(`(?im)(\b[\w.-]*(?:password|token|key)[\w.-]*\s*[:=]\s*)(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s,;]+)`)
|
||||
|
||||
const maxSecretFileBytes = 64 * 1024
|
||||
|
||||
// Sanitize redacts common credential fields and every supplied secret value.
|
||||
func Sanitize(text string, secretValues []string) string {
|
||||
text = credentialField.ReplaceAllString(text, "${1}[REDACTED]")
|
||||
@@ -27,14 +30,36 @@ func Sanitize(text string, secretValues []string) string {
|
||||
// SecretValuesFromFiles reads non-empty secret-file contents without exposing them to callers.
|
||||
func SecretValuesFromFiles(paths []string) ([]string, error) {
|
||||
values := make([]string, 0, len(paths))
|
||||
seen := make(map[string]struct{})
|
||||
for _, path := range paths {
|
||||
contents, err := os.ReadFile(path)
|
||||
value, err := readSecretFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read secret file: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
if value := strings.TrimSpace(string(contents)); value != "" {
|
||||
if value != "" {
|
||||
if _, exists := seen[value]; exists {
|
||||
continue
|
||||
}
|
||||
values = append(values, value)
|
||||
seen[value] = struct{}{}
|
||||
}
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func readSecretFile(path string) (string, error) {
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil || !info.Mode().IsRegular() || info.Size() > maxSecretFileBytes {
|
||||
return "", errors.New("declared secret file could not be read")
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", errors.New("declared secret file could not be read")
|
||||
}
|
||||
defer file.Close()
|
||||
contents, err := io.ReadAll(io.LimitReader(file, maxSecretFileBytes+1))
|
||||
if err != nil || len(contents) > maxSecretFileBytes {
|
||||
return "", errors.New("declared secret file could not be read")
|
||||
}
|
||||
return strings.TrimRight(string(contents), "\r\n"), nil
|
||||
}
|
||||
|
||||
@@ -33,3 +33,16 @@ func TestSanitizeRedactsSecretFileContents(t *testing.T) {
|
||||
t.Errorf("Sanitize() = %q, want redacted secret", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretValuesFromFilesRejectsOversizedFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
secretFile := filepath.Join(t.TempDir(), "oversized-token")
|
||||
if err := os.WriteFile(secretFile, make([]byte, 64*1024+1), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := SecretValuesFromFiles([]string{secretFile}); err == nil {
|
||||
t.Fatal("SecretValuesFromFiles() error = nil, want oversized-file error")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user