@enspirit/emb 0.15.0 → 0.17.5

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.
Files changed (57) hide show
  1. package/README.md +218 -43
  2. package/bin/release +122 -0
  3. package/dist/src/cli/abstract/BaseCommand.d.ts +1 -0
  4. package/dist/src/cli/abstract/BaseCommand.js +23 -4
  5. package/dist/src/cli/abstract/FlavouredCommand.d.ts +1 -0
  6. package/dist/src/cli/abstract/KubernetesCommand.d.ts +1 -0
  7. package/dist/src/cli/commands/components/logs.d.ts +2 -1
  8. package/dist/src/cli/commands/components/logs.js +21 -24
  9. package/dist/src/cli/commands/secrets/index.d.ts +14 -0
  10. package/dist/src/cli/commands/secrets/index.js +71 -0
  11. package/dist/src/cli/commands/secrets/providers.d.ts +12 -0
  12. package/dist/src/cli/commands/secrets/providers.js +50 -0
  13. package/dist/src/cli/commands/secrets/validate.d.ts +18 -0
  14. package/dist/src/cli/commands/secrets/validate.js +145 -0
  15. package/dist/src/cli/commands/tasks/run.js +6 -1
  16. package/dist/src/cli/hooks/init.js +7 -1
  17. package/dist/src/config/index.d.ts +10 -1
  18. package/dist/src/config/index.js +28 -3
  19. package/dist/src/config/schema.d.ts +7 -4
  20. package/dist/src/config/schema.json +173 -9
  21. package/dist/src/context.d.ts +9 -0
  22. package/dist/src/context.js +19 -0
  23. package/dist/src/docker/compose/operations/ComposeLogsOperation.d.ts +21 -0
  24. package/dist/src/docker/compose/operations/ComposeLogsOperation.js +85 -0
  25. package/dist/src/docker/compose/operations/index.d.ts +1 -0
  26. package/dist/src/docker/compose/operations/index.js +1 -0
  27. package/dist/src/index.d.ts +1 -0
  28. package/dist/src/index.js +1 -0
  29. package/dist/src/monorepo/monorepo.js +13 -5
  30. package/dist/src/monorepo/operations/shell/ExecuteLocalCommandOperation.js +40 -10
  31. package/dist/src/monorepo/operations/tasks/RunTasksOperation.d.ts +1 -1
  32. package/dist/src/monorepo/operations/tasks/RunTasksOperation.js +1 -1
  33. package/dist/src/monorepo/plugins/VaultPlugin.d.ts +46 -0
  34. package/dist/src/monorepo/plugins/VaultPlugin.js +91 -0
  35. package/dist/src/monorepo/plugins/index.d.ts +1 -0
  36. package/dist/src/monorepo/plugins/index.js +3 -0
  37. package/dist/src/secrets/SecretDiscovery.d.ts +46 -0
  38. package/dist/src/secrets/SecretDiscovery.js +82 -0
  39. package/dist/src/secrets/SecretManager.d.ts +52 -0
  40. package/dist/src/secrets/SecretManager.js +75 -0
  41. package/dist/src/secrets/SecretProvider.d.ts +45 -0
  42. package/dist/src/secrets/SecretProvider.js +38 -0
  43. package/dist/src/secrets/index.d.ts +3 -0
  44. package/dist/src/secrets/index.js +3 -0
  45. package/dist/src/secrets/providers/VaultOidcHelper.d.ts +39 -0
  46. package/dist/src/secrets/providers/VaultOidcHelper.js +226 -0
  47. package/dist/src/secrets/providers/VaultProvider.d.ts +74 -0
  48. package/dist/src/secrets/providers/VaultProvider.js +266 -0
  49. package/dist/src/secrets/providers/VaultTokenCache.d.ts +60 -0
  50. package/dist/src/secrets/providers/VaultTokenCache.js +188 -0
  51. package/dist/src/secrets/providers/index.d.ts +2 -0
  52. package/dist/src/secrets/providers/index.js +2 -0
  53. package/dist/src/types.d.ts +2 -0
  54. package/dist/src/utils/TemplateExpander.d.ts +13 -1
  55. package/dist/src/utils/TemplateExpander.js +68 -15
  56. package/oclif.manifest.json +578 -173
  57. package/package.json +12 -5
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ export * from './VaultProvider.js';
2
+ export * from './VaultTokenCache.js';
@@ -0,0 +1,2 @@
1
+ export * from './VaultProvider.js';
2
+ export * from './VaultTokenCache.js';
@@ -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, Record<string, unknown>>;
15
+ sources?: Record<string, Source>;
4
16
  };
5
17
  export type ExpansionHistory = {
6
18
  source: string;
@@ -1,31 +1,84 @@
1
- const TPL_REGEX = /(?<!\\)\${(?:(\w+):)?(\w+)(?::-(.*?))?}/g;
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
- return (str || '')
9
- .toString()
10
- .replaceAll(TPL_REGEX, (match, source, key, fallback) => {
11
- const src = source ?? options.default ?? '';
12
- const provider = options.sources?.[src];
13
- if (!provider) {
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 this.track(src, key, fallback);
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
- throw new Error(`Invalid expand provider '${source}' ('${match}')`);
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
- const val = provider[key];
61
+ // Handle missing values
20
62
  if (!val && fallback === undefined) {
21
- throw new Error(`Could not expand '${match}' and no default value provided`);
63
+ throw new Error(`Could not expand '${fullMatch}' and no default value provided`);
22
64
  }
23
65
  if (val !== undefined && val !== null) {
24
- return this.track(src, key, val);
66
+ return {
67
+ match: fullMatch,
68
+ value: this.track(src, key, val),
69
+ };
25
70
  }
26
- return this.track(src, key, fallback ?? '');
27
- })
28
- .replaceAll('\\${', '${');
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') {