diff --git a/.jules/sentinel.md b/.jules/sentinel.md new file mode 100644 index 0000000..f1c3c0a --- /dev/null +++ b/.jules/sentinel.md @@ -0,0 +1,4 @@ +## 2026-05-03 - Symlink Vulnerability in Atomic File Writes +**Vulnerability:** Predictable temporary filenames used before `os.Rename` are vulnerable to symlink attacks. +**Learning:** Atomic file writes often use a predictable temporary file (e.g., appending `.tmp` or `.new`), which an attacker can pre-create as a symlink to an arbitrary target. +**Prevention:** Use `os.CreateTemp` to generate unpredictable temporary filenames in the same directory as the target, write data, close the file, and then rename. diff --git a/internal/burnrate/burnrate.go b/internal/burnrate/burnrate.go index 8cb680c..01ffff9 100644 --- a/internal/burnrate/burnrate.go +++ b/internal/burnrate/burnrate.go @@ -64,10 +64,19 @@ func LoadOrCreateSnapshotAt(sessionID string, currentCost float64, now time.Time return nil, false, err } - tmpPath := path + ".tmp" - if err := os.WriteFile(tmpPath, data, 0644); err != nil { + tmpFile, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".*") + if err != nil { return nil, false, err } + tmpPath := tmpFile.Name() + if _, err := tmpFile.Write(data); err != nil { + tmpFile.Close() + os.Remove(tmpPath) + return nil, false, err + } + tmpFile.Chmod(0644) + tmpFile.Close() + if err := os.Rename(tmpPath, path); err != nil { os.Remove(tmpPath) return nil, false, err diff --git a/internal/sparkline/sparkline.go b/internal/sparkline/sparkline.go index f4c755c..a6be082 100644 --- a/internal/sparkline/sparkline.go +++ b/internal/sparkline/sparkline.go @@ -138,11 +138,21 @@ func Save(sessionID, metric string, b *Buffer) { if err != nil { return } - tmp := path + ".tmp" - if err := os.WriteFile(tmp, data, 0644); err != nil { + tmpFile, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".*") + if err != nil { + return + } + tmp := tmpFile.Name() + if _, err := tmpFile.Write(data); err != nil { + tmpFile.Close() + os.Remove(tmp) return } - os.Rename(tmp, path) + tmpFile.Chmod(0644) + tmpFile.Close() + if err := os.Rename(tmp, path); err != nil { + os.Remove(tmp) + } } // PushAndSave is a convenience that loads, pushes, saves, and returns the buffer diff --git a/internal/update/update.go b/internal/update/update.go index cee6e31..7f717fb 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -68,7 +68,6 @@ func Download(ctx context.Context) error { return fmt.Errorf("failed to get home directory: %w", err) } binaryPath := filepath.Join(homeDir, ".claude", "prism") - tempPath := binaryPath + ".new" // Download to temp file req, err := http.NewRequestWithContext(ctx, "GET", binaryURL, nil) @@ -91,10 +90,11 @@ func Download(ctx context.Context) error { } // Write to temp file - out, err := os.Create(tempPath) + out, err := os.CreateTemp(filepath.Dir(binaryPath), filepath.Base(binaryPath)+".*") if err != nil { return fmt.Errorf("failed to create temp file: %w", err) } + tempPath := out.Name() _, err = io.Copy(out, resp.Body) out.Close()