Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 11 additions & 2 deletions internal/burnrate/burnrate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Comment on lines +72 to +78
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The errors from tmpFile.Chmod(0644) and tmpFile.Close() are currently ignored. For reliable atomic writes, it is important to check the error returned by Close() because this is when the operating system flushes remaining data to disk and reports any write failures (e.g., disk full). Additionally, for true durability, consider calling tmpFile.Sync() before closing to ensure the data is persisted to the physical storage before the rename operation occurs.

	if _, err := tmpFile.Write(data); err != nil {
		tmpFile.Close()
		os.Remove(tmpPath)
		return nil, false, err
	}
	tmpFile.Chmod(0644)
	if err := tmpFile.Close(); err != nil {
		os.Remove(tmpPath)
		return nil, false, err
	}


if err := os.Rename(tmpPath, path); err != nil {
os.Remove(tmpPath)
return nil, false, err
Expand Down
16 changes: 13 additions & 3 deletions internal/sparkline/sparkline.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Comment on lines +146 to +152
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The errors from tmpFile.Chmod(0644) and tmpFile.Close() are ignored. Checking the Close() error is essential to ensure that all data has been successfully flushed to disk. Without this check, the subsequent os.Rename might succeed even if the file content is incomplete or corrupted due to a flush failure.

Suggested change
if _, err := tmpFile.Write(data); err != nil {
tmpFile.Close()
os.Remove(tmp)
return
}
os.Rename(tmp, path)
tmpFile.Chmod(0644)
tmpFile.Close()
if _, err := tmpFile.Write(data); err != nil {
tmpFile.Close()
os.Remove(tmp)
return
}
tmpFile.Chmod(0644)
if err := tmpFile.Close(); err != nil {
os.Remove(tmp)
return
}

if err := os.Rename(tmp, path); err != nil {
os.Remove(tmp)
}
}

// PushAndSave is a convenience that loads, pushes, saves, and returns the buffer
Expand Down
4 changes: 2 additions & 2 deletions internal/update/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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()
Comment on lines 99 to 100
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The error from out.Close() is ignored, which can mask failures during the final flush of the downloaded binary to disk. If io.Copy succeeds but Close() fails (e.g., due to insufficient disk space), the current logic will proceed to install a potentially truncated or corrupted binary. You should capture and check the error from Close().

	_, err = io.Copy(out, resp.Body)
	if cerr := out.Close(); err == nil {
		err = cerr
	}

Expand Down
Loading