Skip to content

Commit a4bce50

Browse files
committed
more permissive validation of secret names
1 parent 5e06e84 commit a4bce50

12 files changed

Lines changed: 367 additions & 43 deletions

File tree

docs/guide/installation.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ Then run Keep commands using:
3232
./vendor/bin/keep [command]
3333
```
3434

35+
You might enjoy having an alias in your shell profile to make it easier:
36+
37+
```bash
38+
alias keep="./vendor/bin/keep"
39+
```
40+
3541
## Laravel Integration (Optional)
3642

3743
If you're using Keep with a Laravel application, you can publish the configuration and set up the service provider:

docs/index.md

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,20 +15,20 @@ hero:
1515

1616
features:
1717
- title: Multi-Vault Support
18-
details: Support for local files, AWS SSM, and AWS Secrets Manager with consistent interface across all providers.
18+
details: Support for local files, AWS SSM, and AWS Secrets Manager with more to come.
1919

2020
- title: Stage Management
2121
details: Organize secrets by environment stages (development, staging, production) with easy promotion between stages.
2222

2323
- title: Template System
2424
details: Generate .env files and configuration from templates with placeholder replacement and validation.
2525

26-
- title: Laravel Integration
27-
details: Seamless integration with Laravel applications including helper functions and service provider.
28-
2926
- title: CLI First
3027
details: Powerful command-line interface for all operations with support for CI/CD workflows and automation.
3128

29+
- title: Laravel Integration
30+
details: Seamless integration with Laravel applications including helper functions and service provider.
31+
3232
- title: Security Focused
3333
details: Encrypted local storage, secure AWS integration, and careful handling of sensitive data throughout.
3434
---
@@ -39,14 +39,26 @@ features:
3939
# Configure your project
4040
keep configure
4141

42-
# Add a vault
43-
keep vault:add local myapp
42+
# Interactively set up and configure a new vault
43+
keep vault:add
44+
45+
# Check your vault permissions across all stages
46+
keep verify
47+
48+
# Set a secret for production stage
49+
keep set --stage=production DB_PASSWORD "super-secret"
50+
51+
# List all secrets in staging
52+
keep list --stage=staging --unmask
53+
54+
# Compare stages to see which secrets are defined and differ from each other
55+
keep diff
4456

45-
# Set a secret
46-
keep set myapp:development DB_PASSWORD "super-secret"
57+
# Export all secrets to .env file
58+
keep export --stage=production > .env
4759

48-
# Export to .env file
49-
keep export myapp:development --format=env > .env
60+
# Merge secrets into a template file with placeholders
61+
keep merge .env.template --stage=production > .env
5062
```
5163

5264
## Why Keep?

src/Commands/ImportCommand.php

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,14 @@ protected function runImport(SecretCollection $importSecrets, SecretCollection $
100100

101101
if (empty($secret->value())) {
102102
$this->warn("Skipping key [{$secret->key()}] with empty value.");
103+
continue;
104+
}
103105

106+
// Validate key before importing to ensure it meets Keep's standards
107+
try {
108+
$this->validateUserKey($secret->key());
109+
} catch (\InvalidArgumentException $e) {
110+
$this->error("Skipping invalid key [{$secret->key()}]: " . $e->getMessage());
104111
continue;
105112
}
106113

@@ -148,4 +155,35 @@ protected function resultsTable(SecretCollection $importSecrets, SecretCollectio
148155

149156
return $rows;
150157
}
158+
159+
/**
160+
* Validate a user-provided key for safe vault operations.
161+
* More permissive than .env requirements to support various use cases.
162+
*/
163+
protected function validateUserKey(string $key): void
164+
{
165+
$trimmed = trim($key);
166+
167+
// Allow letters, digits, underscores, and hyphens (common in cloud services)
168+
if (! preg_match('/^[A-Za-z0-9_-]+$/', $trimmed)) {
169+
throw new \InvalidArgumentException(
170+
"Secret key '{$key}' contains invalid characters. ".
171+
'Only letters, numbers, underscores, and hyphens are allowed.'
172+
);
173+
}
174+
175+
// Length validation (reasonable limits for secret names)
176+
if (strlen($trimmed) < 1 || strlen($trimmed) > 255) {
177+
throw new \InvalidArgumentException(
178+
"Secret key '{$key}' must be 1-255 characters long."
179+
);
180+
}
181+
182+
// Cannot start with hyphen (could be interpreted as command flag)
183+
if (str_starts_with($trimmed, '-')) {
184+
throw new \InvalidArgumentException(
185+
"Secret key '{$key}' cannot start with hyphen."
186+
);
187+
}
188+
}
151189
}

src/Commands/SetCommand.php

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,12 @@ class SetCommand extends BaseCommand
1515

