diff --git a/internal/bait/bait.go b/internal/bait/bait.go index 8678adf..a04ec37 100644 --- a/internal/bait/bait.go +++ b/internal/bait/bait.go @@ -132,6 +132,14 @@ func Plant(t Type, params Params, targetPath string, dryRun bool, opts ...bool) } } + // Backup existing file before appending canary content. + // If snare crashes mid-operation, the user can restore from the .bak file. + if fileExists && mode == manifest.ModeAppend { + if err := writeBackup(targetPath); err != nil { + return nil, fmt.Errorf("creating backup of %s: %w", targetPath, err) + } + } + // Ensure parent directory exists with secure permissions parentDir := filepath.Dir(targetPath) if err := os.MkdirAll(parentDir, 0700); err != nil { @@ -288,10 +296,37 @@ func removeAppended(c manifest.Canary, force bool, dryRun bool) error { return fmt.Errorf("replacing %s: %w", c.Path, err) } + // Clean up the .bak file now that the canary content has been safely removed. + removeBackup(c.Path) + fmt.Printf(" removed canary block from %s\n", c.Path) return nil } +// BackupPath returns the .snare.bak path for a given file path. +func BackupPath(path string) string { + return path + ".snare.bak" +} + +// writeBackup copies the contents of path to path.snare.bak. +// The backup preserves the original file's permissions. +func writeBackup(path string) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + info, err := os.Stat(path) + if err != nil { + return err + } + return os.WriteFile(BackupPath(path), data, info.Mode()) +} + +// removeBackup deletes the .snare.bak file if it exists. +func removeBackup(path string) { + _ = os.Remove(BackupPath(path)) +} + // DefaultPaths returns the standard target paths for each bait type. func DefaultPaths(t Type) ([]string, error) { home, err := os.UserHomeDir() diff --git a/internal/bait/bait_test.go b/internal/bait/bait_test.go index 69ea37b..87eb8a6 100644 --- a/internal/bait/bait_test.go +++ b/internal/bait/bait_test.go @@ -365,6 +365,112 @@ func TestRemoveModifiedContent(t *testing.T) { } } +// TestBackupCreatedOnAppend verifies that a .snare.bak file is created when appending. +func TestBackupCreatedOnAppend(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "credentials") + existing := "[real-profile]\naws_access_key_id = AKIAREALKEY\n" + + if err := os.WriteFile(path, []byte(existing), 0600); err != nil { + t.Fatalf("setup: %v", err) + } + + params := testParams(t, bait.TypeAWS) + _, err := bait.Plant(bait.TypeAWS, params, path, false) + if err != nil { + t.Fatalf("Plant: %v", err) + } + + // .snare.bak must exist with original content + bakPath := bait.BackupPath(path) + bakData, err := os.ReadFile(bakPath) + if err != nil { + t.Fatalf("backup file not created: %v", err) + } + if string(bakData) != existing { + t.Errorf("backup content mismatch\nwant: %q\ngot: %q", existing, string(bakData)) + } +} + +// TestBackupNotCreatedForNewFile verifies no .snare.bak for new files. +func TestBackupNotCreatedForNewFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "sa.json") + + params := testParams(t, bait.TypeGCP) + _, err := bait.Plant(bait.TypeGCP, params, path, false) + if err != nil { + t.Fatalf("Plant: %v", err) + } + + bakPath := bait.BackupPath(path) + if _, err := os.Stat(bakPath); !os.IsNotExist(err) { + t.Error("backup file should not exist for new files") + } +} + +// TestBackupRemovedOnDisarm verifies .snare.bak is cleaned up after successful removal. +func TestBackupRemovedOnDisarm(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "credentials") + existing := "[real-profile]\naws_access_key_id = AKIAREALKEY\n" + + if err := os.WriteFile(path, []byte(existing), 0600); err != nil { + t.Fatalf("setup: %v", err) + } + + params := testParams(t, bait.TypeAWS) + placed, err := bait.Plant(bait.TypeAWS, params, path, false) + if err != nil { + t.Fatalf("Plant: %v", err) + } + + // Backup must exist after plant + bakPath := bait.BackupPath(path) + if _, err := os.Stat(bakPath); err != nil { + t.Fatalf("backup not found after plant: %v", err) + } + + c := manifest.Canary{ + ID: params.TokenID, + Path: placed.Path, + Mode: placed.Mode, + Content: placed.Content, + ContentHash: manifest.HashContent(placed.Content), + } + + if err := bait.Remove(c, false, false); err != nil { + t.Fatalf("Remove: %v", err) + } + + // Backup must be gone after disarm + if _, err := os.Stat(bakPath); !os.IsNotExist(err) { + t.Error("backup file should be removed after successful disarm") + } +} + +// TestBackupNotCreatedOnDryRun verifies dry-run does not create .snare.bak. +func TestBackupNotCreatedOnDryRun(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "credentials") + existing := "[real-profile]\naws_access_key_id = AKIAREALKEY\n" + + if err := os.WriteFile(path, []byte(existing), 0600); err != nil { + t.Fatalf("setup: %v", err) + } + + params := testParams(t, bait.TypeAWS) + _, err := bait.Plant(bait.TypeAWS, params, path, true) + if err != nil { + t.Fatalf("Plant dry-run: %v", err) + } + + bakPath := bait.BackupPath(path) + if _, err := os.Stat(bakPath); !os.IsNotExist(err) { + t.Error("backup file should not be created during dry-run") + } +} + // TestNoGiveawayStrings verifies generated credentials don't contain project identifiers. func TestNoGiveawayStrings(t *testing.T) { giveaways := []string{"SNARE", "snare", "FAKE", "fake", "TEST", "canary", "CANARY"}