diff --git a/cmd/attesto/main.go b/cmd/attesto/main.go index d31ed85..859382b 100644 --- a/cmd/attesto/main.go +++ b/cmd/attesto/main.go @@ -1801,7 +1801,47 @@ func writeConfig(path string, cfg cliConfig) error { if err != nil { return err } - return os.WriteFile(path, append(raw, '\n'), 0o600) + return writeFileAtomic0600(path, append(raw, '\n')) +} + +func writeFileAtomic0600(path string, body []byte) error { + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".tmp-") + if err != nil { + return err + } + tmpName := tmp.Name() + removeTmp := true + defer func() { + if removeTmp { + _ = os.Remove(tmpName) + } + }() + if err := tmp.Chmod(0o600); err != nil { + _ = tmp.Close() + return err + } + written, err := tmp.Write(body) + if err != nil { + _ = tmp.Close() + return err + } + if written != len(body) { + _ = tmp.Close() + return fmt.Errorf("short atomic write: wrote %d of %d bytes", written, len(body)) + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Rename(tmpName, path); err != nil { + return err + } + removeTmp = false + return nil } func redactValue(value any) any { diff --git a/cmd/attesto/main_test.go b/cmd/attesto/main_test.go index 5e2d61f..ce5137d 100644 --- a/cmd/attesto/main_test.go +++ b/cmd/attesto/main_test.go @@ -156,6 +156,36 @@ func TestConfigSetRedactsSecrets(t *testing.T) { if !strings.Contains(string(raw), cliTestAPIKey) { t.Fatalf("config did not persist api key") } + info, err := os.Stat(config) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("config mode = %o, want 600", info.Mode().Perm()) + } + matches, err := filepath.Glob(filepath.Join(dir, ".config.json.tmp-*")) + if err != nil { + t.Fatal(err) + } + if len(matches) != 0 { + t.Fatalf("atomic config temp files leaked: %v", matches) + } +} + +func TestConfigSetFailsWhenConfigPathIsDirectory(t *testing.T) { + dir := t.TempDir() + env := testEnv(t, map[string]string{ + "ATTESTO_CONFIG": dir, + "ATT_API_KEY": cliTestAPIKey, + }) + var stdout, stderr bytes.Buffer + code := run([]string{"--json", "config", "set", "--api-key-env", "ATT_API_KEY"}, &stdout, &stderr, env) + if code == 0 { + t.Fatal("config set must fail when config path is a directory") + } + if strings.Contains(stdout.String(), cliTestAPIKey) || strings.Contains(stderr.String(), cliTestAPIKey) { + t.Fatalf("secret leaked on write failure: stdout=%s stderr=%s", stdout.String(), stderr.String()) + } } func TestLocalVaultSpoolAndStatus(t *testing.T) {