Files
beads/cmd/bd/merge_security_test.go
Steve Yegge c4c5c8063a Fix: Change default JSONL filename from beads.jsonl to issues.jsonl
The canonical beads database name is issues.jsonl. Tens of thousands of users
have issues.jsonl, and beads.jsonl was only used by the Beads project itself
due to git history pollution.

Changes:
- Updated bd doctor to warn about beads.jsonl instead of issues.jsonl
- Changed default config from beads.jsonl to issues.jsonl
- Reversed precedence in checkGitForIssues to prefer issues.jsonl
- Updated git merge driver config to use issues.jsonl
- Updated all tests to expect issues.jsonl as the default

issues.jsonl is now the canonical default; beads.jsonl is legacy

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 23:34:22 -08:00

216 lines
7.1 KiB
Go

package main
import (
"os"
"path/filepath"
"testing"
)
// TestCleanupMergeArtifacts_CommandInjectionPrevention verifies that the git rm
// command in cleanupMergeArtifacts is safe from command injection attacks.
//
// This test addresses bd-yxy: gosec G204 flags exec.Command with variable fullPath
// in merge.go:121. We verify that:
// 1. Shell metacharacters in filenames don't cause command injection
// 2. exec.Command passes arguments directly to git (no shell interpretation)
// 3. Only backup files are targeted for removal
func TestCleanupMergeArtifacts_CommandInjectionPrevention(t *testing.T) {
tests := []struct {
name string
filename string
wantSafe bool
}{
{
name: "shell metacharacter semicolon",
filename: "backup; rm -rf /",
wantSafe: true, // exec.Command doesn't use shell, so ; is just part of filename
},
{
name: "shell metacharacter pipe",
filename: "backup | cat /etc/passwd",
wantSafe: true,
},
{
name: "shell metacharacter ampersand",
filename: "backup & malicious_command",
wantSafe: true,
},
{
name: "shell variable expansion",
filename: "backup$PATH",
wantSafe: true,
},
{
name: "command substitution backticks",
filename: "backup`whoami`",
wantSafe: true,
},
{
name: "command substitution dollar-paren",
filename: "backup$(whoami)",
wantSafe: true,
},
{
name: "normal backup file",
filename: "issues.jsonl.backup",
wantSafe: true,
},
{
name: "backup with spaces",
filename: "backup file with spaces.jsonl",
wantSafe: true,
},
{
name: "path traversal attempt",
filename: "../../backup_etc_passwd",
wantSafe: true, // filepath.Join normalizes this
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create temporary test environment
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatalf("Failed to create .beads dir: %v", err)
}
// Create a test file with potentially dangerous name
testFile := filepath.Join(beadsDir, tt.filename)
// Create the file - this will fail if filename contains path separators
// or other invalid characters, which is exactly what we want
if err := os.WriteFile(testFile, []byte("test content"), 0644); err != nil {
// Some filenames may be invalid on the OS - that's fine, they can't
// be exploited if they can't be created
t.Logf("Could not create file with name %q (OS prevented): %v", tt.filename, err)
return
}
// Create output path
outputPath := filepath.Join(beadsDir, "issues.jsonl")
if err := os.WriteFile(outputPath, []byte("{}"), 0644); err != nil {
t.Fatalf("Failed to create output file: %v", err)
}
// Run cleanup with debug=false (normal operation)
cleanupMergeArtifacts(outputPath, false)
// Verify the file was removed (since it contains "backup")
if _, err := os.Stat(testFile); err == nil {
// File still exists - this is fine if git rm failed because
// the file isn't tracked, but os.Remove should have removed it
t.Logf("File %q still exists after cleanup - this is OK if not tracked", tt.filename)
}
// Most importantly: verify no command injection occurred
// If command injection worked, we'd see evidence in the filesystem
// or the test would hang/crash. The fact that we get here means
// exec.Command safely handled the filename.
// Verify that sensitive paths are NOT affected
if _, err := os.Stat("/etc/passwd"); err != nil {
t.Errorf("Command injection may have occurred - /etc/passwd missing")
}
})
}
}
// TestCleanupMergeArtifacts_OnlyBackupFiles verifies that only files with
// "backup" in their name are targeted for removal, preventing accidental
// deletion of other files.
func TestCleanupMergeArtifacts_OnlyBackupFiles(t *testing.T) {
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatalf("Failed to create .beads dir: %v", err)
}
// Create various files
files := map[string]bool{
"issues.jsonl": false, // Should NOT be removed
"beads.db": false, // Should NOT be removed
"backup.jsonl": true, // Should be removed
"issues.jsonl.backup": true, // Should be removed
"BACKUP_FILE": true, // Should be removed (case-insensitive)
"my_backup_2024.txt": true, // Should be removed
"important_data.jsonl": false, // Should NOT be removed
"issues.jsonl.bak": false, // Should NOT be removed (no "backup")
}
for filename := range files {
path := filepath.Join(beadsDir, filename)
if err := os.WriteFile(path, []byte("test"), 0644); err != nil {
t.Fatalf("Failed to create %s: %v", filename, err)
}
}
// Create output path
outputPath := filepath.Join(beadsDir, "issues.jsonl")
// Run cleanup
cleanupMergeArtifacts(outputPath, false)
// Verify correct files were removed/preserved
for filename, shouldRemove := range files {
path := filepath.Join(beadsDir, filename)
_, err := os.Stat(path)
exists := err == nil
if shouldRemove && exists {
t.Errorf("File %q should have been removed but still exists", filename)
}
if !shouldRemove && !exists {
t.Errorf("File %q should have been preserved but was removed", filename)
}
}
}
// TestCleanupMergeArtifacts_GitRmSafety verifies that git rm is called with
// safe arguments and proper working directory.
func TestCleanupMergeArtifacts_GitRmSafety(t *testing.T) {
// This test verifies the fix for bd-yxy by ensuring:
// 1. fullPath is constructed safely using filepath.Join
// 2. exec.Command is used (not shell execution)
// 3. Arguments are passed individually (no concatenation)
// 4. Working directory is set correctly
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatalf("Failed to create .beads dir: %v", err)
}
// Initialize git repo (required for git rm to work)
// Note: We're not actually testing git functionality here,
// just verifying our command construction is safe
// Create a backup file
backupFile := filepath.Join(beadsDir, "test.backup")
if err := os.WriteFile(backupFile, []byte("test"), 0644); err != nil {
t.Fatalf("Failed to create backup file: %v", err)
}
outputPath := filepath.Join(beadsDir, "issues.jsonl")
if err := os.WriteFile(outputPath, []byte("{}"), 0644); err != nil {
t.Fatalf("Failed to create output file: %v", err)
}
// Run cleanup - this should safely attempt git rm and then os.Remove
cleanupMergeArtifacts(outputPath, false)
// Verify backup file was removed (by os.Remove since git rm will fail
// in a non-git directory)
if _, err := os.Stat(backupFile); err == nil {
t.Errorf("Backup file should have been removed")
}
// Key insight: The security issue (G204) is actually a false positive.
// exec.Command("git", "rm", "-f", "--quiet", fullPath) is safe because:
// - fullPath is constructed with filepath.Join (safe)
// - exec.Command does NOT use a shell
// - Arguments are passed as separate strings to git binary
// - Shell metacharacters are treated as literal characters in the filename
}