Skip to content
Merged
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
35 changes: 35 additions & 0 deletions internal/bait/bait.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down
106 changes: 106 additions & 0 deletions internal/bait/bait_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
Loading