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
6 changes: 3 additions & 3 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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**
Expand Down
37 changes: 37 additions & 0 deletions internal/fsperm/fsperm.go
Original file line number Diff line number Diff line change
@@ -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
}
60 changes: 60 additions & 0 deletions internal/fsperm/fsperm_test.go
Original file line number Diff line number Diff line change
@@ -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))
})
}
16 changes: 16 additions & 0 deletions internal/settings/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package settings
import (
"os"
"path/filepath"
"runtime"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -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())
}
9 changes: 7 additions & 2 deletions internal/settings/global_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"time"

"github.com/DylanDevelops/tmpo/internal/currency"
"github.com/DylanDevelops/tmpo/internal/fsperm"
"go.yaml.in/yaml/v3"
)

Expand Down Expand Up @@ -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)
}

Expand All @@ -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
}

Expand Down
36 changes: 36 additions & 0 deletions internal/settings/global_config_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
9 changes: 7 additions & 2 deletions internal/settings/projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"path/filepath"
"strings"

"github.com/DylanDevelops/tmpo/internal/fsperm"
"go.yaml.in/yaml/v3"
)

Expand Down Expand Up @@ -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)
}

Expand All @@ -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
}

Expand Down
23 changes: 23 additions & 0 deletions internal/settings/projects_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ package settings
import (
"os"
"path/filepath"
"runtime"
"testing"

"github.com/DylanDevelops/tmpo/internal/fsperm"
"github.com/stretchr/testify/assert"
)

Expand Down Expand Up @@ -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())
}
11 changes: 10 additions & 1 deletion internal/storage/backup.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"strings"
"time"

"github.com/DylanDevelops/tmpo/internal/fsperm"
_ "modernc.org/sqlite"
)

Expand Down Expand Up @@ -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)
}

Expand All @@ -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)
Expand Down Expand Up @@ -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
}

Expand Down
51 changes: 51 additions & 0 deletions internal/storage/backup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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())
}
Loading