@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.
- package/README.md +218 -43
- package/bin/release +122 -0
- 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/commands/tasks/run.js +6 -1
- 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/shell/ExecuteLocalCommandOperation.js +40 -10
- 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 +578 -173
- 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
|
+
}
|
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') {
|