@aifabrix/builder 2.2.0 → 2.3.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/lib/build.js +100 -7
- package/lib/cli.js +14 -0
- package/lib/commands/secure.js +260 -0
- package/lib/config.js +58 -0
- package/lib/push.js +34 -7
- package/lib/secrets.js +72 -23
- package/lib/templates.js +1 -1
- package/lib/utils/build-copy.js +18 -0
- package/lib/utils/cli-utils.js +28 -3
- package/lib/utils/compose-generator.js +14 -2
- package/lib/utils/docker-build.js +24 -0
- package/lib/utils/secrets-encryption.js +203 -0
- package/lib/utils/secrets-path.js +22 -3
- package/package.json +2 -2
- package/test-output.txt +0 -5431
package/lib/secrets.js
CHANGED
|
@@ -32,6 +32,7 @@ const {
|
|
|
32
32
|
buildHostnameToServiceMap,
|
|
33
33
|
resolveUrlPort
|
|
34
34
|
} = require('./utils/secrets-utils');
|
|
35
|
+
const { decryptSecret, isEncrypted } = require('./utils/secrets-encryption');
|
|
35
36
|
|
|
36
37
|
/**
|
|
37
38
|
* Loads environment configuration for docker/local context
|
|
@@ -43,16 +44,59 @@ function loadEnvConfig() {
|
|
|
43
44
|
return yaml.load(content);
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Decrypts encrypted values in secrets object
|
|
49
|
+
* Checks for secure:// prefix and decrypts using encryption key from config
|
|
50
|
+
*
|
|
51
|
+
* @async
|
|
52
|
+
* @function decryptSecretsObject
|
|
53
|
+
* @param {Object} secrets - Secrets object with potentially encrypted values
|
|
54
|
+
* @returns {Promise<Object>} Secrets object with decrypted values
|
|
55
|
+
* @throws {Error} If decryption fails or encryption key is missing
|
|
56
|
+
*/
|
|
57
|
+
async function decryptSecretsObject(secrets) {
|
|
58
|
+
if (!secrets || typeof secrets !== 'object') {
|
|
59
|
+
return secrets;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const encryptionKey = await config.getSecretsEncryptionKey();
|
|
63
|
+
if (!encryptionKey) {
|
|
64
|
+
// No encryption key set, check if any values are encrypted
|
|
65
|
+
const hasEncrypted = Object.values(secrets).some(value => isEncrypted(value));
|
|
66
|
+
if (hasEncrypted) {
|
|
67
|
+
throw new Error('Encrypted secrets found but no encryption key configured. Run "aifabrix secure --secrets-encryption <key>" to set encryption key.');
|
|
68
|
+
}
|
|
69
|
+
// No encrypted values, return as-is
|
|
70
|
+
return secrets;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const decryptedSecrets = {};
|
|
74
|
+
for (const [key, value] of Object.entries(secrets)) {
|
|
75
|
+
if (isEncrypted(value)) {
|
|
76
|
+
try {
|
|
77
|
+
decryptedSecrets[key] = decryptSecret(value, encryptionKey);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
throw new Error(`Failed to decrypt secret '${key}': ${error.message}`);
|
|
80
|
+
}
|
|
81
|
+
} else {
|
|
82
|
+
decryptedSecrets[key] = value;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return decryptedSecrets;
|
|
87
|
+
}
|
|
88
|
+
|
|
46
89
|
/**
|
|
47
90
|
* Loads secrets with cascading lookup
|
|
48
91
|
* Supports both user secrets (~/.aifabrix/secrets.local.yaml) and project overrides
|
|
49
92
|
* User's file takes priority, then falls back to build.secrets from variables.yaml
|
|
93
|
+
* Automatically decrypts values with secure:// prefix
|
|
50
94
|
*
|
|
51
95
|
* @async
|
|
52
96
|
* @function loadSecrets
|
|
53
97
|
* @param {string} [secretsPath] - Path to secrets file (optional, for explicit override)
|
|
54
98
|
* @param {string} [appName] - Application name (optional, for variables.yaml lookup)
|
|
55
|
-
* @returns {Promise<Object>} Loaded secrets object
|
|
99
|
+
* @returns {Promise<Object>} Loaded secrets object with decrypted values
|
|
56
100
|
* @throws {Error} If no secrets file found and no fallback available
|
|
57
101
|
*
|
|
58
102
|
* @example
|
|
@@ -60,6 +104,8 @@ function loadEnvConfig() {
|
|
|
60
104
|
* // Returns: { 'postgres-passwordKeyVault': 'admin123', ... }
|
|
61
105
|
*/
|
|
62
106
|
async function loadSecrets(secretsPath, appName) {
|
|
107
|
+
let secrets;
|
|
108
|
+
|
|
63
109
|
// If explicit path provided, use it (backward compatibility)
|
|
64
110
|
if (secretsPath) {
|
|
65
111
|
const resolvedPath = resolveSecretsPath(secretsPath);
|
|
@@ -68,34 +114,35 @@ async function loadSecrets(secretsPath, appName) {
|
|
|
68
114
|
}
|
|
69
115
|
|
|
70
116
|
const content = fs.readFileSync(resolvedPath, 'utf8');
|
|
71
|
-
|
|
117
|
+
secrets = yaml.load(content);
|
|
72
118
|
|
|
73
119
|
if (!secrets || typeof secrets !== 'object') {
|
|
74
120
|
throw new Error(`Invalid secrets file format: ${resolvedPath}`);
|
|
75
121
|
}
|
|
122
|
+
} else {
|
|
123
|
+
// Cascading lookup: user's file first
|
|
124
|
+
let mergedSecrets = loadUserSecrets();
|
|
76
125
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
let mergedSecrets = loadUserSecrets();
|
|
126
|
+
// Then check build.secrets from variables.yaml if appName provided
|
|
127
|
+
if (appName) {
|
|
128
|
+
mergedSecrets = await loadBuildSecrets(mergedSecrets, appName);
|
|
129
|
+
}
|
|
82
130
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
131
|
+
// If still no secrets found, try default location
|
|
132
|
+
if (Object.keys(mergedSecrets).length === 0) {
|
|
133
|
+
mergedSecrets = loadDefaultSecrets();
|
|
134
|
+
}
|
|
87
135
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
136
|
+
// If still empty, throw error
|
|
137
|
+
if (Object.keys(mergedSecrets).length === 0) {
|
|
138
|
+
throw new Error('No secrets file found. Please create ~/.aifabrix/secrets.local.yaml or configure build.secrets in variables.yaml');
|
|
139
|
+
}
|
|
92
140
|
|
|
93
|
-
|
|
94
|
-
if (Object.keys(mergedSecrets).length === 0) {
|
|
95
|
-
throw new Error('No secrets file found. Please create ~/.aifabrix/secrets.local.yaml or configure build.secrets in variables.yaml');
|
|
141
|
+
secrets = mergedSecrets;
|
|
96
142
|
}
|
|
97
143
|
|
|
98
|
-
|
|
144
|
+
// Decrypt encrypted values
|
|
145
|
+
return await decryptSecretsObject(secrets);
|
|
99
146
|
}
|
|
100
147
|
|
|
101
148
|
/**
|
|
@@ -110,6 +157,7 @@ async function loadSecrets(secretsPath, appName) {
|
|
|
110
157
|
* @param {Object|string|null} [secretsFilePaths] - Paths object with userPath and buildPath, or string path (for backward compatibility)
|
|
111
158
|
* @param {string} [secretsFilePaths.userPath] - User's secrets file path
|
|
112
159
|
* @param {string|null} [secretsFilePaths.buildPath] - App's build.secrets file path (if configured)
|
|
160
|
+
* @param {string} [appName] - Application name (optional, for error messages)
|
|
113
161
|
* @returns {Promise<string>} Resolved environment content
|
|
114
162
|
* @throws {Error} If kv:// reference cannot be resolved
|
|
115
163
|
*
|
|
@@ -117,7 +165,7 @@ async function loadSecrets(secretsPath, appName) {
|
|
|
117
165
|
* const resolved = await resolveKvReferences(template, secrets, 'local');
|
|
118
166
|
* // Returns: 'DATABASE_URL=postgresql://user:pass@localhost:5432/db'
|
|
119
167
|
*/
|
|
120
|
-
async function resolveKvReferences(envTemplate, secrets, environment = 'local', secretsFilePaths = null) {
|
|
168
|
+
async function resolveKvReferences(envTemplate, secrets, environment = 'local', secretsFilePaths = null, appName = null) {
|
|
121
169
|
const envConfig = loadEnvConfig();
|
|
122
170
|
const envVars = envConfig.environments[environment] || envConfig.environments.local;
|
|
123
171
|
|
|
@@ -152,7 +200,8 @@ async function resolveKvReferences(envTemplate, secrets, environment = 'local',
|
|
|
152
200
|
fileInfo = `\n\nSecrets file location: ${paths.join(' and ')}`;
|
|
153
201
|
}
|
|
154
202
|
}
|
|
155
|
-
|
|
203
|
+
const resolveCommand = appName ? `aifabrix resolve ${appName}` : 'aifabrix resolve <app-name>';
|
|
204
|
+
throw new Error(`Missing secrets: ${missingSecrets.join(', ')}${fileInfo}\n\nRun "${resolveCommand}" to generate missing secrets.`);
|
|
156
205
|
}
|
|
157
206
|
|
|
158
207
|
// Now replace kv:// references, and handle ${VAR} inside the secret values
|
|
@@ -290,7 +339,7 @@ async function generateEnvFile(appName, secretsPath, environment = 'local', forc
|
|
|
290
339
|
const template = loadEnvTemplate(templatePath);
|
|
291
340
|
|
|
292
341
|
// Resolve secrets paths to show in error messages (use actual paths that loadSecrets would use)
|
|
293
|
-
const secretsPaths = getActualSecretsPath(secretsPath, appName);
|
|
342
|
+
const secretsPaths = await getActualSecretsPath(secretsPath, appName);
|
|
294
343
|
|
|
295
344
|
if (force) {
|
|
296
345
|
// Use userPath for generating missing secrets (priority file)
|
|
@@ -298,7 +347,7 @@ async function generateEnvFile(appName, secretsPath, environment = 'local', forc
|
|
|
298
347
|
}
|
|
299
348
|
|
|
300
349
|
const secrets = await loadSecrets(secretsPath, appName);
|
|
301
|
-
let resolved = await resolveKvReferences(template, secrets, environment, secretsPaths);
|
|
350
|
+
let resolved = await resolveKvReferences(template, secrets, environment, secretsPaths, appName);
|
|
302
351
|
|
|
303
352
|
// Resolve service ports in URLs for docker environment
|
|
304
353
|
if (environment === 'docker') {
|
package/lib/templates.js
CHANGED
|
@@ -49,7 +49,7 @@ function generateVariablesYaml(appName, config) {
|
|
|
49
49
|
build: {
|
|
50
50
|
language: config.language || 'typescript',
|
|
51
51
|
envOutputPath: null,
|
|
52
|
-
context:
|
|
52
|
+
context: null, // Defaults to dev directory in build process
|
|
53
53
|
dockerfile: '',
|
|
54
54
|
secrets: null
|
|
55
55
|
},
|
package/lib/utils/build-copy.js
CHANGED
|
@@ -125,6 +125,23 @@ function getDevDirectory(appName, developerId) {
|
|
|
125
125
|
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Copies app source files from apps directory to dev directory
|
|
130
|
+
* Used when old context format is detected to ensure source files are available
|
|
131
|
+
* @async
|
|
132
|
+
* @param {string} appsSourcePath - Path to apps/{appName} directory
|
|
133
|
+
* @param {string} devDir - Target dev directory
|
|
134
|
+
* @throws {Error} If copying fails
|
|
135
|
+
*/
|
|
136
|
+
async function copyAppSourceFiles(appsSourcePath, devDir) {
|
|
137
|
+
if (!fsSync.existsSync(appsSourcePath)) {
|
|
138
|
+
return; // Nothing to copy
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Copy all files from apps directory to dev directory
|
|
142
|
+
await copyDirectory(appsSourcePath, devDir);
|
|
143
|
+
}
|
|
144
|
+
|
|
128
145
|
/**
|
|
129
146
|
* Checks if developer-specific directory exists
|
|
130
147
|
* @param {string} appName - Application name
|
|
@@ -138,6 +155,7 @@ function devDirectoryExists(appName, developerId) {
|
|
|
138
155
|
|
|
139
156
|
module.exports = {
|
|
140
157
|
copyBuilderToDevDirectory,
|
|
158
|
+
copyAppSourceFiles,
|
|
141
159
|
getDevDirectory,
|
|
142
160
|
devDirectoryExists
|
|
143
161
|
};
|
package/lib/utils/cli-utils.js
CHANGED
|
@@ -60,11 +60,12 @@ function formatError(error) {
|
|
|
60
60
|
} else if (errorMsg.includes('permission')) {
|
|
61
61
|
messages.push(' Permission denied.');
|
|
62
62
|
messages.push(' Make sure you have the necessary permissions to run Docker commands.');
|
|
63
|
-
} else if (errorMsg.includes('Azure CLI') || errorMsg.includes('az --version')) {
|
|
64
|
-
|
|
63
|
+
} else if (errorMsg.includes('Azure CLI is not installed') || errorMsg.includes('az --version failed') || (errorMsg.includes('az') && errorMsg.includes('failed'))) {
|
|
64
|
+
// Specific error for missing Azure CLI installation or Azure CLI command failures
|
|
65
|
+
messages.push(' Azure CLI is not installed or not working properly.');
|
|
65
66
|
messages.push(' Install from: https://docs.microsoft.com/cli/azure/install-azure-cli');
|
|
66
67
|
messages.push(' Run: az login');
|
|
67
|
-
} else if (errorMsg.includes('authenticate') || errorMsg.includes('ACR')) {
|
|
68
|
+
} else if (errorMsg.includes('authenticate') || errorMsg.includes('ACR') || errorMsg.includes('Authentication required')) {
|
|
68
69
|
messages.push(' Azure Container Registry authentication failed.');
|
|
69
70
|
messages.push(' Run: az acr login --name <registry-name>');
|
|
70
71
|
messages.push(' Or login to Azure: az login');
|
|
@@ -74,6 +75,30 @@ function formatError(error) {
|
|
|
74
75
|
} else if (errorMsg.includes('Registry URL is required')) {
|
|
75
76
|
messages.push(' Registry URL is required.');
|
|
76
77
|
messages.push(' Provide via --registry flag or configure in variables.yaml under image.registry');
|
|
78
|
+
} else if (errorMsg.includes('Missing secrets')) {
|
|
79
|
+
// Extract the missing secrets list and file info from the error message
|
|
80
|
+
const missingSecretsMatch = errorMsg.match(/Missing secrets: ([^\n]+)/);
|
|
81
|
+
const fileInfoMatch = errorMsg.match(/Secrets file location: ([^\n]+)/);
|
|
82
|
+
const resolveMatch = errorMsg.match(/Run "aifabrix resolve ([^"]+)"/);
|
|
83
|
+
|
|
84
|
+
if (missingSecretsMatch) {
|
|
85
|
+
messages.push(` Missing secrets: ${missingSecretsMatch[1]}`);
|
|
86
|
+
} else {
|
|
87
|
+
messages.push(' Missing secrets in secrets file.');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (fileInfoMatch) {
|
|
91
|
+
messages.push(` Secrets file location: ${fileInfoMatch[1]}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Always show resolve command suggestion
|
|
95
|
+
if (resolveMatch) {
|
|
96
|
+
// Extract app name from error message if available
|
|
97
|
+
messages.push(` Run: aifabrix resolve ${resolveMatch[1]} to generate missing secrets.`);
|
|
98
|
+
} else {
|
|
99
|
+
// Generic suggestion if app name not in error message
|
|
100
|
+
messages.push(' Run: aifabrix resolve <app-name> to generate missing secrets.');
|
|
101
|
+
}
|
|
77
102
|
} else if (errorMsg.includes('Deployment failed after')) {
|
|
78
103
|
// Handle deployment retry errors - extract the actual error message
|
|
79
104
|
const match = errorMsg.match(/Deployment failed after \d+ attempts: (.+)/);
|
|
@@ -323,9 +323,21 @@ async function generateDockerCompose(appName, appConfig, options) {
|
|
|
323
323
|
const envFilePath = path.join(devDir, '.env');
|
|
324
324
|
const envFileAbsolutePath = envFilePath.replace(/\\/g, '/'); // Use forward slashes for Docker
|
|
325
325
|
|
|
326
|
-
// Read database passwords from .env file
|
|
326
|
+
// Read database passwords from .env file only if database is required
|
|
327
327
|
const databases = networksConfig.databases || [];
|
|
328
|
-
const
|
|
328
|
+
const requiresDatabase = serviceConfig.requiresDatabase || false;
|
|
329
|
+
let databasePasswords;
|
|
330
|
+
|
|
331
|
+
if (requiresDatabase || databases.length > 0) {
|
|
332
|
+
// Only read passwords if database is actually required
|
|
333
|
+
databasePasswords = await readDatabasePasswords(envFilePath, databases, appName);
|
|
334
|
+
} else {
|
|
335
|
+
// Return empty passwords when database is not required
|
|
336
|
+
databasePasswords = {
|
|
337
|
+
map: {},
|
|
338
|
+
array: []
|
|
339
|
+
};
|
|
340
|
+
}
|
|
329
341
|
|
|
330
342
|
const templateData = {
|
|
331
343
|
...serviceConfig,
|
|
@@ -78,6 +78,30 @@ async function executeDockerBuild(imageName, dockerfilePath, contextPath, tag) {
|
|
|
78
78
|
spinner: 'dots'
|
|
79
79
|
}).start();
|
|
80
80
|
|
|
81
|
+
// Ensure paths are absolute and normalized
|
|
82
|
+
const fsSync = require('fs');
|
|
83
|
+
const path = require('path');
|
|
84
|
+
|
|
85
|
+
dockerfilePath = path.resolve(dockerfilePath);
|
|
86
|
+
contextPath = path.resolve(contextPath);
|
|
87
|
+
|
|
88
|
+
// Validate paths exist (skip in test environments)
|
|
89
|
+
const isTestEnv = process.env.NODE_ENV === 'test' ||
|
|
90
|
+
process.env.JEST_WORKER_ID !== undefined ||
|
|
91
|
+
typeof jest !== 'undefined';
|
|
92
|
+
|
|
93
|
+
if (!isTestEnv) {
|
|
94
|
+
if (!fsSync.existsSync(dockerfilePath)) {
|
|
95
|
+
spinner.fail('Build failed');
|
|
96
|
+
throw new Error(`Dockerfile not found: ${dockerfilePath}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!fsSync.existsSync(contextPath)) {
|
|
100
|
+
spinner.fail('Build failed');
|
|
101
|
+
throw new Error(`Build context path does not exist: ${contextPath}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
81
105
|
return new Promise((resolve, reject) => {
|
|
82
106
|
// Use spawn for streaming output
|
|
83
107
|
const dockerProcess = spawn('docker', [
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI Fabrix Builder Secrets Encryption Utilities
|
|
3
|
+
*
|
|
4
|
+
* This module provides encryption and decryption functions for secrets
|
|
5
|
+
* using AES-256-GCM algorithm for ISO 27001 compliance.
|
|
6
|
+
*
|
|
7
|
+
* @fileoverview Secrets encryption utilities for AI Fabrix Builder
|
|
8
|
+
* @author AI Fabrix Team
|
|
9
|
+
* @version 2.0.0
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const crypto = require('crypto');
|
|
13
|
+
|
|
14
|
+
const ALGORITHM = 'aes-256-gcm';
|
|
15
|
+
const IV_LENGTH = 12; // 96 bits for GCM
|
|
16
|
+
const AUTH_TAG_LENGTH = 16; // 128 bits
|
|
17
|
+
const KEY_LENGTH = 32; // 256 bits for AES-256
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Validates encryption key format
|
|
21
|
+
* Key must be 32 bytes (256 bits) for AES-256
|
|
22
|
+
* Accepts hex string (64 chars) or base64 string (44 chars)
|
|
23
|
+
*
|
|
24
|
+
* @function validateEncryptionKey
|
|
25
|
+
* @param {string} key - Encryption key to validate
|
|
26
|
+
* @returns {boolean} True if key is valid
|
|
27
|
+
* @throws {Error} If key format is invalid
|
|
28
|
+
*/
|
|
29
|
+
function validateEncryptionKey(key) {
|
|
30
|
+
if (!key || typeof key !== 'string') {
|
|
31
|
+
throw new Error('Encryption key is required and must be a string');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Try to parse as hex (64 characters = 32 bytes)
|
|
35
|
+
if (key.length === 64 && /^[0-9a-fA-F]+$/.test(key)) {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Try to parse as base64 (44 characters = 32 bytes)
|
|
40
|
+
if (key.length === 44) {
|
|
41
|
+
try {
|
|
42
|
+
const buffer = Buffer.from(key, 'base64');
|
|
43
|
+
if (buffer.length === KEY_LENGTH) {
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
} catch (error) {
|
|
47
|
+
// Not valid base64
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
throw new Error(`Encryption key must be 32 bytes (64 hex characters or 44 base64 characters). Got ${key.length} characters`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Normalizes encryption key to Buffer
|
|
56
|
+
* Converts hex or base64 string to 32-byte buffer
|
|
57
|
+
*
|
|
58
|
+
* @function normalizeKey
|
|
59
|
+
* @param {string} key - Encryption key (hex or base64)
|
|
60
|
+
* @returns {Buffer} 32-byte key buffer
|
|
61
|
+
*/
|
|
62
|
+
function normalizeKey(key) {
|
|
63
|
+
validateEncryptionKey(key);
|
|
64
|
+
|
|
65
|
+
// Try hex first (64 characters)
|
|
66
|
+
if (key.length === 64 && /^[0-9a-fA-F]+$/.test(key)) {
|
|
67
|
+
return Buffer.from(key, 'hex');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Try base64 (44 characters)
|
|
71
|
+
if (key.length === 44) {
|
|
72
|
+
const buffer = Buffer.from(key, 'base64');
|
|
73
|
+
if (buffer.length === KEY_LENGTH) {
|
|
74
|
+
return buffer;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
throw new Error('Invalid encryption key format');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Encrypts a secret value using AES-256-GCM
|
|
83
|
+
* Returns encrypted value in format: secure://<iv>:<ciphertext>:<authTag>
|
|
84
|
+
* All components are base64 encoded
|
|
85
|
+
*
|
|
86
|
+
* @function encryptSecret
|
|
87
|
+
* @param {string} value - Plaintext secret value to encrypt
|
|
88
|
+
* @param {string} key - Encryption key (hex or base64, 32 bytes)
|
|
89
|
+
* @returns {string} Encrypted value with secure:// prefix
|
|
90
|
+
* @throws {Error} If encryption fails or key is invalid
|
|
91
|
+
*
|
|
92
|
+
* @example
|
|
93
|
+
* const encrypted = encryptSecret('my-secret', 'a1b2c3...');
|
|
94
|
+
* // Returns: 'secure://<iv>:<ciphertext>:<authTag>'
|
|
95
|
+
*/
|
|
96
|
+
function encryptSecret(value, key) {
|
|
97
|
+
if (typeof value !== 'string') {
|
|
98
|
+
throw new Error('Value is required and must be a string');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const keyBuffer = normalizeKey(key);
|
|
102
|
+
|
|
103
|
+
// Generate random IV
|
|
104
|
+
const iv = crypto.randomBytes(IV_LENGTH);
|
|
105
|
+
|
|
106
|
+
// Create cipher
|
|
107
|
+
const cipher = crypto.createCipheriv(ALGORITHM, keyBuffer, iv);
|
|
108
|
+
|
|
109
|
+
// Encrypt
|
|
110
|
+
let ciphertext = cipher.update(value, 'utf8', 'base64');
|
|
111
|
+
ciphertext += cipher.final('base64');
|
|
112
|
+
|
|
113
|
+
// Get authentication tag
|
|
114
|
+
const authTag = cipher.getAuthTag();
|
|
115
|
+
|
|
116
|
+
// Format: secure://<iv>:<ciphertext>:<authTag>
|
|
117
|
+
return `secure://${iv.toString('base64')}:${ciphertext}:${authTag.toString('base64')}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Decrypts an encrypted secret value
|
|
122
|
+
* Handles secure:// prefixed values and extracts IV, ciphertext, and auth tag
|
|
123
|
+
*
|
|
124
|
+
* @function decryptSecret
|
|
125
|
+
* @param {string} encryptedValue - Encrypted value with secure:// prefix
|
|
126
|
+
* @param {string} key - Encryption key (hex or base64, 32 bytes)
|
|
127
|
+
* @returns {string} Decrypted plaintext value
|
|
128
|
+
* @throws {Error} If decryption fails, key is invalid, or format is incorrect
|
|
129
|
+
*
|
|
130
|
+
* @example
|
|
131
|
+
* const decrypted = decryptSecret('secure://<iv>:<ciphertext>:<authTag>', 'a1b2c3...');
|
|
132
|
+
* // Returns: 'my-secret'
|
|
133
|
+
*/
|
|
134
|
+
function decryptSecret(encryptedValue, key) {
|
|
135
|
+
if (!encryptedValue || typeof encryptedValue !== 'string') {
|
|
136
|
+
throw new Error('Encrypted value is required and must be a string');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!encryptedValue.startsWith('secure://')) {
|
|
140
|
+
throw new Error('Encrypted value must start with secure:// prefix');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const keyBuffer = normalizeKey(key);
|
|
144
|
+
|
|
145
|
+
// Remove secure:// prefix
|
|
146
|
+
const parts = encryptedValue.substring(9).split(':');
|
|
147
|
+
if (parts.length !== 3) {
|
|
148
|
+
throw new Error('Invalid encrypted value format. Expected: secure://<iv>:<ciphertext>:<authTag>');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const [ivBase64, ciphertext, authTagBase64] = parts;
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
// Decode IV and auth tag
|
|
155
|
+
const iv = Buffer.from(ivBase64, 'base64');
|
|
156
|
+
const authTag = Buffer.from(authTagBase64, 'base64');
|
|
157
|
+
|
|
158
|
+
// Validate lengths
|
|
159
|
+
if (iv.length !== IV_LENGTH) {
|
|
160
|
+
throw new Error(`Invalid IV length: expected ${IV_LENGTH} bytes, got ${iv.length}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (authTag.length !== AUTH_TAG_LENGTH) {
|
|
164
|
+
throw new Error(`Invalid auth tag length: expected ${AUTH_TAG_LENGTH} bytes, got ${authTag.length}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Create decipher
|
|
168
|
+
const decipher = crypto.createDecipheriv(ALGORITHM, keyBuffer, iv);
|
|
169
|
+
decipher.setAuthTag(authTag);
|
|
170
|
+
|
|
171
|
+
// Decrypt
|
|
172
|
+
let plaintext = decipher.update(ciphertext, 'base64', 'utf8');
|
|
173
|
+
plaintext += decipher.final('utf8');
|
|
174
|
+
|
|
175
|
+
return plaintext;
|
|
176
|
+
} catch (error) {
|
|
177
|
+
// Don't expose sensitive details in error messages
|
|
178
|
+
if (error.message.includes('Unsupported state') || error.message.includes('bad decrypt')) {
|
|
179
|
+
throw new Error('Decryption failed: invalid key or corrupted data');
|
|
180
|
+
}
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Checks if a value is encrypted (starts with secure://)
|
|
187
|
+
*
|
|
188
|
+
* @function isEncrypted
|
|
189
|
+
* @param {string} value - Value to check
|
|
190
|
+
* @returns {boolean} True if value is encrypted
|
|
191
|
+
*/
|
|
192
|
+
function isEncrypted(value) {
|
|
193
|
+
return typeof value === 'string' && value.startsWith('secure://');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
module.exports = {
|
|
197
|
+
encryptSecret,
|
|
198
|
+
decryptSecret,
|
|
199
|
+
isEncrypted,
|
|
200
|
+
validateEncryptionKey,
|
|
201
|
+
normalizeKey
|
|
202
|
+
};
|
|
203
|
+
|
|
@@ -13,6 +13,7 @@ const fs = require('fs');
|
|
|
13
13
|
const path = require('path');
|
|
14
14
|
const os = require('os');
|
|
15
15
|
const yaml = require('js-yaml');
|
|
16
|
+
const config = require('../config');
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Resolves secrets file path (backward compatibility)
|
|
@@ -56,14 +57,17 @@ function resolveSecretsPath(secretsPath) {
|
|
|
56
57
|
/**
|
|
57
58
|
* Determines the actual secrets file paths that loadSecrets would use
|
|
58
59
|
* Mirrors the cascading lookup logic from loadSecrets
|
|
60
|
+
* Checks config.yaml for general secrets-path as fallback
|
|
61
|
+
*
|
|
62
|
+
* @async
|
|
59
63
|
* @function getActualSecretsPath
|
|
60
64
|
* @param {string} [secretsPath] - Path to secrets file (optional)
|
|
61
65
|
* @param {string} [appName] - Application name (optional, for variables.yaml lookup)
|
|
62
|
-
* @returns {Object} Object with userPath and buildPath (if configured)
|
|
66
|
+
* @returns {Promise<Object>} Object with userPath and buildPath (if configured)
|
|
63
67
|
* @returns {string} returns.userPath - User's secrets file path (~/.aifabrix/secrets.local.yaml)
|
|
64
|
-
* @returns {string|null} returns.buildPath - App's build.secrets file path (if configured in variables.yaml)
|
|
68
|
+
* @returns {string|null} returns.buildPath - App's build.secrets file path (if configured in variables.yaml or config.yaml)
|
|
65
69
|
*/
|
|
66
|
-
function getActualSecretsPath(secretsPath, appName) {
|
|
70
|
+
async function getActualSecretsPath(secretsPath, appName) {
|
|
67
71
|
// If explicit path provided, use it (backward compatibility)
|
|
68
72
|
if (secretsPath) {
|
|
69
73
|
const resolvedPath = resolveSecretsPath(secretsPath);
|
|
@@ -97,6 +101,21 @@ function getActualSecretsPath(secretsPath, appName) {
|
|
|
97
101
|
}
|
|
98
102
|
}
|
|
99
103
|
|
|
104
|
+
// If no build.secrets found in variables.yaml, check config.yaml for general secrets-path
|
|
105
|
+
if (!buildSecretsPath) {
|
|
106
|
+
try {
|
|
107
|
+
const generalSecretsPath = await config.getSecretsPath();
|
|
108
|
+
if (generalSecretsPath) {
|
|
109
|
+
// Resolve relative paths from current working directory
|
|
110
|
+
buildSecretsPath = path.isAbsolute(generalSecretsPath)
|
|
111
|
+
? generalSecretsPath
|
|
112
|
+
: path.resolve(process.cwd(), generalSecretsPath);
|
|
113
|
+
}
|
|
114
|
+
} catch (error) {
|
|
115
|
+
// Ignore errors, continue
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
100
119
|
// Return both paths (even if files don't exist) for error messages
|
|
101
120
|
return {
|
|
102
121
|
userPath: userSecretsPath,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aifabrix/builder",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"description": "AI Fabrix Local Fabric & Deployment SDK",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"container"
|
|
33
33
|
],
|
|
34
34
|
"author": "eSystems Nordic Ltd",
|
|
35
|
-
"license": "
|
|
35
|
+
"license": "MIT",
|
|
36
36
|
"engines": {
|
|
37
37
|
"node": ">=18.0.0"
|
|
38
38
|
},
|