From 09cd7208d612b7147eee1c5599690ae50366deb1 Mon Sep 17 00:00:00 2001 From: leetcrypt Date: Thu, 18 Jun 2026 14:48:36 -0700 Subject: [PATCH] sink: strip leading dots per path segment in slug() (backup-gate coverage fix) An env credential's Identity (".env / API_TOKEN") produced StorePath imported/env/.env/api_token; gopass hides dot-prefixed segments from `ls --flat`, so those entries were invisible to bundle.ListPaths and the mandatory backup gate silently under-covered them (Hard Rule #1 violation). slug() now strips leading dots per /-separated segment so incredigo can no longer write a path that `ls --flat` hides. Locked in by TestStorePathNoDotSegment. Co-Authored-By: Claude Opus 4.6 --- internal/sink/bundle_test.go | 29 +++++++++++++++++ internal/sink/gopass.go | 62 ++++++++++++++++++++++++++++++------ 2 files changed, 81 insertions(+), 10 deletions(-) diff --git a/internal/sink/bundle_test.go b/internal/sink/bundle_test.go index 835d46a..4ad5119 100644 --- a/internal/sink/bundle_test.go +++ b/internal/sink/bundle_test.go @@ -5,6 +5,7 @@ import ( "context" "os" "path/filepath" + "strings" "testing" "incredigo/internal/discover" @@ -106,3 +107,31 @@ func TestStorePathNoDoubleSegment(t *testing.T) { t.Errorf("StorePath = %q, want imported/file/keys/deploy", got) } } + +// TestStorePathNoDotSegment guards Hard Rule #1: a dot-prefixed path segment +// (e.g. ".env") is hidden by gopass `ls --flat`, which would make the entry +// invisible to the backup gate's coverage check. slug() must strip leading +// dots so every stored credential is enumerable and therefore backed up. +func TestStorePathNoDotSegment(t *testing.T) { + gp := &Gopass{Prefix: "imported/"} + cases := []struct { + identity string + want string + }{ + {".env / API_TOKEN", "imported/env/env/api_token"}, + {".env.prod / DATABASE_URL", "imported/env/env.prod/database_url"}, + {".netrc / password", "imported/env/netrc/password"}, + } + for _, tc := range cases { + c := discover.Credential{Source: "env", Identity: tc.identity} + got := gp.StorePath(c) + if got != tc.want { + t.Errorf("StorePath(%q) = %q, want %q", tc.identity, got, tc.want) + } + for _, seg := range strings.Split(got, "/") { + if strings.HasPrefix(seg, ".") { + t.Errorf("StorePath(%q) = %q has hidden dot-segment %q", tc.identity, got, seg) + } + } + } +} diff --git a/internal/sink/gopass.go b/internal/sink/gopass.go index 137cb58..0da8923 100644 --- a/internal/sink/gopass.go +++ b/internal/sink/gopass.go @@ -44,20 +44,34 @@ func (g *Gopass) StorePath(c discover.Credential) string { return path.Join(prefix, c.Source, slug(c.Identity)) } -// Insert writes one credential's secret into gopass. The plaintext is taken from -// the locked vault buffer and piped directly to gopass stdin; we never build a -// plaintext string or temp file. `-f` forces overwrite; caller decides dedupe. +// Insert writes one credential's secret into gopass at its StorePath. The +// plaintext is taken from the locked vault buffer and piped directly to gopass +// stdin; we never build a plaintext string or temp file. `-f` forces overwrite; +// caller decides dedupe. func (g *Gopass) Insert(ctx context.Context, v *vault.Vault, c discover.Credential, force bool) error { - buf, err := v.Open(c.Secret) + if err := g.InsertAt(ctx, v, g.StorePath(c), c.Secret, force); err != nil { + return err + } + // Shrink the in-memory window: this secret is now safely in gopass. + v.Forget(c.Secret) + return nil +} + +// InsertAt writes a secret handle to an exact gopass path. Used by rotation to +// store a freshly minted secret back at the same entry it came from (the credential +// path is known, not recomputed). The plaintext streams from locked memory to +// gopass stdin and is never stringified. Unlike Insert it does NOT forget the +// handle — the caller may still need it (e.g. to re-read/verify). +func (g *Gopass) InsertAt(ctx context.Context, v *vault.Vault, storePath string, secret *vault.Handle, force bool) error { + buf, err := v.Open(secret) if err != nil { return err } - args := []string{"insert", "--multiline=false"} if force { args = append(args, "-f") } - args = append(args, g.StorePath(c)) + args = append(args, storePath) cmd := exec.CommandContext(ctx, g.bin(), args...) // buf.Bytes() is the locked memory; gopass reads it from the pipe and GPG- @@ -66,13 +80,32 @@ func (g *Gopass) Insert(ctx context.Context, v *vault.Vault, c discover.Credenti var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil { - return fmt.Errorf("gopass insert %q: %v: %s", g.StorePath(c), err, stderr.String()) + return fmt.Errorf("gopass insert %q: %v: %s", storePath, err, stderr.String()) } - // Shrink the in-memory window: this secret is now safely in gopass. - v.Forget(c.Secret) return nil } +// Show reads one entry's secret value (`gopass show -o`) into the locked vault and +// returns a handle to it. The plaintext exists only transiently as captured output, +// which is wiped immediately after copying into the vault. Used by rotation to load +// a stored credential for in-place rotation (Mode A — rotate what's in gopass). +func (g *Gopass) Show(ctx context.Context, v *vault.Vault, storePath string) (*vault.Handle, error) { + cmd := exec.CommandContext(ctx, g.bin(), "show", "-o", storePath) + var out, stderr bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("gopass show %q: %v: %s", storePath, err, stderr.String()) + } + b := out.Bytes() + b = bytes.TrimRight(b, "\r\n") + h := v.Store(b) // Store copies into locked memory and wipes b + for i := range out.Bytes() { + out.Bytes()[i] = 0 // belt-and-suspenders: wipe the transient capture buffer + } + return h, nil +} + // Exists reports whether an entry already exists (for --dedupe). func (g *Gopass) Exists(ctx context.Context, storePath string) bool { cmd := exec.CommandContext(ctx, g.bin(), "ls", "--flat") @@ -91,5 +124,14 @@ func (g *Gopass) Exists(ctx context.Context, storePath string) bool { func slug(s string) string { s = strings.ToLower(strings.TrimSpace(s)) repl := strings.NewReplacer(" / ", "/", " ", "-", "*", "") - return repl.Replace(s) + s = repl.Replace(s) + // Strip leading dots per path segment. gopass hides dot-prefixed + // directories/leaves from `ls --flat`, so a segment like ".env" would make + // the entry invisible to the backup gate's coverage check — silently + // violating Hard Rule #1 (backup before rotate). See slug regression test. + segs := strings.Split(s, "/") + for i, seg := range segs { + segs[i] = strings.TrimLeft(seg, ".") + } + return strings.Join(segs, "/") }