Compare commits
No commits in common. "main" and "fix/issue-14" have entirely different histories.
main
...
fix/issue-
@ -71,8 +71,6 @@ func getUnlockerIDsCompletionFunc(fs afero.Fs, stateDir string) func(
|
|||||||
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
||||||
files, err := afero.ReadDir(fs, unlockersDir)
|
files, err := afero.ReadDir(fs, unlockersDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Warn("Could not read unlockers directory during completion", "error", err)
|
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -87,15 +85,11 @@ func getUnlockerIDsCompletionFunc(fs afero.Fs, stateDir string) func(
|
|||||||
// Check if this is the right unlocker by comparing metadata
|
// Check if this is the right unlocker by comparing metadata
|
||||||
metadataBytes, err := afero.ReadFile(fs, metadataPath)
|
metadataBytes, err := afero.ReadFile(fs, metadataPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Warn("Could not read unlocker metadata during completion", "path", metadataPath, "error", err)
|
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
var diskMetadata secret.UnlockerMetadata
|
var diskMetadata secret.UnlockerMetadata
|
||||||
if err := json.Unmarshal(metadataBytes, &diskMetadata); err != nil {
|
if err := json.Unmarshal(metadataBytes, &diskMetadata); err != nil {
|
||||||
secret.Warn("Could not parse unlocker metadata during completion", "path", metadataPath, "error", err)
|
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -29,8 +28,6 @@ func gatherVaultStats(
|
|||||||
// Count secrets in this vault
|
// Count secrets in this vault
|
||||||
secretEntries, err := afero.ReadDir(fs, secretsPath)
|
secretEntries, err := afero.ReadDir(fs, secretsPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Warn("Could not read secrets directory for vault", "vault", vaultEntry.Name(), "error", err)
|
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -46,8 +43,6 @@ func gatherVaultStats(
|
|||||||
versionsPath := filepath.Join(secretPath, "versions")
|
versionsPath := filepath.Join(secretPath, "versions")
|
||||||
versionEntries, err := afero.ReadDir(fs, versionsPath)
|
versionEntries, err := afero.ReadDir(fs, versionsPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Warn("Could not read versions directory for secret", "secret", secretEntry.Name(), "error", err)
|
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1047,6 +1047,7 @@ func test12SecretNameFormats(t *testing.T, tempDir, testMnemonic string, runSecr
|
|||||||
// Test invalid secret names
|
// Test invalid secret names
|
||||||
invalidNames := []string{
|
invalidNames := []string{
|
||||||
"", // empty
|
"", // empty
|
||||||
|
"UPPERCASE", // uppercase not allowed
|
||||||
"with space", // spaces not allowed
|
"with space", // spaces not allowed
|
||||||
"with@symbol", // special characters not allowed
|
"with@symbol", // special characters not allowed
|
||||||
"with#hash", // special characters not allowed
|
"with#hash", // special characters not allowed
|
||||||
@ -1072,7 +1073,7 @@ func test12SecretNameFormats(t *testing.T, tempDir, testMnemonic string, runSecr
|
|||||||
|
|
||||||
// Some of these might not be invalid after all (e.g., leading/trailing slashes might be stripped, .hidden might be allowed)
|
// Some of these might not be invalid after all (e.g., leading/trailing slashes might be stripped, .hidden might be allowed)
|
||||||
// For now, just check the ones we know should definitely fail
|
// For now, just check the ones we know should definitely fail
|
||||||
definitelyInvalid := []string{"", "with space", "with@symbol", "with#hash", "with$dollar"}
|
definitelyInvalid := []string{"", "UPPERCASE", "with space", "with@symbol", "with#hash", "with$dollar"}
|
||||||
shouldFail := false
|
shouldFail := false
|
||||||
for _, invalid := range definitelyInvalid {
|
for _, invalid := range definitelyInvalid {
|
||||||
if invalidName == invalid {
|
if invalidName == invalid {
|
||||||
|
|||||||
@ -507,7 +507,7 @@ func (cli *Instance) ImportSecret(cmd *cobra.Command, secretName, sourceFile str
|
|||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
if err := file.Close(); err != nil {
|
if err := file.Close(); err != nil {
|
||||||
secret.Warn("Failed to close file", "error", err)
|
secret.Debug("Failed to close file", "error", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
@ -271,8 +271,6 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
|
|||||||
// Create unlocker instance to get the proper ID
|
// Create unlocker instance to get the proper ID
|
||||||
vaultDir, err := vlt.GetDirectory()
|
vaultDir, err := vlt.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Warn("Could not get vault directory while listing unlockers", "error", err)
|
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -280,8 +278,6 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
|
|||||||
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
||||||
files, err := afero.ReadDir(cli.fs, unlockersDir)
|
files, err := afero.ReadDir(cli.fs, unlockersDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Warn("Could not read unlockers directory", "error", err)
|
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -297,16 +293,12 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
|
|||||||
// Check if this is the right unlocker by comparing metadata
|
// Check if this is the right unlocker by comparing metadata
|
||||||
metadataBytes, err := afero.ReadFile(cli.fs, metadataPath)
|
metadataBytes, err := afero.ReadFile(cli.fs, metadataPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Warn("Could not read unlocker metadata file", "path", metadataPath, "error", err)
|
continue // FIXME this error needs to be handled
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var diskMetadata secret.UnlockerMetadata
|
var diskMetadata secret.UnlockerMetadata
|
||||||
if err := json.Unmarshal(metadataBytes, &diskMetadata); err != nil {
|
if err := json.Unmarshal(metadataBytes, &diskMetadata); err != nil {
|
||||||
secret.Warn("Could not parse unlocker metadata file", "path", metadataPath, "error", err)
|
continue // FIXME this error needs to be handled
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match by type and creation time
|
// Match by type and creation time
|
||||||
@ -332,7 +324,6 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
|
|||||||
} else {
|
} else {
|
||||||
// Generate ID as fallback
|
// Generate ID as fallback
|
||||||
properID = fmt.Sprintf("%s-%s", metadata.CreatedAt.Format("2006-01-02.15.04"), metadata.Type)
|
properID = fmt.Sprintf("%s-%s", metadata.CreatedAt.Format("2006-01-02.15.04"), metadata.Type)
|
||||||
secret.Warn("Could not create unlocker instance, using fallback ID", "fallback_id", properID, "type", metadata.Type)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
unlockerInfo := UnlockerInfo{
|
unlockerInfo := UnlockerInfo{
|
||||||
@ -599,16 +590,12 @@ func (cli *Instance) checkUnlockerExists(vlt *vault.Vault, unlockerID string) er
|
|||||||
// Get the list of unlockers and check if any match the ID
|
// Get the list of unlockers and check if any match the ID
|
||||||
unlockers, err := vlt.ListUnlockers()
|
unlockers, err := vlt.ListUnlockers()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Warn("Could not list unlockers during duplicate check", "error", err)
|
|
||||||
|
|
||||||
return nil // If we can't list unlockers, assume it doesn't exist
|
return nil // If we can't list unlockers, assume it doesn't exist
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get vault directory to construct unlocker instances
|
// Get vault directory to construct unlocker instances
|
||||||
vaultDir, err := vlt.GetDirectory()
|
vaultDir, err := vlt.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Warn("Could not get vault directory during duplicate check", "error", err)
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -618,8 +605,6 @@ func (cli *Instance) checkUnlockerExists(vlt *vault.Vault, unlockerID string) er
|
|||||||
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
||||||
files, err := afero.ReadDir(cli.fs, unlockersDir)
|
files, err := afero.ReadDir(cli.fs, unlockersDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Warn("Could not read unlockers directory during duplicate check", "error", err)
|
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -634,15 +619,11 @@ func (cli *Instance) checkUnlockerExists(vlt *vault.Vault, unlockerID string) er
|
|||||||
// Check if this matches our metadata
|
// Check if this matches our metadata
|
||||||
metadataBytes, err := afero.ReadFile(cli.fs, metadataPath)
|
metadataBytes, err := afero.ReadFile(cli.fs, metadataPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Warn("Could not read unlocker metadata during duplicate check", "path", metadataPath, "error", err)
|
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
var diskMetadata secret.UnlockerMetadata
|
var diskMetadata secret.UnlockerMetadata
|
||||||
if err := json.Unmarshal(metadataBytes, &diskMetadata); err != nil {
|
if err := json.Unmarshal(metadataBytes, &diskMetadata); err != nil {
|
||||||
secret.Warn("Could not parse unlocker metadata during duplicate check", "path", metadataPath, "error", err)
|
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -164,7 +164,7 @@ func (cli *Instance) ListVersions(cmd *cobra.Command, secretName string) error {
|
|||||||
|
|
||||||
// Load metadata
|
// Load metadata
|
||||||
if err := sv.LoadMetadata(ltIdentity); err != nil {
|
if err := sv.LoadMetadata(ltIdentity); err != nil {
|
||||||
secret.Warn("Failed to load version metadata", "version", version, "error", err)
|
secret.Debug("Failed to load version metadata", "version", version, "error", err)
|
||||||
// Display version with error
|
// Display version with error
|
||||||
status := "error"
|
status := "error"
|
||||||
if version == currentVersion {
|
if version == currentVersion {
|
||||||
|
|||||||
@ -58,16 +58,6 @@ func IsDebugEnabled() bool {
|
|||||||
return debugEnabled
|
return debugEnabled
|
||||||
}
|
}
|
||||||
|
|
||||||
// Warn logs a warning message to stderr unconditionally (visible without --verbose or debug flags)
|
|
||||||
func Warn(msg string, args ...any) {
|
|
||||||
output := fmt.Sprintf("WARNING: %s", msg)
|
|
||||||
for i := 0; i+1 < len(args); i += 2 {
|
|
||||||
output += fmt.Sprintf(" %s=%v", args[i], args[i+1])
|
|
||||||
}
|
|
||||||
output += "\n"
|
|
||||||
fmt.Fprint(os.Stderr, output)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debug logs a debug message with optional attributes
|
// Debug logs a debug message with optional attributes
|
||||||
func Debug(msg string, args ...any) {
|
func Debug(msg string, args ...any) {
|
||||||
if !debugEnabled {
|
if !debugEnabled {
|
||||||
|
|||||||
@ -1,82 +0,0 @@
|
|||||||
package secret
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
|
||||||
"github.com/awnumar/memguard"
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// realVault is a minimal VaultInterface backed by a real afero filesystem,
|
|
||||||
// using the same directory layout as vault.Vault.
|
|
||||||
type realVault struct {
|
|
||||||
name string
|
|
||||||
stateDir string
|
|
||||||
fs afero.Fs
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *realVault) GetDirectory() (string, error) {
|
|
||||||
return filepath.Join(v.stateDir, "vaults.d", v.name), nil
|
|
||||||
}
|
|
||||||
func (v *realVault) GetName() string { return v.name }
|
|
||||||
func (v *realVault) GetFilesystem() afero.Fs { return v.fs }
|
|
||||||
|
|
||||||
// Unused by getLongTermPrivateKey — these satisfy VaultInterface.
|
|
||||||
func (v *realVault) AddSecret(string, *memguard.LockedBuffer, bool) error { panic("not used") }
|
|
||||||
func (v *realVault) GetCurrentUnlocker() (Unlocker, error) { panic("not used") }
|
|
||||||
func (v *realVault) CreatePassphraseUnlocker(*memguard.LockedBuffer) (*PassphraseUnlocker, error) {
|
|
||||||
panic("not used")
|
|
||||||
}
|
|
||||||
|
|
||||||
// createRealVault sets up a complete vault directory structure on an in-memory
|
|
||||||
// filesystem, identical to what vault.CreateVault produces.
|
|
||||||
func createRealVault(t *testing.T, fs afero.Fs, stateDir, name string, derivationIndex uint32) *realVault {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
vaultDir := filepath.Join(stateDir, "vaults.d", name)
|
|
||||||
require.NoError(t, fs.MkdirAll(filepath.Join(vaultDir, "secrets.d"), DirPerms))
|
|
||||||
require.NoError(t, fs.MkdirAll(filepath.Join(vaultDir, "unlockers.d"), DirPerms))
|
|
||||||
|
|
||||||
metadata := VaultMetadata{
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
DerivationIndex: derivationIndex,
|
|
||||||
}
|
|
||||||
metaBytes, err := json.Marshal(metadata)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, afero.WriteFile(fs, filepath.Join(vaultDir, "vault-metadata.json"), metaBytes, FilePerms))
|
|
||||||
|
|
||||||
return &realVault{name: name, stateDir: stateDir, fs: fs}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetLongTermPrivateKeyUsesVaultDerivationIndex(t *testing.T) {
|
|
||||||
const testMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
|
||||||
|
|
||||||
// Derive expected keys at two different indices to prove they differ.
|
|
||||||
key0, err := agehd.DeriveIdentity(testMnemonic, 0)
|
|
||||||
require.NoError(t, err)
|
|
||||||
key5, err := agehd.DeriveIdentity(testMnemonic, 5)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotEqual(t, key0.String(), key5.String(),
|
|
||||||
"sanity check: different derivation indices must produce different keys")
|
|
||||||
|
|
||||||
// Build a real vault with DerivationIndex=5 on an in-memory filesystem.
|
|
||||||
fs := afero.NewMemMapFs()
|
|
||||||
vault := createRealVault(t, fs, "/state", "test-vault", 5)
|
|
||||||
|
|
||||||
t.Setenv(EnvMnemonic, testMnemonic)
|
|
||||||
|
|
||||||
result, err := getLongTermPrivateKey(fs, vault)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer result.Destroy()
|
|
||||||
|
|
||||||
assert.Equal(t, key5.String(), string(result.Bytes()),
|
|
||||||
"getLongTermPrivateKey should derive at vault's DerivationIndex (5)")
|
|
||||||
assert.NotEqual(t, key0.String(), string(result.Bytes()),
|
|
||||||
"getLongTermPrivateKey must not use hardcoded index 0")
|
|
||||||
}
|
|
||||||
@ -53,10 +53,7 @@ func DetermineStateDir(customConfigDir string) (string, error) {
|
|||||||
return "", fmt.Errorf("unable to determine state directory: config dir: %w, home dir: %w", err, homeErr)
|
return "", fmt.Errorf("unable to determine state directory: config dir: %w, home dir: %w", err, homeErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
fallbackDir := filepath.Join(homeDir, ".config", AppID)
|
return filepath.Join(homeDir, ".config", AppID), nil
|
||||||
Warn("Could not determine user config directory, falling back to default", "fallback", fallbackDir, "error", err)
|
|
||||||
|
|
||||||
return fallbackDir, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return filepath.Join(configDir, AppID), nil
|
return filepath.Join(configDir, AppID), nil
|
||||||
|
|||||||
@ -251,25 +251,8 @@ func getLongTermPrivateKey(fs afero.Fs, vault VaultInterface) (*memguard.LockedB
|
|||||||
// Check if mnemonic is available in environment variable
|
// Check if mnemonic is available in environment variable
|
||||||
envMnemonic := os.Getenv(EnvMnemonic)
|
envMnemonic := os.Getenv(EnvMnemonic)
|
||||||
if envMnemonic != "" {
|
if envMnemonic != "" {
|
||||||
// Read vault metadata to get the correct derivation index
|
// Use mnemonic directly to derive long-term key
|
||||||
vaultDir, err := vault.GetDirectory()
|
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, 0)
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
metadataPath := filepath.Join(vaultDir, "vault-metadata.json")
|
|
||||||
metadataBytes, err := afero.ReadFile(fs, metadataPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read vault metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var metadata VaultMetadata
|
|
||||||
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse vault metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use mnemonic with the vault's actual derivation index
|
|
||||||
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, metadata.DerivationIndex)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
|
return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -257,10 +257,9 @@ func isValidSecretName(name string) bool {
|
|||||||
if name == "" {
|
if name == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
// Valid characters for secret names: letters, numbers, dash, dot, underscore, slash
|
// Valid characters for secret names: lowercase letters, numbers, dash, dot, underscore, slash
|
||||||
for _, char := range name {
|
for _, char := range name {
|
||||||
if (char < 'a' || char > 'z') && // lowercase letters
|
if (char < 'a' || char > 'z') && // lowercase letters
|
||||||
(char < 'A' || char > 'Z') && // uppercase letters
|
|
||||||
(char < '0' || char > '9') && // numbers
|
(char < '0' || char > '9') && // numbers
|
||||||
char != '-' && // dash
|
char != '-' && // dash
|
||||||
char != '.' && // dot
|
char != '.' && // dot
|
||||||
@ -284,9 +283,7 @@ func TestSecretNameValidation(t *testing.T) {
|
|||||||
{"valid/path/name", true},
|
{"valid/path/name", true},
|
||||||
{"123valid", true},
|
{"123valid", true},
|
||||||
{"", false},
|
{"", false},
|
||||||
{"Valid-Upper-Name", true}, // uppercase allowed
|
{"Invalid-Name", false}, // uppercase not allowed
|
||||||
{"2025-11-21-ber1app1-vaultik-test-bucket-AKI", true}, // real-world uppercase key ID
|
|
||||||
{"MixedCase/Path/Name", true}, // mixed case with path
|
|
||||||
{"invalid name", false}, // space not allowed
|
{"invalid name", false}, // space not allowed
|
||||||
{"invalid@name", false}, // @ not allowed
|
{"invalid@name", false}, // @ not allowed
|
||||||
}
|
}
|
||||||
|
|||||||
@ -102,8 +102,6 @@ func GenerateVersionName(fs afero.Fs, secretDir string) (string, error) {
|
|||||||
|
|
||||||
var serial int
|
var serial int
|
||||||
if _, err := fmt.Sscanf(parts[1], "%03d", &serial); err != nil {
|
if _, err := fmt.Sscanf(parts[1], "%03d", &serial); err != nil {
|
||||||
Warn("Skipping malformed version directory name", "name", entry.Name(), "error", err)
|
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,96 +0,0 @@
|
|||||||
package vault
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
|
||||||
"github.com/awnumar/memguard"
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestGetSecretVersionRejectsPathTraversal verifies that GetSecretVersion
|
|
||||||
// validates the secret name and rejects path traversal attempts.
|
|
||||||
// This is a regression test for https://git.eeqj.de/sneak/secret/issues/13
|
|
||||||
func TestGetSecretVersionRejectsPathTraversal(t *testing.T) {
|
|
||||||
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
|
||||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
|
||||||
t.Setenv(secret.EnvUnlockPassphrase, "test-passphrase")
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
|
||||||
stateDir := "/test/state"
|
|
||||||
|
|
||||||
vlt, err := CreateVault(fs, stateDir, "test-vault")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Add a legitimate secret so the vault is set up
|
|
||||||
value := memguard.NewBufferFromBytes([]byte("legitimate-secret"))
|
|
||||||
err = vlt.AddSecret("legit", value, false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// These names contain path traversal and should be rejected
|
|
||||||
maliciousNames := []string{
|
|
||||||
"../../../etc/passwd",
|
|
||||||
"..%2f..%2fetc/passwd",
|
|
||||||
".secret",
|
|
||||||
"../sibling-vault/secrets.d/target",
|
|
||||||
"foo/../bar",
|
|
||||||
"a/../../etc/passwd",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, name := range maliciousNames {
|
|
||||||
t.Run(name, func(t *testing.T) {
|
|
||||||
_, err := vlt.GetSecretVersion(name, "")
|
|
||||||
assert.Error(t, err, "GetSecretVersion should reject malicious name: %s", name)
|
|
||||||
assert.Contains(t, err.Error(), "invalid secret name",
|
|
||||||
"error should indicate invalid name for: %s", name)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestGetSecretRejectsPathTraversal verifies GetSecret (which calls GetSecretVersion)
|
|
||||||
// also rejects path traversal names.
|
|
||||||
func TestGetSecretRejectsPathTraversal(t *testing.T) {
|
|
||||||
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
|
||||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
|
||||||
t.Setenv(secret.EnvUnlockPassphrase, "test-passphrase")
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
|
||||||
stateDir := "/test/state"
|
|
||||||
|
|
||||||
vlt, err := CreateVault(fs, stateDir, "test-vault")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
_, err = vlt.GetSecret("../../../etc/passwd")
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "invalid secret name")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestGetSecretObjectRejectsPathTraversal verifies GetSecretObject
|
|
||||||
// also validates names and rejects path traversal attempts.
|
|
||||||
func TestGetSecretObjectRejectsPathTraversal(t *testing.T) {
|
|
||||||
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
|
||||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
|
||||||
t.Setenv(secret.EnvUnlockPassphrase, "test-passphrase")
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
|
||||||
stateDir := "/test/state"
|
|
||||||
|
|
||||||
vlt, err := CreateVault(fs, stateDir, "test-vault")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
maliciousNames := []string{
|
|
||||||
"../../../etc/passwd",
|
|
||||||
"foo/../bar",
|
|
||||||
"a/../../etc/passwd",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, name := range maliciousNames {
|
|
||||||
t.Run(name, func(t *testing.T) {
|
|
||||||
_, err := vlt.GetSecretObject(name)
|
|
||||||
assert.Error(t, err, "GetSecretObject should reject: %s", name)
|
|
||||||
assert.Contains(t, err.Error(), "invalid secret name")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -67,7 +67,7 @@ func (v *Vault) ListSecrets() ([]string, error) {
|
|||||||
return secrets, nil
|
return secrets, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// isValidSecretName validates secret names according to the format [a-zA-Z0-9\.\-\_\/]+
|
// isValidSecretName validates secret names according to the format [a-z0-9\.\-\_\/]+
|
||||||
// but with additional restrictions:
|
// but with additional restrictions:
|
||||||
// - No leading or trailing slashes
|
// - No leading or trailing slashes
|
||||||
// - No double slashes
|
// - No double slashes
|
||||||
@ -92,15 +92,8 @@ func isValidSecretName(name string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for path traversal via ".." components
|
|
||||||
for _, part := range strings.Split(name, "/") {
|
|
||||||
if part == ".." {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check the basic pattern
|
// Check the basic pattern
|
||||||
matched, _ := regexp.MatchString(`^[a-zA-Z0-9\.\-\_\/]+$`, name)
|
matched, _ := regexp.MatchString(`^[a-z0-9\.\-\_\/]+$`, name)
|
||||||
|
|
||||||
return matched
|
return matched
|
||||||
}
|
}
|
||||||
@ -326,13 +319,6 @@ func (v *Vault) GetSecretVersion(name string, version string) ([]byte, error) {
|
|||||||
slog.String("version", version),
|
slog.String("version", version),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Validate secret name to prevent path traversal
|
|
||||||
if !isValidSecretName(name) {
|
|
||||||
secret.Debug("Invalid secret name provided", "secret_name", name)
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("invalid secret name '%s': must match pattern [a-z0-9.\\-_/]+", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get vault directory
|
// Get vault directory
|
||||||
vaultDir, err := v.GetDirectory()
|
vaultDir, err := v.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -468,10 +454,6 @@ func (v *Vault) UnlockVault() (*age.X25519Identity, error) {
|
|||||||
|
|
||||||
// GetSecretObject retrieves a Secret object with metadata loaded from this vault
|
// GetSecretObject retrieves a Secret object with metadata loaded from this vault
|
||||||
func (v *Vault) GetSecretObject(name string) (*secret.Secret, error) {
|
func (v *Vault) GetSecretObject(name string) (*secret.Secret, error) {
|
||||||
if !isValidSecretName(name) {
|
|
||||||
return nil, fmt.Errorf("invalid secret name: %s", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// First check if the secret exists by checking for the metadata file
|
// First check if the secret exists by checking for the metadata file
|
||||||
vaultDir, err := v.GetDirectory()
|
vaultDir, err := v.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -1,42 +0,0 @@
|
|||||||
package vault
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestIsValidSecretNameUppercase(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
valid bool
|
|
||||||
}{
|
|
||||||
// Lowercase (existing behavior)
|
|
||||||
{"valid-name", true},
|
|
||||||
{"valid.name", true},
|
|
||||||
{"valid_name", true},
|
|
||||||
{"valid/path/name", true},
|
|
||||||
{"123valid", true},
|
|
||||||
|
|
||||||
// Uppercase (new behavior - issue #2)
|
|
||||||
{"Valid-Upper-Name", true},
|
|
||||||
{"2025-11-21-ber1app1-vaultik-test-bucket-AKI", true},
|
|
||||||
{"MixedCase/Path/Name", true},
|
|
||||||
{"ALLUPPERCASE", true},
|
|
||||||
{"ABC123", true},
|
|
||||||
|
|
||||||
// Still invalid
|
|
||||||
{"", false},
|
|
||||||
{"invalid name", false},
|
|
||||||
{"invalid@name", false},
|
|
||||||
{".dotstart", false},
|
|
||||||
{"/leading-slash", false},
|
|
||||||
{"trailing-slash/", false},
|
|
||||||
{"double//slash", false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
result := isValidSecretName(tt.name)
|
|
||||||
if result != tt.valid {
|
|
||||||
t.Errorf("isValidSecretName(%q) = %v, want %v", tt.name, result, tt.valid)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -213,9 +213,7 @@ func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
|
|||||||
return nil, fmt.Errorf("failed to check if metadata exists for unlocker %s: %w", file.Name(), err)
|
return nil, fmt.Errorf("failed to check if metadata exists for unlocker %s: %w", file.Name(), err)
|
||||||
}
|
}
|
||||||
if !exists {
|
if !exists {
|
||||||
secret.Warn("Skipping unlocker directory with missing metadata file", "directory", file.Name())
|
return nil, fmt.Errorf("unlocker directory %s is missing metadata file", file.Name())
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
|
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
|
||||||
|
|||||||
@ -243,57 +243,3 @@ func TestVaultOperations(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestListUnlockers_SkipsMissingMetadata(t *testing.T) {
|
|
||||||
// Set test environment variables
|
|
||||||
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
|
||||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
|
||||||
t.Setenv(secret.EnvUnlockPassphrase, "test-passphrase")
|
|
||||||
|
|
||||||
// Use in-memory filesystem
|
|
||||||
fs := afero.NewMemMapFs()
|
|
||||||
stateDir := "/test/state"
|
|
||||||
|
|
||||||
// Create vault
|
|
||||||
vlt, err := CreateVault(fs, stateDir, "test-vault")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create vault: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a passphrase unlocker so we have at least one valid unlocker
|
|
||||||
passphraseBuffer := memguard.NewBufferFromBytes([]byte("test-passphrase"))
|
|
||||||
defer passphraseBuffer.Destroy()
|
|
||||||
_, err = vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create passphrase unlocker: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a bogus unlocker directory with no metadata file
|
|
||||||
vaultDir, err := vlt.GetDirectory()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to get vault directory: %v", err)
|
|
||||||
}
|
|
||||||
bogusDir := filepath.Join(vaultDir, "unlockers.d", "bogus-no-metadata")
|
|
||||||
err = fs.MkdirAll(bogusDir, 0o700)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create bogus directory: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListUnlockers should succeed, skipping the bogus directory
|
|
||||||
unlockers, err := vlt.ListUnlockers()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ListUnlockers returned error when it should have skipped bad directory: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should still have the valid passphrase unlocker
|
|
||||||
if len(unlockers) == 0 {
|
|
||||||
t.Errorf("Expected at least one unlocker, got none")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify we only got the valid unlocker(s), not the bogus one
|
|
||||||
for _, u := range unlockers {
|
|
||||||
if u.Type == "" {
|
|
||||||
t.Errorf("Got unlocker with empty type, likely from bogus directory")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user