From 66ab4290ade6cb1519374227f6ebec843b52bc3b Mon Sep 17 00:00:00 2001 From: Dylan Ravel Date: Wed, 15 Jul 2026 11:08:24 -1000 Subject: [PATCH 1/2] Add fsperm package and enforce secure file permissions --- internal/fsperm/fsperm.go | 37 +++++++++++++++ internal/fsperm/fsperm_test.go | 60 +++++++++++++++++++++++++ internal/settings/config_test.go | 16 +++++++ internal/settings/global_config.go | 9 +++- internal/settings/global_config_test.go | 36 +++++++++++++++ internal/settings/projects.go | 9 +++- internal/settings/projects_test.go | 23 ++++++++++ internal/storage/backup.go | 11 ++++- internal/storage/backup_test.go | 51 +++++++++++++++++++++ internal/storage/db.go | 12 ++++- internal/storage/db_test.go | 57 +++++++++++++++++++++++ 11 files changed, 315 insertions(+), 6 deletions(-) create mode 100644 internal/fsperm/fsperm.go create mode 100644 internal/fsperm/fsperm_test.go create mode 100644 internal/settings/global_config_test.go diff --git a/internal/fsperm/fsperm.go b/internal/fsperm/fsperm.go new file mode 100644 index 0000000..d1095b9 --- /dev/null +++ b/internal/fsperm/fsperm.go @@ -0,0 +1,37 @@ +package fsperm + +import ( + "fmt" + "os" +) + +const ( + DirPerm os.FileMode = 0700 + FilePerm os.FileMode = 0600 +) + +// SecureDir creates dir (and any parents) if needed and sets it to owner-only +// access (DirPerm, 0700). The Chmod also applies to a pre-existing directory, +// remediating installs created with looser permissions. +func SecureDir(dir string) error { + if err := os.MkdirAll(dir, DirPerm); err != nil { + return fmt.Errorf("failed to create directory %s: %w", dir, err) + } + if err := os.Chmod(dir, DirPerm); err != nil { + return fmt.Errorf("failed to secure directory %s: %w", dir, err) + } + return nil +} + +// SecureFile sets an existing file to owner-only read/write (FilePerm, 0600). +// It is a no-op if the file does not exist yet, so callers may invoke it +// defensively on optional files. +func SecureFile(path string) error { + if err := os.Chmod(path, FilePerm); err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("failed to secure file %s: %w", path, err) + } + return nil +} diff --git a/internal/fsperm/fsperm_test.go b/internal/fsperm/fsperm_test.go new file mode 100644 index 0000000..e8ce0fc --- /dev/null +++ b/internal/fsperm/fsperm_test.go @@ -0,0 +1,60 @@ +package fsperm + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSecureDir(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX permissions are not enforced on Windows") + } + + t.Run("creates a new directory with private permissions", func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "new") + + assert.NoError(t, SecureDir(dir)) + + info, err := os.Stat(dir) + assert.NoError(t, err) + assert.Equal(t, DirPerm, info.Mode().Perm()) + }) + + t.Run("tightens an existing loose directory", func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "loose") + assert.NoError(t, os.MkdirAll(dir, 0755)) + + assert.NoError(t, SecureDir(dir)) + + info, err := os.Stat(dir) + assert.NoError(t, err) + assert.Equal(t, DirPerm, info.Mode().Perm()) + }) +} + +func TestSecureFile(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX permissions are not enforced on Windows") + } + + t.Run("tightens an existing loose file", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "data") + assert.NoError(t, os.WriteFile(path, []byte("secret"), 0644)) + + assert.NoError(t, SecureFile(path)) + + info, err := os.Stat(path) + assert.NoError(t, err) + assert.Equal(t, FilePerm, info.Mode().Perm()) + }) + + t.Run("is a no-op when the file does not exist", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "missing") + + assert.NoError(t, SecureFile(path)) + }) +} diff --git a/internal/settings/config_test.go b/internal/settings/config_test.go index 2e22fe9..5e94636 100644 --- a/internal/settings/config_test.go +++ b/internal/settings/config_test.go @@ -3,6 +3,7 @@ package settings import ( "os" "path/filepath" + "runtime" "testing" "github.com/stretchr/testify/assert" @@ -331,3 +332,18 @@ func TestFindAndLoad(t *testing.T) { assert.Equal(t, expectedPath, actualPath) }) } + +func TestCreateWithTemplate_TmporcRemainsGroupReadable(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX permissions are not enforced on Windows") + } + + dir := t.TempDir() + t.Chdir(dir) + + assert.NoError(t, CreateWithTemplate("demo", 0, "", "")) + + fileInfo, err := os.Stat(filepath.Join(dir, ".tmporc")) + assert.NoError(t, err) + assert.Equal(t, os.FileMode(0644), fileInfo.Mode().Perm()) +} diff --git a/internal/settings/global_config.go b/internal/settings/global_config.go index 0b37952..2c83488 100644 --- a/internal/settings/global_config.go +++ b/internal/settings/global_config.go @@ -7,6 +7,7 @@ import ( "time" "github.com/DylanDevelops/tmpo/internal/currency" + "github.com/DylanDevelops/tmpo/internal/fsperm" "go.yaml.in/yaml/v3" ) @@ -83,7 +84,7 @@ func (gc *GlobalConfig) Save() error { } tmpoDir := filepath.Dir(configPath) - if err := os.MkdirAll(tmpoDir, 0755); err != nil { + if err := fsperm.SecureDir(tmpoDir); err != nil { return fmt.Errorf("failed to create config directory: %w", err) } @@ -92,10 +93,14 @@ func (gc *GlobalConfig) Save() error { return fmt.Errorf("failed to marshal global config: %w", err) } - if err := os.WriteFile(configPath, data, 0644); err != nil { + if err := os.WriteFile(configPath, data, fsperm.FilePerm); err != nil { return fmt.Errorf("failed to write global config: %w", err) } + if err := fsperm.SecureFile(configPath); err != nil { + return err + } + return nil } diff --git a/internal/settings/global_config_test.go b/internal/settings/global_config_test.go new file mode 100644 index 0000000..32022ae --- /dev/null +++ b/internal/settings/global_config_test.go @@ -0,0 +1,36 @@ +package settings + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/DylanDevelops/tmpo/internal/fsperm" + "github.com/stretchr/testify/assert" +) + +func TestGlobalConfigSave_SetsPrivatePermissions(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX permissions are not enforced on Windows") + } + + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + t.Setenv("USERPROFILE", tmpHome) + t.Setenv("TMPO_DEV", "") + + cfg := &GlobalConfig{Currency: "USD"} + assert.NoError(t, cfg.Save()) + + path, err := GetGlobalConfigPath() + assert.NoError(t, err) + + fileInfo, err := os.Stat(path) + assert.NoError(t, err) + assert.Equal(t, fsperm.FilePerm, fileInfo.Mode().Perm()) + + dirInfo, err := os.Stat(filepath.Dir(path)) + assert.NoError(t, err) + assert.Equal(t, fsperm.DirPerm, dirInfo.Mode().Perm()) +} diff --git a/internal/settings/projects.go b/internal/settings/projects.go index 9d66771..ed61450 100644 --- a/internal/settings/projects.go +++ b/internal/settings/projects.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" + "github.com/DylanDevelops/tmpo/internal/fsperm" "go.yaml.in/yaml/v3" ) @@ -74,7 +75,7 @@ func (pr *ProjectsRegistry) Save() error { } tmpoDir := filepath.Dir(projectsPath) - if err := os.MkdirAll(tmpoDir, 0755); err != nil { + if err := fsperm.SecureDir(tmpoDir); err != nil { return fmt.Errorf("failed to create config directory: %w", err) } @@ -83,10 +84,14 @@ func (pr *ProjectsRegistry) Save() error { return fmt.Errorf("failed to marshal projects registry: %w", err) } - if err := os.WriteFile(projectsPath, data, 0644); err != nil { + if err := os.WriteFile(projectsPath, data, fsperm.FilePerm); err != nil { return fmt.Errorf("failed to write projects registry: %w", err) } + if err := fsperm.SecureFile(projectsPath); err != nil { + return err + } + return nil } diff --git a/internal/settings/projects_test.go b/internal/settings/projects_test.go index 41831f2..3cc582d 100644 --- a/internal/settings/projects_test.go +++ b/internal/settings/projects_test.go @@ -3,8 +3,10 @@ package settings import ( "os" "path/filepath" + "runtime" "testing" + "github.com/DylanDevelops/tmpo/internal/fsperm" "github.com/stretchr/testify/assert" ) @@ -447,3 +449,24 @@ func TestGetProjectsPath(t *testing.T) { assert.Equal(t, "projects.yaml", filepath.Base(path)) }) } + +func TestProjectsRegistrySave_SetsPrivatePermissions(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX permissions are not enforced on Windows") + } + + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + t.Setenv("USERPROFILE", tmpHome) + t.Setenv("TMPO_DEV", "") + + reg := &ProjectsRegistry{Projects: []GlobalProject{}} + assert.NoError(t, reg.Save()) + + path, err := GetProjectsPath() + assert.NoError(t, err) + + fileInfo, err := os.Stat(path) + assert.NoError(t, err) + assert.Equal(t, fsperm.FilePerm, fileInfo.Mode().Perm()) +} diff --git a/internal/storage/backup.go b/internal/storage/backup.go index 59fa40d..aa02112 100644 --- a/internal/storage/backup.go +++ b/internal/storage/backup.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/DylanDevelops/tmpo/internal/fsperm" _ "modernc.org/sqlite" ) @@ -46,7 +47,7 @@ func (d *Database) CreateBackup() (*BackupInfo, error) { return nil, err } - if err := os.MkdirAll(backupDir, 0755); err != nil { + if err := fsperm.SecureDir(backupDir); err != nil { return nil, fmt.Errorf("failed to create backups directory: %w", err) } @@ -59,6 +60,10 @@ func (d *Database) CreateBackup() (*BackupInfo, error) { return nil, fmt.Errorf("failed to create backup: %w", err) } + if err := fsperm.SecureFile(destPath); err != nil { + return nil, err + } + info, err := os.Stat(destPath) if err != nil { return nil, fmt.Errorf("failed to stat backup file: %w", err) @@ -151,6 +156,10 @@ func RestoreBackup(backupPath string) error { return fmt.Errorf("failed to restore backup: %w", err) } + if err := fsperm.SecureFile(dbPath); err != nil { + return err + } + return nil } diff --git a/internal/storage/backup_test.go b/internal/storage/backup_test.go index 99f3b3c..bf4b221 100644 --- a/internal/storage/backup_test.go +++ b/internal/storage/backup_test.go @@ -4,10 +4,12 @@ import ( "database/sql" "os" "path/filepath" + "runtime" "strings" "testing" "time" + "github.com/DylanDevelops/tmpo/internal/fsperm" "github.com/stretchr/testify/assert" _ "modernc.org/sqlite" ) @@ -257,3 +259,52 @@ func TestRestoreBackup(t *testing.T) { assert.Len(t, entries, 1) assert.Equal(t, "before backup", entries[0].Description) } + +func TestCreateBackup_SetsPrivatePermissions(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX permissions are not enforced on Windows") + } + + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + t.Setenv("USERPROFILE", tmpHome) + t.Setenv("TMPO_DEV", "") + + db, err := Initialize() + assert.NoError(t, err) + defer db.Close() + + info, err := db.CreateBackup() + assert.NoError(t, err) + + dirInfo, err := os.Stat(filepath.Join(tmpHome, ".tmpo", "backups")) + assert.NoError(t, err) + assert.Equal(t, fsperm.DirPerm, dirInfo.Mode().Perm()) + + fileInfo, err := os.Stat(info.Path) + assert.NoError(t, err) + assert.Equal(t, fsperm.FilePerm, fileInfo.Mode().Perm()) +} + +func TestRestoreBackup_SetsPrivatePermissions(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX permissions are not enforced on Windows") + } + + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + t.Setenv("USERPROFILE", tmpHome) + t.Setenv("TMPO_DEV", "") + + db, err := Initialize() + assert.NoError(t, err) + info, err := db.CreateBackup() + assert.NoError(t, err) + assert.NoError(t, db.Close()) + + assert.NoError(t, RestoreBackup(info.Path)) + + dbInfo, err := os.Stat(filepath.Join(tmpHome, ".tmpo", "tmpo.db")) + assert.NoError(t, err) + assert.Equal(t, fsperm.FilePerm, dbInfo.Mode().Perm()) +} diff --git a/internal/storage/db.go b/internal/storage/db.go index 5059d67..a96afa8 100644 --- a/internal/storage/db.go +++ b/internal/storage/db.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/DylanDevelops/tmpo/internal/fsperm" _ "modernc.org/sqlite" ) @@ -33,7 +34,7 @@ func Initialize() (*Database, error) { return nil, err } - if err := os.MkdirAll(tmpoDir, 0755); err != nil { + if err := fsperm.SecureDir(tmpoDir); err != nil { return nil, fmt.Errorf("failed to create .tmpo directory: %w", err) } @@ -112,6 +113,15 @@ func Initialize() (*Database, error) { return nil, fmt.Errorf("failed to run migrations: %w", err) } + if err := fsperm.SecureFile(dbPath); err != nil { + return nil, err + } + for _, suffix := range []string{"-wal", "-shm"} { + if err := fsperm.SecureFile(dbPath + suffix); err != nil { + return nil, err + } + } + return database, nil } diff --git a/internal/storage/db_test.go b/internal/storage/db_test.go index 6124ac5..182bb12 100644 --- a/internal/storage/db_test.go +++ b/internal/storage/db_test.go @@ -2,9 +2,13 @@ package storage import ( "database/sql" + "os" + "path/filepath" + "runtime" "testing" "time" + "github.com/DylanDevelops/tmpo/internal/fsperm" "github.com/stretchr/testify/assert" _ "modernc.org/sqlite" ) @@ -642,3 +646,56 @@ func floatPtr(f float64) *float64 { func timePtr(t time.Time) *time.Time { return &t } + +func TestInitialize_SetsPrivatePermissions(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX permissions are not enforced on Windows") + } + + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + t.Setenv("USERPROFILE", tmpHome) + t.Setenv("TMPO_DEV", "") + + db, err := Initialize() + assert.NoError(t, err) + defer db.Close() + + tmpoDir := filepath.Join(tmpHome, ".tmpo") + dirInfo, err := os.Stat(tmpoDir) + assert.NoError(t, err) + assert.Equal(t, fsperm.DirPerm, dirInfo.Mode().Perm()) + + dbInfo, err := os.Stat(filepath.Join(tmpoDir, "tmpo.db")) + assert.NoError(t, err) + assert.Equal(t, fsperm.FilePerm, dbInfo.Mode().Perm()) +} + +func TestInitialize_RemediatesLoosePermissions(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX permissions are not enforced on Windows") + } + + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + t.Setenv("USERPROFILE", tmpHome) + t.Setenv("TMPO_DEV", "") + + // Simulate an install created before permissions were enforced. + tmpoDir := filepath.Join(tmpHome, ".tmpo") + assert.NoError(t, os.MkdirAll(tmpoDir, 0755)) + dbPath := filepath.Join(tmpoDir, "tmpo.db") + assert.NoError(t, os.WriteFile(dbPath, []byte(""), 0644)) + + db, err := Initialize() + assert.NoError(t, err) + defer db.Close() + + dirInfo, err := os.Stat(tmpoDir) + assert.NoError(t, err) + assert.Equal(t, fsperm.DirPerm, dirInfo.Mode().Perm()) + + dbInfo, err := os.Stat(dbPath) + assert.NoError(t, err) + assert.Equal(t, fsperm.FilePerm, dbInfo.Mode().Perm()) +} From 189e0a95b41b358db0b8d82ec3bb2fe326b243a5 Mon Sep 17 00:00:00 2001 From: Dylan Ravel Date: Wed, 15 Jul 2026 11:22:02 -1000 Subject: [PATCH 2/2] Update security docs --- SECURITY.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index e0f4d43..ffe62e6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,8 +6,8 @@ We release security updates for the following versions of tmpo: | Version | Supported | | ------- | ------------------ | -| 0.8.x | :white_check_mark: | -| < 0.8.0 | :x: | +| 0.9.x | :white_check_mark: | +| < 0.9.0 | :x: | We recommend always using the latest stable release to ensure you have the most recent security patches. @@ -18,7 +18,7 @@ tmpo is a local-first CLI tool that stores time tracking data on your machine. H ### **Local Data Storage** - All time entries are stored in a SQLite database at `$HOME/.tmpo/tmpo.db` -- The database is only accessible to your user account (standard file permissions apply) +- The `~/.tmpo` directory is created with owner-only permissions (`0700`), and the database, backups, and config files are created with owner-only read/write (`0600`), so other local users on the same machine cannot read your time tracking data - Your time tracking data is never transmitted over the network ### **Update Checks**