fix: harden thothctl diagnostics

This commit is contained in:
2026-08-04 17:00:05 +02:00
parent 853a151796
commit 4158990c10
7 changed files with 351 additions and 12 deletions
@@ -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)
+29 -4
View File
@@ -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")
}
}