From 9b7e8c45f3b1bfb0c3451cc0bfe7b0f8f609fe1d Mon Sep 17 00:00:00 2001 From: Caio Rocha de Oliveira Date: Wed, 29 Apr 2026 16:06:18 -0300 Subject: [PATCH 1/7] fix(age): support /dev/fd and /proc stream reading Signed-off-by: Caio Rocha de Oliveira --- age/keysource.go | 40 +++++++++++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/age/keysource.go b/age/keysource.go index cdf6b1d696..c95ca1f31a 100644 --- a/age/keysource.go +++ b/age/keysource.go @@ -399,14 +399,30 @@ func getUserConfigDir() (string, error) { return os.UserConfigDir() } +// reads a file from the given path, if it is a stream (e.g., /dev/fd/* or /proc/*) +// it caches the content in memory to avoid issues with multiple reads from the same stream. +func readStreamSafe(path string) ([]byte, error) { + isStream := strings.HasPrefix(path, "/dev/fd/") || strings.HasPrefix(path, "/proc/") + + if isStream { + if cached, ok := fileStreamCache.Load(path); ok { + return cached.([]byte), nil + } + } + + b, err := os.ReadFile(path) + if err == nil && isStream { + fileStreamCache.Store(path, b) + } + return b, err +} + // loadIdentities attempts to load the age identities based on runtime -// environment configurations (e.g. SopsAgeKeyEnv, SopsAgeKeyFileEnv, -// SopsAgeSshPrivateKeyFileEnv, SopsAgeKeyUserConfigPath). It will load all +// environment configurations (e.g. SopsAgeKeyUserConfigPath). It will load all // found references, and expects at least one configuration to be present. func (key *MasterKey) loadIdentities() (ParsedIdentities, []string, errSet) { identities, unusedLocations, errs := key.loadAgeSSHIdentities() - - var readers = make(map[string]io.Reader, 0) + readers := make(map[string]io.Reader) if ageKey, ok := os.LookupEnv(SopsAgeKeyEnv); ok { readers[SopsAgeKeyEnv] = strings.NewReader(ageKey) @@ -415,19 +431,21 @@ func (key *MasterKey) loadIdentities() (ParsedIdentities, []string, errSet) { } if ageKeyFile, ok := os.LookupEnv(SopsAgeKeyFileEnv); ok { - f, err := os.Open(ageKeyFile) + b, err := readStreamSafe(ageKeyFile) if err != nil { errs = append(errs, fmt.Errorf("failed to open %s file: %w", SopsAgeKeyFileEnv, err)) } else { - defer f.Close() - readers[SopsAgeKeyFileEnv] = f + readers[SopsAgeKeyFileEnv] = bytes.NewReader(b) } } else { unusedLocations = append(unusedLocations, SopsAgeKeyFileEnv) } if ageKeyCmd, ok := os.LookupEnv(SopsAgeKeyCmdEnv); ok { - out, err := getOutputFromCmd(ageKeyCmd, []string{fmt.Sprintf("%s=%s", SopsAgeRecipientEnv, key.Recipient)}) + out, err := getOutputFromCmd( + ageKeyCmd, + []string{fmt.Sprintf("%s=%s", SopsAgeRecipientEnv, key.Recipient)}, + ) if err != nil { errs = append(errs, err) } else { @@ -442,14 +460,13 @@ func (key *MasterKey) loadIdentities() (ParsedIdentities, []string, errSet) { errs = append(errs, fmt.Errorf("user config directory could not be determined: %w", err)) } else if userConfigDir != "" { ageKeyFilePath := filepath.Join(userConfigDir, filepath.FromSlash(SopsAgeKeyUserConfigPath)) - f, err := os.Open(ageKeyFilePath) + b, err := readStreamSafe(ageKeyFilePath) if err != nil && !errors.Is(err, os.ErrNotExist) { errs = append(errs, fmt.Errorf("failed to open file: %w", err)) } else if errors.Is(err, os.ErrNotExist) && len(readers) == 0 && len(identities) == 0 { unusedLocations = append(unusedLocations, ageKeyFilePath) } else if err == nil { - defer f.Close() - readers[ageKeyFilePath] = f + readers[ageKeyFilePath] = bytes.NewReader(b) } } @@ -464,6 +481,7 @@ func (key *MasterKey) loadIdentities() (ParsedIdentities, []string, errSet) { } } } + return identities, unusedLocations, errs } From a3aacaac18cf3712c15c59dbe27b9a6e8a85c780 Mon Sep 17 00:00:00 2001 From: Caio Rocha de Oliveira Date: Wed, 29 Apr 2026 16:07:44 -0300 Subject: [PATCH 2/7] sec(age): zero out cached credentials Signed-off-by: Caio Rocha de Oliveira --- age/keysource.go | 15 +++++++++++++++ cmd/sops/main.go | 2 ++ 2 files changed, 17 insertions(+) diff --git a/age/keysource.go b/age/keysource.go index c95ca1f31a..ede5fc79da 100644 --- a/age/keysource.go +++ b/age/keysource.go @@ -399,6 +399,21 @@ func getUserConfigDir() (string, error) { return os.UserConfigDir() } +// ClearFileStreamCache wipes the cached stream secrets from memory by overwriting +// the byte slices with zeros before deleting them from the map. +// This is critical for security to prevent keys from lingering in RAM. +func ClearFileStreamCache() { + fileStreamCache.Range(func(key, value interface{}) bool { + if byte, ok := value.([]byte); ok { + for i := range byte { + byte[i] = 0 + } + } + fileStreamCache.Delete(key) + return true + }) +} + // reads a file from the given path, if it is a stream (e.g., /dev/fd/* or /proc/*) // it caches the content in memory to avoid issues with multiple reads from the same stream. func readStreamSafe(path string) ([]byte, error) { diff --git a/cmd/sops/main.go b/cmd/sops/main.go index 330c3bc8eb..939772b4db 100644 --- a/cmd/sops/main.go +++ b/cmd/sops/main.go @@ -75,6 +75,8 @@ func warnMoreThanOnePositionalArgument(c *cli.Context) { } func main() { + defer age.ClearFileStreamCache() + cli.VersionPrinter = version.PrintVersion app := cli.NewApp() From f825e3d114a7c3bccbfb0d59e43f66b9eb2d8ba4 Mon Sep 17 00:00:00 2001 From: Caio Rocha de Oliveira Date: Wed, 29 Apr 2026 16:18:31 -0300 Subject: [PATCH 3/7] fix(age): fileStreamCache Signed-off-by: Caio Rocha de Oliveira --- age/keysource.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/age/keysource.go b/age/keysource.go index ede5fc79da..886ccb98a8 100644 --- a/age/keysource.go +++ b/age/keysource.go @@ -55,6 +55,8 @@ const ( // log is the global logger for any age MasterKey. var log *logrus.Logger +var fileStreamCache sync.Map + func init() { log = logging.NewLogger("AGE") } From 495cb3755a30c7df7ef211dbba698f2ed4f1b1cd Mon Sep 17 00:00:00 2001 From: Caio Rocha de Oliveira Date: Wed, 29 Apr 2026 16:22:22 -0300 Subject: [PATCH 4/7] fix(age): sync Signed-off-by: Caio Rocha de Oliveira --- age/keysource.go | 1 + 1 file changed, 1 insertion(+) diff --git a/age/keysource.go b/age/keysource.go index 886ccb98a8..372f391f45 100644 --- a/age/keysource.go +++ b/age/keysource.go @@ -21,6 +21,7 @@ import ( "github.com/getsops/sops/v3/logging" "github.com/google/shlex" + "sync" ) const ( From 0caddf55c2362af46ecabe9f56b9f98812bcf4d6 Mon Sep 17 00:00:00 2001 From: Caio Rocha de Oliveira Date: Wed, 29 Apr 2026 19:57:26 -0300 Subject: [PATCH 5/7] feat(age): support /dev/stdin Signed-off-by: Caio Rocha de Oliveira --- age/keysource.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/age/keysource.go b/age/keysource.go index 372f391f45..45d93e3699 100644 --- a/age/keysource.go +++ b/age/keysource.go @@ -420,7 +420,7 @@ func ClearFileStreamCache() { // reads a file from the given path, if it is a stream (e.g., /dev/fd/* or /proc/*) // it caches the content in memory to avoid issues with multiple reads from the same stream. func readStreamSafe(path string) ([]byte, error) { - isStream := strings.HasPrefix(path, "/dev/fd/") || strings.HasPrefix(path, "/proc/") + isStream := strings.HasPrefix(path, "/dev/fd/") || strings.HasPrefix(path, "/proc/") || strings.HasPrefix(path, "/dev/stdin") if isStream { if cached, ok := fileStreamCache.Load(path); ok { From cadd7d99d2b382a1498dc8a592f8697f45f9e956 Mon Sep 17 00:00:00 2001 From: Caio Rocha de Oliveira Date: Sun, 3 May 2026 16:42:43 -0300 Subject: [PATCH 6/7] fix(age): support windows, mac and linux Signed-off-by: Caio Rocha de Oliveira --- age/keysource.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/age/keysource.go b/age/keysource.go index 45d93e3699..28555fc972 100644 --- a/age/keysource.go +++ b/age/keysource.go @@ -418,9 +418,11 @@ func ClearFileStreamCache() { } // reads a file from the given path, if it is a stream (e.g., /dev/fd/* or /proc/*) -// it caches the content in memory to avoid issues with multiple reads from the same stream. +// using os.Stat to check the file mode. If it is a stream, it reads the content and caches it in memory. +// It caches the content in memory to avoid issues with multiple reads from the same stream. func readStreamSafe(path string) ([]byte, error) { - isStream := strings.HasPrefix(path, "/dev/fd/") || strings.HasPrefix(path, "/proc/") || strings.HasPrefix(path, "/dev/stdin") + fileInfo, err := os.Stat(path) + isStream := err == nil && (fileInfo.Mode()&os.ModeNamedPipe != 0 || fileInfo.Mode()&os.ModeCharDevice != 0) if isStream { if cached, ok := fileStreamCache.Load(path); ok { From d5255a876f9961b870c2d4ecd409b2f997a97371 Mon Sep 17 00:00:00 2001 From: Caio Rocha de Oliveira Date: Sun, 3 May 2026 16:53:09 -0300 Subject: [PATCH 7/7] fix(age): merge conflict errors Signed-off-by: Caio Rocha de Oliveira --- age/keysource.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/age/keysource.go b/age/keysource.go index 585e18584c..1ad1b11e79 100644 --- a/age/keysource.go +++ b/age/keysource.go @@ -464,9 +464,8 @@ func (key *MasterKey) loadIdentities() (ParsedIdentities, []string, errSet) { if err != nil { errs = append(errs, fmt.Errorf("failed to open %s file: %w", SopsAgeKeyFileEnv, err)) } else { - defer f.Close() readers[SopsAgeKeyFileEnv] = identityReader{ - reader: f, + reader: bytes.NewReader(b), allowMultipleKeysPerLine: false, } } @@ -502,9 +501,8 @@ func (key *MasterKey) loadIdentities() (ParsedIdentities, []string, errSet) { } else if errors.Is(err, os.ErrNotExist) && len(readers) == 0 && len(identities) == 0 { unusedLocations = append(unusedLocations, ageKeyFilePath) } else if err == nil { - defer f.Close() readers[ageKeyFilePath] = identityReader{ - reader: f, + reader: bytes.NewReader(b), allowMultipleKeysPerLine: false, } }