1616
public function process()
1717
{
18+
// Validate key using strict user input validation
19+
$key = $this->key();
20+
$this->validateUserKey($key);
21+
1822
$context = $this->context();
19-
$secret = $context->createVault()->set($this->key(), $this->value(), $this->secure());
23+
$secret = $context->createVault()->set($key, $this->value(), $this->secure());
2024

2125
$this->info(
2226
sprintf('Secret [%s] %s in vault [%s].',
@@ -26,4 +30,35 @@ public function process()
2630
)
2731
);
2832
}
33+
34+
/**
35+
* Validate a user-provided key for safe vault operations.
36+
* More permissive than .env requirements to support various use cases.
37+
*/
38+
protected function validateUserKey(string $key): void
39+
{
40+
$trimmed = trim($key);
41+
42+
// Allow letters, digits, underscores, and hyphens (common in cloud services)
43+
if (! preg_match('/^[A-Za-z0-9_-]+$/', $trimmed)) {
44+
throw new \InvalidArgumentException(
45+
"Secret key '{$key}' contains invalid characters. ".
46+
'Only letters, numbers, underscores, and hyphens are allowed.'
47+
);
48+
}
49+
50+
// Length validation (reasonable limits for secret names)
51+
if (strlen($trimmed) < 1 || strlen($trimmed) > 255) {
52+
throw new \InvalidArgumentException(
53+
"Secret key '{$key}' must be 1-255 characters long."
54+
);
55+
}
56+
57+
// Cannot start with hyphen (could be interpreted as command flag)
58+
if (str_starts_with($trimmed, '-')) {
59+
throw new \InvalidArgumentException(
60+
"Secret key '{$key}' cannot start with hyphen."
61+
);
62+
}
63+
}
2964
}

src/Data/Collections/SecretCollection.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ public function toKeyValuePair(): static
1818

