lockbox

password manager
Log | Files | Refs | README | LICENSE

commit 277f8ee2cd7e5977b09322365a6c5b4ef675745e
parent 1e118e349d1d6fd9756206d3f9b0a9bdab6e9f72
Author: Sean Enck <sean@ttypty.com>
Date:   Wed,  2 Nov 2022 18:07:06 -0400

adding ability to have hooks

Diffstat:
MMakefile | 2+-
Mcmd/vers.txt | 4++--
Minternal/backend/actions.go | 70+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Minternal/backend/actions_test.go | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/backend/types.go | 24++++++++++++++++++++++++
Minternal/inputs/env.go | 3+++
Minternal/inputs/env_test.go | 2+-
Mscripts/check.go | 5++++-
Ascripts/hooks/all.sh | 2++
Ascripts/hooks/test.sh | 2++
Mscripts/tests.expected.log | 18+++++++++++++++++-
11 files changed, 178 insertions(+), 9 deletions(-)

diff --git a/Makefile b/Makefile @@ -21,7 +21,7 @@ $(TESTDIR): cd $@ && go test check: $(TARGET) $(TESTDIR) - LB_BUILD=$(TARGET) TEST_DATA=$(BUILD) go run scripts/check.go 2>&1 | sed "s#$(PWD)/$(DATA)##g" | sed 's/^[0-9][0-9][0-9][0-9][0-9][0-9]$$/XXXXXX/g' > $(ACTUAL) + LB_BUILD=$(TARGET) TEST_DATA=$(BUILD) SCRIPTS=$(PWD)/scripts/ go run scripts/check.go 2>&1 | sed "s#$(PWD)/$(DATA)##g" | sed 's/^[0-9][0-9][0-9][0-9][0-9][0-9]$$/XXXXXX/g' > $(ACTUAL) diff -u $(ACTUAL) scripts/tests.expected.log clean: diff --git a/cmd/vers.txt b/cmd/vers.txt @@ -1 +1 @@ -v22.10.10 -\ No newline at end of file +v22.11.00 +\ No newline at end of file diff --git a/internal/backend/actions.go b/internal/backend/actions.go @@ -4,6 +4,8 @@ package backend import ( "errors" "os" + "os/exec" + "path/filepath" "strings" "github.com/enckse/lockbox/internal/inputs" @@ -11,6 +13,48 @@ import ( "github.com/tobischo/gokeepasslib/v3/wrappers" ) +// NewHook will create a new hook type +func NewHook(path string, a ActionMode) (Hook, error) { + if strings.TrimSpace(path) == "" { + return Hook{}, errors.New("empty path is not allowed for hooks") + } + dir := inputs.EnvOrDefault(inputs.HookDirEnv, "") + if dir == "" { + return Hook{enabled: false}, nil + } + if !pathExists(dir) { + return Hook{}, errors.New("hook directory does NOT exist") + } + entries, err := os.ReadDir(dir) + if err != nil { + return Hook{}, err + } + scripts := []string{} + for _, e := range entries { + if e.IsDir() { + return Hook{}, errors.New("found subdirectory in hookdir") + } + scripts = append(scripts, filepath.Join(dir, e.Name())) + } + return Hook{path: path, mode: a, enabled: len(scripts) > 0, scripts: scripts}, nil +} + +// Run will execute any scripts configured as hooks +func (h Hook) Run(mode HookMode) error { + if !h.enabled { + return nil + } + for _, s := range h.scripts { + c := exec.Command(s, string(mode), string(h.mode), h.path) + c.Stdout = os.Stdout + c.Stderr = os.Stderr + if err := c.Run(); err != nil { + return err + } + } + return nil +} + func (t *Transaction) act(cb action) error { if !t.valid { return errors.New("invalid transaction") @@ -183,11 +227,21 @@ func (t *Transaction) Move(src QueryEntity, dst string) error { if err != nil { return err } - isMove := dst != src.Path + action := MoveAction + if dst == src.Path { + action = InsertAction + } + hook, err := NewHook(src.Path, action) + if err != nil { + return err + } multi := len(strings.Split(strings.TrimSpace(src.Value), "\n")) > 1 return t.change(func(c Context) error { + if err := hook.Run(HookPre); err != nil { + return err + } c.removeEntity(sOffset, sTitle) - if isMove { + if action == MoveAction { c.removeEntity(dOffset, dTitle) } e := gokeepasslib.NewEntry() @@ -211,7 +265,7 @@ func (t *Transaction) Move(src QueryEntity, dst string) error { e.Values = append(e.Values, protectedValue(field, v)) c.insertEntity(dOffset, dTitle, e) - return nil + return hook.Run(HookPost) }) } @@ -239,9 +293,19 @@ func (t *Transaction) RemoveAll(entities []QueryEntity) error { if err != nil { return err } + hook, err := NewHook(entity.Path, RemoveAction) + if err != nil { + return err + } + if err := hook.Run(HookPre); err != nil { + return err + } if ok := c.removeEntity(offset, title); !ok { return errors.New("failed to remove entity") } + if err := hook.Run(HookPost); err != nil { + return err + } } return nil }) diff --git a/internal/backend/actions_test.go b/internal/backend/actions_test.go @@ -3,6 +3,8 @@ package backend_test import ( "fmt" "os" + "path/filepath" + "strings" "testing" "github.com/enckse/lockbox/internal/backend" @@ -17,6 +19,7 @@ func fullSetup(t *testing.T, keep bool) *backend.Transaction { os.Setenv("LOCKBOX_KEY", "test") os.Setenv("LOCKBOX_KEYMODE", "plaintext") os.Setenv("LOCKBOX_TOTP", "totp") + os.Setenv("LOCKBOX_HOOKDIR", "") tr, err := backend.NewTransaction() if err != nil { t.Errorf("failed: %v", err) @@ -219,3 +222,55 @@ func check(t *testing.T, checks ...string) error { } return nil } + +func TestHooks(t *testing.T) { + os.Setenv("LOCKBOX_HOOKDIR", "") + h, err := backend.NewHook("a", backend.InsertAction) + if err != nil { + t.Errorf("invalid error: %v", err) + } + if err := h.Run(backend.HookPre); err != nil { + t.Errorf("invalid error: %v", err) + } + if _, err := backend.NewHook("", backend.InsertAction); err.Error() != "empty path is not allowed for hooks" { + t.Errorf("wrong error: %v", err) + } + os.Setenv("LOCKBOX_HOOKDIR", "is_garbage") + if _, err := backend.NewHook("b", backend.InsertAction); err.Error() != "hook directory does NOT exist" { + t.Errorf("wrong error: %v", err) + } + testPath := "hooks.kdbx" + os.RemoveAll(testPath) + if err := os.MkdirAll(testPath, 0755); err != nil { + t.Errorf("failed, mkdir: %v", err) + } + os.Setenv("LOCKBOX_HOOKDIR", testPath) + h, err = backend.NewHook("a", backend.InsertAction) + if err != nil { + t.Errorf("invalid error: %v", err) + } + if err := h.Run(backend.HookPre); err != nil { + t.Errorf("invalid error: %v", err) + } + sub := filepath.Join(testPath, "subdir") + if err := os.MkdirAll(sub, 0755); err != nil { + t.Errorf("failed, mkdir sub: %v", err) + } + if _, err := backend.NewHook("b", backend.InsertAction); err.Error() != "found subdirectory in hookdir" { + t.Errorf("wrong error: %v", err) + } + if err := os.RemoveAll(sub); err != nil { + t.Errorf("failed rmdir: %v", err) + } + script := filepath.Join(testPath, "testscript") + if err := os.WriteFile(script, []byte{}, 0644); err != nil { + t.Errorf("unable to write script: %v", err) + } + h, err = backend.NewHook("a", backend.InsertAction) + if err != nil { + t.Errorf("invalid error: %v", err) + } + if err := h.Run(backend.HookPre); strings.Contains("fork/exec", err.Error()) { + t.Errorf("wrong error: %v", err) + } +} diff --git a/internal/backend/types.go b/internal/backend/types.go @@ -12,6 +12,10 @@ type ( QueryMode int // ValueMode indicates what to do with the store value of the entity ValueMode int + // ActionMode represents activities performed via transactions + ActionMode string + // HookMode are hook operations the user can tie to + HookMode string // QueryOptions indicates how to find entities QueryOptions struct { Mode QueryMode @@ -37,6 +41,13 @@ type ( Context struct { db *gokeepasslib.Database } + // Hook represents a runnable user-defined hook + Hook struct { + path string + mode ActionMode + enabled bool + scripts []string + } ) const ( @@ -54,6 +65,19 @@ const ( ) const ( + // MoveAction represents changes via moves, like the Move command + MoveAction ActionMode = "mv" + // InsertAction represents changes via inserts, like the Insert command + InsertAction ActionMode = "insert" + // RemoveAction represents changes via deletions, like Remove or globbed remove commands + RemoveAction ActionMode = "rm" + // HookPre are triggers BEFORE an action is performed on an entity + HookPre HookMode = "pre" + // HookPost are triggers AFTER an action is performed on an entity + HookPost HookMode = "post" +) + +const ( // BlankValue will not decrypt secrets, empty value BlankValue ValueMode = iota // HashedValue will decrypt and then hash the password diff --git a/internal/inputs/env.go b/internal/inputs/env.go @@ -56,6 +56,8 @@ const ( colorWindowSpan = ":" detectedValue = "(detected)" noTOTPEnv = prefixKey + "NOTOTP" + // HookDirEnv represents a stored location for user hooks + HookDirEnv = prefixKey + "HOOKDIR" ) var ( @@ -282,5 +284,6 @@ func ListEnvironmentVariables(showValues bool) []string { results = append(results, e.formatEnvironmentVariable(false, clipMaxEnv, fmt.Sprintf("%d", defaultMaxClipboard), "override the amount of time before totp clears the clipboard (e.g. 10), must be an integer", []string{"integer"})) results = append(results, e.formatEnvironmentVariable(false, PlatformEnv, detectedValue, "override the detected platform", []string{MacOSPlatform, LinuxWaylandPlatform, LinuxXPlatform, WindowsLinuxPlatform})) results = append(results, e.formatEnvironmentVariable(false, noTOTPEnv, isNo, "disable TOTP integrations", isYesNoArgs)) + results = append(results, e.formatEnvironmentVariable(false, HookDirEnv, "", "the path to hooks to execute on actions against the database", []string{"directory"})) return results } diff --git a/internal/inputs/env_test.go b/internal/inputs/env_test.go @@ -150,7 +150,7 @@ func TestGetKey(t *testing.T) { func TestListVariables(t *testing.T) { vars := inputs.ListEnvironmentVariables(false) - if len(vars) != 15 { + if len(vars) != 16 { t.Errorf("invalid env count, outdated? %d", len(vars)) } } diff --git a/scripts/check.go b/scripts/check.go @@ -56,7 +56,9 @@ func totpList() { } func main() { - store := filepath.Join(os.Getenv("TEST_DATA"), fmt.Sprintf("%s.kdbx", time.Now().Format("20060102150405"))) + path := os.Getenv("TEST_DATA") + store := filepath.Join(path, fmt.Sprintf("%s.kdbx", time.Now().Format("20060102150405"))) + os.Setenv("LOCKBOX_HOOKDIR", "") os.Setenv("LOCKBOX_STORE", store) os.Setenv("LOCKBOX_KEY", testKey) os.Setenv("LOCKBOX_TOTP", "totp") @@ -106,6 +108,7 @@ func main() { rm("keys/k2/t1/*") fmt.Println() ls() + os.Setenv("LOCKBOX_HOOKDIR", filepath.Join(os.Getenv("SCRIPTS"), "hooks")) rm("keys/k2/*") fmt.Println() ls() diff --git a/scripts/hooks/all.sh b/scripts/hooks/all.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +echo "$@" diff --git a/scripts/hooks/test.sh b/scripts/hooks/test.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +echo "CALLED" diff --git a/scripts/tests.expected.log b/scripts/tests.expected.log @@ -77,5 +77,21 @@ selected entities: keys/k2/t2/one keys/k2/t2/one2 -delete entries? (y/N) +delete entries? (y/N) pre rm keys/k2/one +CALLED +post rm keys/k2/one +CALLED +pre rm keys/k2/one2 +CALLED +post rm keys/k2/one2 +CALLED +pre rm keys/k2/t2/one +CALLED +post rm keys/k2/t2/one +CALLED +pre rm keys/k2/t2/one2 +CALLED +post rm keys/k2/t2/one2 +CALLED + keys/k/one2