@enspirit/emb 0.15.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +162 -43
- package/dist/src/cli/abstract/BaseCommand.d.ts +1 -0
- package/dist/src/cli/abstract/BaseCommand.js +23 -4
- package/dist/src/cli/abstract/FlavouredCommand.d.ts +1 -0
- package/dist/src/cli/abstract/KubernetesCommand.d.ts +1 -0
- package/dist/src/cli/commands/components/logs.d.ts +2 -1
- package/dist/src/cli/commands/components/logs.js +21 -24
- package/dist/src/cli/commands/secrets/index.d.ts +14 -0
- package/dist/src/cli/commands/secrets/index.js +71 -0
- package/dist/src/cli/commands/secrets/providers.d.ts +12 -0
- package/dist/src/cli/commands/secrets/providers.js +50 -0
- package/dist/src/cli/commands/secrets/validate.d.ts +18 -0
- package/dist/src/cli/commands/secrets/validate.js +145 -0
- package/dist/src/cli/hooks/init.js +7 -1
- package/dist/src/config/index.d.ts +10 -1
- package/dist/src/config/index.js +28 -3
- package/dist/src/config/schema.d.ts +7 -4
- package/dist/src/config/schema.json +173 -9
- package/dist/src/context.d.ts +9 -0
- package/dist/src/context.js +19 -0
- package/dist/src/docker/compose/operations/ComposeLogsOperation.d.ts +21 -0
- package/dist/src/docker/compose/operations/ComposeLogsOperation.js +85 -0
- package/dist/src/docker/compose/operations/index.d.ts +1 -0
- package/dist/src/docker/compose/operations/index.js +1 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/monorepo/monorepo.js +13 -5
- package/dist/src/monorepo/operations/tasks/RunTasksOperation.d.ts +1 -1
- package/dist/src/monorepo/operations/tasks/RunTasksOperation.js +1 -1
- package/dist/src/monorepo/plugins/VaultPlugin.d.ts +46 -0
- package/dist/src/monorepo/plugins/VaultPlugin.js +91 -0
- package/dist/src/monorepo/plugins/index.d.ts +1 -0
- package/dist/src/monorepo/plugins/index.js +3 -0
- package/dist/src/secrets/SecretDiscovery.d.ts +46 -0
- package/dist/src/secrets/SecretDiscovery.js +82 -0
- package/dist/src/secrets/SecretManager.d.ts +52 -0
- package/dist/src/secrets/SecretManager.js +75 -0
- package/dist/src/secrets/SecretProvider.d.ts +45 -0
- package/dist/src/secrets/SecretProvider.js +38 -0
- package/dist/src/secrets/index.d.ts +3 -0
- package/dist/src/secrets/index.js +3 -0
- package/dist/src/secrets/providers/VaultOidcHelper.d.ts +39 -0
- package/dist/src/secrets/providers/VaultOidcHelper.js +226 -0
- package/dist/src/secrets/providers/VaultProvider.d.ts +74 -0
- package/dist/src/secrets/providers/VaultProvider.js +266 -0
- package/dist/src/secrets/providers/VaultTokenCache.d.ts +60 -0
- package/dist/src/secrets/providers/VaultTokenCache.js +188 -0
- package/dist/src/secrets/providers/index.d.ts +2 -0
- package/dist/src/secrets/providers/index.js +2 -0
- package/dist/src/types.d.ts +2 -0
- package/dist/src/utils/TemplateExpander.d.ts +13 -1
- package/dist/src/utils/TemplateExpander.js +68 -15
- package/oclif.manifest.json +454 -49
- package/package.json +8 -5
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/* eslint-disable n/no-unsupported-features/node-builtins -- fetch is stable in Node 20+ */
|
|
2
|
+
import { AbstractSecretProvider } from '../SecretProvider.js';
|
|
3
|
+
import { cacheToken, clearCachedToken, getCachedToken, } from './VaultTokenCache.js';
|
|
4
|
+
/**
|
|
5
|
+
* Error class for Vault-specific errors.
|
|
6
|
+
*/
|
|
7
|
+
export class VaultError extends Error {
|
|
8
|
+
code;
|
|
9
|
+
statusCode;
|
|
10
|
+
constructor(message, code, statusCode) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.code = code;
|
|
13
|
+
this.statusCode = statusCode;
|
|
14
|
+
this.name = 'VaultError';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* HashiCorp Vault secret provider.
|
|
19
|
+
* Supports KV v2 secrets engine.
|
|
20
|
+
*/
|
|
21
|
+
export class VaultProvider extends AbstractSecretProvider {
|
|
22
|
+
token = null;
|
|
23
|
+
async connect() {
|
|
24
|
+
const { auth, address, namespace } = this.config;
|
|
25
|
+
// Try to use cached token first (for methods that benefit from caching)
|
|
26
|
+
if (auth.method === 'oidc') {
|
|
27
|
+
const cached = await getCachedToken(address, namespace);
|
|
28
|
+
if (cached) {
|
|
29
|
+
this.token = cached.token;
|
|
30
|
+
try {
|
|
31
|
+
await this.verifyToken();
|
|
32
|
+
// Cached token is still valid
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// Cached token is invalid, clear it and proceed with fresh auth
|
|
37
|
+
await clearCachedToken(address, namespace);
|
|
38
|
+
this.token = null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
let authResult;
|
|
43
|
+
switch (auth.method) {
|
|
44
|
+
case 'approle': {
|
|
45
|
+
authResult = await this.loginAppRole(auth.roleId, auth.secretId);
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
case 'jwt': {
|
|
49
|
+
authResult = await this.loginJwt(auth.role, auth.jwt);
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
case 'kubernetes': {
|
|
53
|
+
authResult = await this.loginKubernetes(auth.role);
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
case 'oidc': {
|
|
57
|
+
authResult = await this.loginOidc(auth.role, auth.port);
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
case 'token': {
|
|
61
|
+
// For explicit tokens, we don't know the TTL - use a default
|
|
62
|
+
authResult = { token: auth.token, ttlSeconds: 3600 };
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
default: {
|
|
66
|
+
throw new VaultError(`Unsupported auth method: ${auth.method}`, 'VAULT_AUTH_ERROR');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
this.token = authResult.token;
|
|
70
|
+
// Verify the token works by looking it up
|
|
71
|
+
await this.verifyToken();
|
|
72
|
+
// Cache the token for methods that benefit from caching
|
|
73
|
+
if (auth.method === 'oidc' && authResult.ttlSeconds > 0) {
|
|
74
|
+
await cacheToken(address, authResult.token, authResult.ttlSeconds, namespace);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
async disconnect() {
|
|
78
|
+
this.token = null;
|
|
79
|
+
this.clearCache();
|
|
80
|
+
}
|
|
81
|
+
async fetchSecret(ref) {
|
|
82
|
+
if (!this.token) {
|
|
83
|
+
throw new VaultError('Not connected to Vault', 'VAULT_NOT_CONNECTED');
|
|
84
|
+
}
|
|
85
|
+
// For KV v2, the path needs 'data' inserted after the mount point
|
|
86
|
+
// e.g., "secret/myapp" becomes "secret/data/myapp"
|
|
87
|
+
const path = this.normalizeKvPath(ref.path);
|
|
88
|
+
const url = new URL(`/v1/${path}`, this.config.address);
|
|
89
|
+
if (ref.version) {
|
|
90
|
+
url.searchParams.set('version', ref.version);
|
|
91
|
+
}
|
|
92
|
+
const response = await fetch(url.toString(), {
|
|
93
|
+
method: 'GET',
|
|
94
|
+
headers: this.buildHeaders(),
|
|
95
|
+
});
|
|
96
|
+
if (!response.ok) {
|
|
97
|
+
const error = await this.parseErrorResponse(response);
|
|
98
|
+
const namespace = this.config.namespace
|
|
99
|
+
? ` (namespace: ${this.config.namespace})`
|
|
100
|
+
: '';
|
|
101
|
+
throw new VaultError(`Failed to read secret at '${ref.path}'${namespace}: ${error.message}`, 'VAULT_READ_ERROR', response.status);
|
|
102
|
+
}
|
|
103
|
+
const data = await response.json();
|
|
104
|
+
// KV v2 wraps the data in a 'data' field
|
|
105
|
+
return (data.data?.data ||
|
|
106
|
+
data.data ||
|
|
107
|
+
{});
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Normalize a path for the appropriate secrets engine.
|
|
111
|
+
* - KV v2: Insert '/data/' after the mount point
|
|
112
|
+
* - 1Password Connect: Use path as-is (contains /vaults/ and /items/)
|
|
113
|
+
* - Other engines: Use path as-is
|
|
114
|
+
*/
|
|
115
|
+
normalizeKvPath(path) {
|
|
116
|
+
// If path already contains '/data/', assume it's correctly formatted for KV v2
|
|
117
|
+
if (path.includes('/data/')) {
|
|
118
|
+
return path;
|
|
119
|
+
}
|
|
120
|
+
// 1Password Connect paths contain /vaults/ and /items/ - don't modify
|
|
121
|
+
if (path.includes('/vaults/') || path.includes('/items/')) {
|
|
122
|
+
return path;
|
|
123
|
+
}
|
|
124
|
+
// For KV v2, insert /data/ after the mount point
|
|
125
|
+
// Split by first '/' to get mount and rest of path
|
|
126
|
+
const firstSlash = path.indexOf('/');
|
|
127
|
+
if (firstSlash === -1) {
|
|
128
|
+
// Just a mount, no sub-path
|
|
129
|
+
return `${path}/data`;
|
|
130
|
+
}
|
|
131
|
+
const mount = path.slice(0, Math.max(0, firstSlash));
|
|
132
|
+
const subPath = path.slice(Math.max(0, firstSlash + 1));
|
|
133
|
+
return `${mount}/data/${subPath}`;
|
|
134
|
+
}
|
|
135
|
+
buildHeaders() {
|
|
136
|
+
const headers = {
|
|
137
|
+
'X-Vault-Token': this.token,
|
|
138
|
+
'Content-Type': 'application/json',
|
|
139
|
+
};
|
|
140
|
+
if (this.config.namespace) {
|
|
141
|
+
headers['X-Vault-Namespace'] = this.config.namespace;
|
|
142
|
+
}
|
|
143
|
+
return headers;
|
|
144
|
+
}
|
|
145
|
+
async loginAppRole(roleId, secretId) {
|
|
146
|
+
const url = new URL('/v1/auth/approle/login', this.config.address);
|
|
147
|
+
const response = await fetch(url.toString(), {
|
|
148
|
+
method: 'POST',
|
|
149
|
+
headers: {
|
|
150
|
+
'Content-Type': 'application/json',
|
|
151
|
+
...(this.config.namespace && {
|
|
152
|
+
'X-Vault-Namespace': this.config.namespace,
|
|
153
|
+
}),
|
|
154
|
+
},
|
|
155
|
+
// Vault API uses snake_case for these properties
|
|
156
|
+
// eslint-disable-next-line camelcase
|
|
157
|
+
body: JSON.stringify({ role_id: roleId, secret_id: secretId }),
|
|
158
|
+
});
|
|
159
|
+
if (!response.ok) {
|
|
160
|
+
const error = await this.parseErrorResponse(response);
|
|
161
|
+
throw new VaultError(`AppRole login failed: ${error.message}`, 'VAULT_AUTH_ERROR', response.status);
|
|
162
|
+
}
|
|
163
|
+
const data = (await response.json());
|
|
164
|
+
return {
|
|
165
|
+
token: data.auth?.client_token || '',
|
|
166
|
+
ttlSeconds: data.auth?.lease_duration || 3600,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
async loginKubernetes(role) {
|
|
170
|
+
// Read the service account token from the mounted file
|
|
171
|
+
const fs = await import('node:fs/promises');
|
|
172
|
+
const tokenPath = '/var/run/secrets/kubernetes.io/serviceaccount/token';
|
|
173
|
+
let jwt;
|
|
174
|
+
try {
|
|
175
|
+
jwt = await fs.readFile(tokenPath, 'utf8');
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
throw new VaultError(`Could not read Kubernetes service account token from ${tokenPath}`, 'VAULT_AUTH_ERROR');
|
|
179
|
+
}
|
|
180
|
+
const url = new URL('/v1/auth/kubernetes/login', this.config.address);
|
|
181
|
+
const response = await fetch(url.toString(), {
|
|
182
|
+
method: 'POST',
|
|
183
|
+
headers: {
|
|
184
|
+
'Content-Type': 'application/json',
|
|
185
|
+
...(this.config.namespace && {
|
|
186
|
+
'X-Vault-Namespace': this.config.namespace,
|
|
187
|
+
}),
|
|
188
|
+
},
|
|
189
|
+
body: JSON.stringify({ role, jwt }),
|
|
190
|
+
});
|
|
191
|
+
if (!response.ok) {
|
|
192
|
+
const error = await this.parseErrorResponse(response);
|
|
193
|
+
throw new VaultError(`Kubernetes login failed: ${error.message}`, 'VAULT_AUTH_ERROR', response.status);
|
|
194
|
+
}
|
|
195
|
+
const data = (await response.json());
|
|
196
|
+
return {
|
|
197
|
+
token: data.auth?.client_token || '',
|
|
198
|
+
ttlSeconds: data.auth?.lease_duration || 3600,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Authenticate using JWT (non-interactive).
|
|
203
|
+
* Suitable for CI/CD pipelines where a JWT is provided externally.
|
|
204
|
+
*/
|
|
205
|
+
async loginJwt(role, jwt) {
|
|
206
|
+
const url = new URL('/v1/auth/jwt/login', this.config.address);
|
|
207
|
+
const response = await fetch(url.toString(), {
|
|
208
|
+
method: 'POST',
|
|
209
|
+
headers: {
|
|
210
|
+
'Content-Type': 'application/json',
|
|
211
|
+
...(this.config.namespace && {
|
|
212
|
+
'X-Vault-Namespace': this.config.namespace,
|
|
213
|
+
}),
|
|
214
|
+
},
|
|
215
|
+
body: JSON.stringify({ role, jwt }),
|
|
216
|
+
});
|
|
217
|
+
if (!response.ok) {
|
|
218
|
+
const error = await this.parseErrorResponse(response);
|
|
219
|
+
throw new VaultError(`JWT login failed: ${error.message}`, 'VAULT_AUTH_ERROR', response.status);
|
|
220
|
+
}
|
|
221
|
+
const data = (await response.json());
|
|
222
|
+
return {
|
|
223
|
+
token: data.auth?.client_token || '',
|
|
224
|
+
ttlSeconds: data.auth?.lease_duration || 3600,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Authenticate using OIDC (interactive browser flow).
|
|
229
|
+
* Opens a browser for the user to authenticate with Keycloak/OIDC provider.
|
|
230
|
+
*/
|
|
231
|
+
async loginOidc(role, port) {
|
|
232
|
+
const { performOidcLogin } = await import('./VaultOidcHelper.js');
|
|
233
|
+
const result = await performOidcLogin({
|
|
234
|
+
vaultAddress: this.config.address,
|
|
235
|
+
role,
|
|
236
|
+
port: port ?? 8250,
|
|
237
|
+
namespace: this.config.namespace,
|
|
238
|
+
});
|
|
239
|
+
return {
|
|
240
|
+
token: result.token,
|
|
241
|
+
ttlSeconds: result.ttlSeconds,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
async verifyToken() {
|
|
245
|
+
const url = new URL('/v1/auth/token/lookup-self', this.config.address);
|
|
246
|
+
const response = await fetch(url.toString(), {
|
|
247
|
+
method: 'GET',
|
|
248
|
+
headers: this.buildHeaders(),
|
|
249
|
+
});
|
|
250
|
+
if (!response.ok) {
|
|
251
|
+
const error = await this.parseErrorResponse(response);
|
|
252
|
+
throw new VaultError(`Token verification failed: ${error.message}`, 'VAULT_AUTH_ERROR', response.status);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
async parseErrorResponse(response) {
|
|
256
|
+
try {
|
|
257
|
+
const data = (await response.json());
|
|
258
|
+
return {
|
|
259
|
+
message: data.errors?.join(', ') || `HTTP ${response.status}`,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
return { message: `HTTP ${response.status}: ${response.statusText}` };
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cached token metadata stored on disk.
|
|
3
|
+
*/
|
|
4
|
+
export interface CachedToken {
|
|
5
|
+
/** Unix timestamp (ms) when the token was cached */
|
|
6
|
+
createdAt: number;
|
|
7
|
+
/** Unix timestamp (ms) when the token expires */
|
|
8
|
+
expiresAt: number;
|
|
9
|
+
/** Vault namespace (if any) */
|
|
10
|
+
namespace?: string;
|
|
11
|
+
/** The Vault client token */
|
|
12
|
+
token: string;
|
|
13
|
+
/** Vault address this token is for */
|
|
14
|
+
vaultAddress: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Options for token caching.
|
|
18
|
+
*/
|
|
19
|
+
export interface TokenCacheOptions {
|
|
20
|
+
/** Custom cache directory (default: ~/.emb/vault-tokens) */
|
|
21
|
+
cacheDir?: string;
|
|
22
|
+
/** Buffer time in ms before expiry to consider token invalid (default: 5 minutes) */
|
|
23
|
+
expiryBuffer?: number;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Retrieve a cached token if it exists and is still valid.
|
|
27
|
+
*
|
|
28
|
+
* @param vaultAddress - The Vault server address
|
|
29
|
+
* @param namespace - Optional Vault namespace
|
|
30
|
+
* @param options - Cache options
|
|
31
|
+
* @returns The cached token or null if not found/expired
|
|
32
|
+
*/
|
|
33
|
+
export declare function getCachedToken(vaultAddress: string, namespace?: string, options?: TokenCacheOptions): Promise<CachedToken | null>;
|
|
34
|
+
/**
|
|
35
|
+
* Cache a Vault token to disk (encrypted).
|
|
36
|
+
*
|
|
37
|
+
* @param vaultAddress - The Vault server address
|
|
38
|
+
* @param token - The Vault client token
|
|
39
|
+
* @param ttlSeconds - Token TTL in seconds (from Vault's lease_duration)
|
|
40
|
+
* @param namespace - Optional Vault namespace
|
|
41
|
+
* @param options - Cache options
|
|
42
|
+
*/
|
|
43
|
+
export declare function cacheToken(vaultAddress: string, token: string, ttlSeconds: number, namespace?: string, options?: TokenCacheOptions): Promise<void>;
|
|
44
|
+
/**
|
|
45
|
+
* Clear a cached token.
|
|
46
|
+
*
|
|
47
|
+
* @param vaultAddress - The Vault server address
|
|
48
|
+
* @param namespace - Optional Vault namespace
|
|
49
|
+
* @param options - Cache options
|
|
50
|
+
*/
|
|
51
|
+
export declare function clearCachedToken(vaultAddress: string, namespace?: string, options?: TokenCacheOptions): Promise<void>;
|
|
52
|
+
/**
|
|
53
|
+
* Check if a cached token exists and is valid (without returning the token).
|
|
54
|
+
*
|
|
55
|
+
* @param vaultAddress - The Vault server address
|
|
56
|
+
* @param namespace - Optional Vault namespace
|
|
57
|
+
* @param options - Cache options
|
|
58
|
+
* @returns True if a valid cached token exists
|
|
59
|
+
*/
|
|
60
|
+
export declare function hasCachedToken(vaultAddress: string, namespace?: string, options?: TokenCacheOptions): Promise<boolean>;
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, pbkdf2Sync, randomBytes, } from 'node:crypto';
|
|
2
|
+
import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { homedir, hostname, userInfo } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
const DEFAULT_EXPIRY_BUFFER = 5 * 60 * 1000; // 5 minutes
|
|
6
|
+
const DEFAULT_CACHE_DIR = join(homedir(), '.emb', 'vault-tokens');
|
|
7
|
+
// Encryption constants
|
|
8
|
+
const ALGORITHM = 'aes-256-gcm';
|
|
9
|
+
const IV_LENGTH = 16;
|
|
10
|
+
const _AUTH_TAG_LENGTH = 16;
|
|
11
|
+
const SALT_LENGTH = 32;
|
|
12
|
+
const KEY_LENGTH = 32;
|
|
13
|
+
const PBKDF2_ITERATIONS = 100_000;
|
|
14
|
+
/**
|
|
15
|
+
* Derive an encryption key from machine-specific data.
|
|
16
|
+
* The key is derived from hostname + username + a static pepper,
|
|
17
|
+
* making the cache file unusable if copied to another machine or user.
|
|
18
|
+
*/
|
|
19
|
+
function deriveKey(salt) {
|
|
20
|
+
const machineId = `${hostname()}:${userInfo().username}:emb-vault-cache`;
|
|
21
|
+
return pbkdf2Sync(machineId, salt, PBKDF2_ITERATIONS, KEY_LENGTH, 'sha256');
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Encrypt data using AES-256-GCM.
|
|
25
|
+
*/
|
|
26
|
+
function encrypt(data) {
|
|
27
|
+
const salt = randomBytes(SALT_LENGTH);
|
|
28
|
+
const key = deriveKey(salt);
|
|
29
|
+
const iv = randomBytes(IV_LENGTH);
|
|
30
|
+
const cipher = createCipheriv(ALGORITHM, key, iv);
|
|
31
|
+
const encrypted = Buffer.concat([
|
|
32
|
+
cipher.update(data, 'utf8'),
|
|
33
|
+
cipher.final(),
|
|
34
|
+
]);
|
|
35
|
+
const authTag = cipher.getAuthTag();
|
|
36
|
+
return {
|
|
37
|
+
version: 1,
|
|
38
|
+
salt: salt.toString('hex'),
|
|
39
|
+
iv: iv.toString('hex'),
|
|
40
|
+
authTag: authTag.toString('hex'),
|
|
41
|
+
encrypted: encrypted.toString('hex'),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Decrypt data using AES-256-GCM.
|
|
46
|
+
* Returns null if decryption fails (wrong machine, corrupted data, etc.)
|
|
47
|
+
*/
|
|
48
|
+
function decrypt(file) {
|
|
49
|
+
try {
|
|
50
|
+
if (file.version !== 1) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
const salt = Buffer.from(file.salt, 'hex');
|
|
54
|
+
const key = deriveKey(salt);
|
|
55
|
+
const iv = Buffer.from(file.iv, 'hex');
|
|
56
|
+
const authTag = Buffer.from(file.authTag, 'hex');
|
|
57
|
+
const encrypted = Buffer.from(file.encrypted, 'hex');
|
|
58
|
+
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
|
59
|
+
decipher.setAuthTag(authTag);
|
|
60
|
+
const decrypted = Buffer.concat([
|
|
61
|
+
decipher.update(encrypted),
|
|
62
|
+
decipher.final(),
|
|
63
|
+
]);
|
|
64
|
+
return decrypted.toString('utf8');
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// Decryption failed - wrong key (different machine/user) or corrupted data
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Generate a cache key for a Vault address and namespace combination.
|
|
73
|
+
* Uses a hash to create safe filenames.
|
|
74
|
+
*/
|
|
75
|
+
function getCacheKey(vaultAddress, namespace) {
|
|
76
|
+
const input = namespace ? `${vaultAddress}::${namespace}` : vaultAddress;
|
|
77
|
+
return createHash('sha256').update(input).digest('hex').slice(0, 16);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Get the path to the cache file for a given Vault address.
|
|
81
|
+
*/
|
|
82
|
+
function getCachePath(vaultAddress, namespace, cacheDir = DEFAULT_CACHE_DIR) {
|
|
83
|
+
const key = getCacheKey(vaultAddress, namespace);
|
|
84
|
+
return join(cacheDir, `${key}.json`);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Retrieve a cached token if it exists and is still valid.
|
|
88
|
+
*
|
|
89
|
+
* @param vaultAddress - The Vault server address
|
|
90
|
+
* @param namespace - Optional Vault namespace
|
|
91
|
+
* @param options - Cache options
|
|
92
|
+
* @returns The cached token or null if not found/expired
|
|
93
|
+
*/
|
|
94
|
+
export async function getCachedToken(vaultAddress, namespace, options = {}) {
|
|
95
|
+
const { expiryBuffer = DEFAULT_EXPIRY_BUFFER, cacheDir } = options;
|
|
96
|
+
const cachePath = getCachePath(vaultAddress, namespace, cacheDir);
|
|
97
|
+
try {
|
|
98
|
+
const content = await readFile(cachePath, 'utf8');
|
|
99
|
+
const encryptedFile = JSON.parse(content);
|
|
100
|
+
// Decrypt the cached data
|
|
101
|
+
const decrypted = decrypt(encryptedFile);
|
|
102
|
+
if (!decrypted) {
|
|
103
|
+
// Decryption failed - likely different machine/user or corrupted
|
|
104
|
+
await clearCachedToken(vaultAddress, namespace, options);
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
const cached = JSON.parse(decrypted);
|
|
108
|
+
// Verify the cached token matches the requested address/namespace
|
|
109
|
+
if (cached.vaultAddress !== vaultAddress ||
|
|
110
|
+
cached.namespace !== namespace) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
// Check if token is expired or close to expiry
|
|
114
|
+
const now = Date.now();
|
|
115
|
+
if (cached.expiresAt - expiryBuffer <= now) {
|
|
116
|
+
// Token is expired or about to expire, clear it
|
|
117
|
+
await clearCachedToken(vaultAddress, namespace, options);
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
return cached;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// File doesn't exist or is invalid
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Cache a Vault token to disk (encrypted).
|
|
129
|
+
*
|
|
130
|
+
* @param vaultAddress - The Vault server address
|
|
131
|
+
* @param token - The Vault client token
|
|
132
|
+
* @param ttlSeconds - Token TTL in seconds (from Vault's lease_duration)
|
|
133
|
+
* @param namespace - Optional Vault namespace
|
|
134
|
+
* @param options - Cache options
|
|
135
|
+
*/
|
|
136
|
+
// eslint-disable-next-line max-params
|
|
137
|
+
export async function cacheToken(vaultAddress, token, ttlSeconds, namespace, options = {}) {
|
|
138
|
+
const { cacheDir = DEFAULT_CACHE_DIR } = options;
|
|
139
|
+
const cachePath = getCachePath(vaultAddress, namespace, cacheDir);
|
|
140
|
+
const now = Date.now();
|
|
141
|
+
const cached = {
|
|
142
|
+
token,
|
|
143
|
+
expiresAt: now + ttlSeconds * 1000,
|
|
144
|
+
createdAt: now,
|
|
145
|
+
namespace,
|
|
146
|
+
vaultAddress,
|
|
147
|
+
};
|
|
148
|
+
// Encrypt the token data
|
|
149
|
+
const encryptedFile = encrypt(JSON.stringify(cached));
|
|
150
|
+
// Ensure cache directory exists
|
|
151
|
+
await mkdir(cacheDir, { recursive: true, mode: 0o700 });
|
|
152
|
+
// Write the encrypted cache file with restricted permissions
|
|
153
|
+
await writeFile(cachePath, JSON.stringify(encryptedFile, null, 2), {
|
|
154
|
+
mode: 0o600,
|
|
155
|
+
encoding: 'utf8',
|
|
156
|
+
});
|
|
157
|
+
// Ensure permissions are correct (writeFile mode may not work on all platforms)
|
|
158
|
+
await chmod(cachePath, 0o600);
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Clear a cached token.
|
|
162
|
+
*
|
|
163
|
+
* @param vaultAddress - The Vault server address
|
|
164
|
+
* @param namespace - Optional Vault namespace
|
|
165
|
+
* @param options - Cache options
|
|
166
|
+
*/
|
|
167
|
+
export async function clearCachedToken(vaultAddress, namespace, options = {}) {
|
|
168
|
+
const { cacheDir } = options;
|
|
169
|
+
const cachePath = getCachePath(vaultAddress, namespace, cacheDir);
|
|
170
|
+
try {
|
|
171
|
+
await rm(cachePath);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
// Ignore errors if file doesn't exist
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Check if a cached token exists and is valid (without returning the token).
|
|
179
|
+
*
|
|
180
|
+
* @param vaultAddress - The Vault server address
|
|
181
|
+
* @param namespace - Optional Vault namespace
|
|
182
|
+
* @param options - Cache options
|
|
183
|
+
* @returns True if a valid cached token exists
|
|
184
|
+
*/
|
|
185
|
+
export async function hasCachedToken(vaultAddress, namespace, options = {}) {
|
|
186
|
+
const cached = await getCachedToken(vaultAddress, namespace, options);
|
|
187
|
+
return cached !== null;
|
|
188
|
+
}
|
package/dist/src/types.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type Docker from 'dockerode';
|
|
2
2
|
import { AppsV1Api, CoreV1Api, KubeConfig } from '@kubernetes/client-node';
|
|
3
3
|
import { Monorepo } from './monorepo/index.js';
|
|
4
|
+
import { SecretManager } from './secrets/index.js';
|
|
4
5
|
import { DockerComposeClient } from './docker/index.js';
|
|
5
6
|
/**
|
|
6
7
|
* The context is meant to be what all plugins can decorate
|
|
@@ -18,4 +19,5 @@ export interface EmbContext {
|
|
|
18
19
|
core: CoreV1Api;
|
|
19
20
|
};
|
|
20
21
|
monorepo: Monorepo;
|
|
22
|
+
secrets: SecretManager;
|
|
21
23
|
}
|
|
@@ -1,6 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An async source is a function that returns a promise resolving to the value.
|
|
3
|
+
*/
|
|
4
|
+
type AsyncSource = (key: string) => Promise<unknown>;
|
|
5
|
+
/**
|
|
6
|
+
* A static source is a simple key-value record.
|
|
7
|
+
*/
|
|
8
|
+
type StaticSource = Record<string, unknown>;
|
|
9
|
+
/**
|
|
10
|
+
* A source can be either static or async.
|
|
11
|
+
*/
|
|
12
|
+
type Source = AsyncSource | StaticSource;
|
|
1
13
|
type ExpandOptions = {
|
|
2
14
|
default?: string;
|
|
3
|
-
sources?: Record<string,
|
|
15
|
+
sources?: Record<string, Source>;
|
|
4
16
|
};
|
|
5
17
|
export type ExpansionHistory = {
|
|
6
18
|
source: string;
|
|
@@ -1,31 +1,84 @@
|
|
|
1
|
-
|
|
1
|
+
// Matches ${source:key} or ${key} patterns
|
|
2
|
+
// - Source name: word characters only (e.g., "vault", "env")
|
|
3
|
+
// - Key: word characters, slashes, hashes, dots, with hyphens allowed between segments
|
|
4
|
+
// (e.g., "secret/path#field", "MY_VAR", "secret/my-app#key")
|
|
5
|
+
// - Optional fallback: :-value
|
|
6
|
+
// The key pattern uses (?:-[\w/#.]+)* to allow hyphens only between valid segments,
|
|
7
|
+
// preventing the key from consuming the :- fallback delimiter.
|
|
8
|
+
const TPL_REGEX = /(?<!\\)\${(?:(\w+):)?([\w/#.]+(?:-[\w/#.]+)*)(?::-(.*?))?}/g;
|
|
9
|
+
/**
|
|
10
|
+
* Check if a source is async (a function).
|
|
11
|
+
*/
|
|
12
|
+
function isAsyncSource(source) {
|
|
13
|
+
return typeof source === 'function';
|
|
14
|
+
}
|
|
2
15
|
export class TemplateExpander {
|
|
3
16
|
expansions = [];
|
|
4
17
|
get expansionCount() {
|
|
5
18
|
return this.expansions.length;
|
|
6
19
|
}
|
|
7
20
|
async expand(str, options = {}) {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
21
|
+
const input = (str || '').toString();
|
|
22
|
+
// Collect all matches with their positions
|
|
23
|
+
const matches = [...input.matchAll(TPL_REGEX)];
|
|
24
|
+
if (matches.length === 0) {
|
|
25
|
+
return input.replaceAll('\\${', '${');
|
|
26
|
+
}
|
|
27
|
+
// Resolve all values (async for function sources, sync for objects)
|
|
28
|
+
const resolutions = await Promise.all(matches.map(async (match) => {
|
|
29
|
+
const [fullMatch, sourceName, key, fallback] = match;
|
|
30
|
+
const src = sourceName ?? options.default ?? '';
|
|
31
|
+
const source = options.sources?.[src];
|
|
32
|
+
// No source found
|
|
33
|
+
if (!source) {
|
|
14
34
|
if (fallback !== undefined) {
|
|
15
|
-
return
|
|
35
|
+
return {
|
|
36
|
+
match: fullMatch,
|
|
37
|
+
value: this.track(src, key, fallback),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
throw new Error(`Invalid expand provider '${sourceName}' ('${fullMatch}')`);
|
|
41
|
+
}
|
|
42
|
+
// Resolve value based on source type
|
|
43
|
+
let val;
|
|
44
|
+
if (isAsyncSource(source)) {
|
|
45
|
+
try {
|
|
46
|
+
val = await source(key);
|
|
16
47
|
}
|
|
17
|
-
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (fallback !== undefined) {
|
|
50
|
+
return {
|
|
51
|
+
match: fullMatch,
|
|
52
|
+
value: this.track(src, key, fallback),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
val = source[key];
|
|
18
60
|
}
|
|
19
|
-
|
|
61
|
+
// Handle missing values
|
|
20
62
|
if (!val && fallback === undefined) {
|
|
21
|
-
throw new Error(`Could not expand '${
|
|
63
|
+
throw new Error(`Could not expand '${fullMatch}' and no default value provided`);
|
|
22
64
|
}
|
|
23
65
|
if (val !== undefined && val !== null) {
|
|
24
|
-
return
|
|
66
|
+
return {
|
|
67
|
+
match: fullMatch,
|
|
68
|
+
value: this.track(src, key, val),
|
|
69
|
+
};
|
|
25
70
|
}
|
|
26
|
-
return
|
|
27
|
-
|
|
28
|
-
|
|
71
|
+
return {
|
|
72
|
+
match: fullMatch,
|
|
73
|
+
value: this.track(src, key, fallback ?? ''),
|
|
74
|
+
};
|
|
75
|
+
}));
|
|
76
|
+
// Build result string by replacing matches with resolved values
|
|
77
|
+
let result = input;
|
|
78
|
+
for (const { match, value } of resolutions) {
|
|
79
|
+
result = result.replace(match, value);
|
|
80
|
+
}
|
|
81
|
+
return result.replaceAll('\\${', '${');
|
|
29
82
|
}
|
|
30
83
|
async expandRecord(record, options) {
|
|
31
84
|
if (typeof record === 'string') {
|