1919
public function toEnvString()
2020
{
21-
return $this->map(fn (Secret $secret) => $secret->key().'='.$this->formatEnvValue($secret->value())
21+
return $this->map(fn (Secret $secret) => $secret->sanitizedKey().'='.$this->formatEnvValue($secret->value())
2222
)->implode(PHP_EOL);
2323
}
2424

src/Data/Env.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ public function list(): Collection
5151
public function secrets(): SecretCollection
5252
{
5353
$secrets = $this->entries()->map(function ($entry) {
54-
return new Secret(
54+
return Secret::fromVault(
5555
key: $entry->getName(),
5656
value: $entry->getValue()->get()->getChars(),
5757
);

src/Data/Secret.php

Lines changed: 93 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,15 @@ public function __construct(
2222
protected ?int $revision = 0,
2323
protected ?string $path = null,
2424
protected ?AbstractVault $vault = null,
25+
protected bool $skipValidation = false,
2526
) {
26-
$this->key = $this->validateKey($key);
27+
$this->key = $skipValidation ? trim($key) : $this->validateKey($key);
2728
}
2829

2930
/**
30-
* Validate a secret key using strict whitelist validation.
31-
* Only allows letters, digits, and underscores (standard .env format).
31+
* Validate a secret key for safe vault operations.
32+
* Allows common naming conventions (letters, digits, underscores, hyphens)
33+
* while preventing characters that could break vault API calls.
3234
*
3335
* @param string $key The raw key to validate
3436
* @return string The validated key
@@ -39,11 +41,11 @@ protected function validateKey(string $key): string
3941
{
4042
$trimmed = trim($key);
4143

42-
// Strict whitelist: Only allow letters, digits, and underscores
43-
if (! preg_match('/^[A-Za-z0-9_]+$/', $trimmed)) {
44+
// Allow letters, digits, underscores, and hyphens (common in cloud services)
45+
if (! preg_match('/^[A-Za-z0-9_-]+$/', $trimmed)) {
4446
throw new \InvalidArgumentException(
4547
"Secret key '{$key}' contains invalid characters. ".
46-
'Only letters, numbers, and underscores are allowed.'
48+
'Only letters, numbers, underscores, and hyphens are allowed.'
4749
);
4850
}
4951

@@ -54,28 +56,103 @@ protected function validateKey(string $key): string
5456
);
5557
}
5658

57-
// Cannot start with underscore (poor practice, could conflict with system vars)
58-
if (str_starts_with($trimmed, '_')) {
59+
// Cannot start with hyphen (could be interpreted as command flag)
60+
if (str_starts_with($trimmed, '-')) {
5961
throw new \InvalidArgumentException(
60-
"Secret key '{$key}' cannot start with underscore."
61-
);
62-
}
63-
64-
// Cannot start with digit (invalid variable name in most languages)
65-
if (preg_match('/^[0-9]/', $trimmed)) {
66-
throw new \InvalidArgumentException(
67-
"Secret key '{$key}' cannot start with a number."
62+
"Secret key '{$key}' cannot start with hyphen."
6863
);
6964
}
7065

7166
return $trimmed;
7267
}
7368

69+
/**
70+
* Create a Secret from vault data (permissive key validation).
71+
* Use this when reading existing secrets from external sources.
72+
*/
73+
public static function fromVault(
74+
string $key,
75+
?string $value = null,
76+
?string $encryptedValue = null,
77+
bool $secure = true,
78+
?string $stage = null,
79+
?int $revision = 0,
80+
?string $path = null,
81+
?AbstractVault $vault = null,
82+
): static {
83+
return new static(
84+
key: $key,
85+
value: $value,
86+
encryptedValue: $encryptedValue,
87+
secure: $secure,
88+
stage: $stage,
89+
revision: $revision,
90+
path: $path,
91+
vault: $vault,
92+
skipValidation: true,
93+
);
94+
}
95+
96+
/**
97+
* Create a Secret from user input (strict key validation).
98+
* Use this when accepting user-provided secret names.
99+
*/
100+
public static function fromUser(
101+
string $key,
102+
?string $value = null,
103+
?string $encryptedValue = null,
104+
bool $secure = true,
105+
?string $stage = null,
106+
?int $revision = 0,
107+
?string $path = null,
108+
?AbstractVault $vault = null,
109+
): static {
110+
return new static(
111+
key: $key,
112+
value: $value,
113+
encryptedValue: $encryptedValue,
114+
secure: $secure,
115+
stage: $stage,
116+
revision: $revision,
117+
path: $path,
118+
vault: $vault,
119+
skipValidation: false,
120+
);
121+
}
122+
74123
public function key()
75124
{
76125
return $this->key;
77126
}
78127

128+
/**
129+
* Get a sanitized version of the key safe for .env files.
130+
* Converts non-alphanumeric characters to underscores and ensures valid .env format.
131+
*/
132+
public function sanitizedKey(): string
133+
{
134+
$sanitized = $this->key;
135+
136+
// Replace invalid characters with underscores
137+
$sanitized = preg_replace('/[^A-Za-z0-9_]/', '_', $sanitized);
138+
139+
// Remove leading underscores
140+
$sanitized = ltrim($sanitized, '_');
141+
142+
// Remove leading digits by prefixing with 'KEY_'
143+
if (preg_match('/^[0-9]/', $sanitized)) {
144+
$sanitized = 'KEY_' . $sanitized;
145+
}
146+
147+
// Handle empty string case
148+
if (empty($sanitized)) {
149+
$sanitized = 'UNNAMED_KEY';
150+
}
151+
152+
// Convert to uppercase (common .env convention)
153+
return strtoupper($sanitized);
154+
}
155+
79156
public function value(): ?string
80157
{
81158
return $this->value;

src/Vaults/AwsSecretsManagerVault.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ public function list(): SecretCollection
111111
$value = $secretValue->get('SecretString');
112112
$isSecure = true; // AWS Secrets Manager always encrypts
113113

114-
$secrets->push(new Secret(
114+
$secrets->push(Secret::fromVault(
115115
key: $key,
116116
value: $value,
117117
encryptedValue: null,
@@ -164,7 +164,7 @@ public function get(string $key): Secret
164164
throw new SecretNotFoundException("Secret not found [{$this->format($key)}]");
165165
}
166166

167-
return new Secret(
167+
return Secret::fromVault(
168168
key: $key,
169169
value: $value,
170170
encryptedValue: null,

src/Vaults/AwsSsmVault.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ public function list(): SecretCollection
9090
->trim('/')
9191
->toString();
9292

93-
$secrets->push(new Secret(
93+
$secrets->push(Secret::fromVault(
9494
key: $key,
9595
value: $parameter['Value'] ?? null,
9696
encryptedValue: null,
@@ -137,7 +137,7 @@ public function get(string $key): Secret
137137
throw ExceptionFactory::secretNotFound($key, $this->name());
138138
}
139139

140-
return new Secret(
140+
return Secret::fromVault(
141141
key: $key,
142142
value: $parameter['Value'] ?? null,
143143
encryptedValue: null,

tests/Support/TestVault.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ public function set(string $key, string $value, bool $secure = true): Secret
7272
$secrets = $this->getVaultStageSecrets();
7373
$revision = isset($secrets[$path]) ? $secrets[$path]->revision() + 1 : 1;
7474

75-
$secret = new Secret($key, $value, null, $secure, $this->stage, $revision, $path, $this);
75+
$secret = Secret::fromUser($key, $value, null, $secure, $this->stage, $revision, $path, $this);
7676
$this->setVaultStageSecret($path, $secret);
7777

7878
// Add to history

0 commit comments

Comments
 (0)