@saasak/tool-env 1.0.0 → 1.0.1
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/package.json +3 -2
- package/src/utils-crypto.js +90 -0
- package/src/utils-env.js +58 -0
- package/src/utils-pkg.js +68 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@saasak/tool-env",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "1.0.
|
|
4
|
+
"version": "1.0.1",
|
|
5
5
|
"author": "dev@saasak.studio",
|
|
6
6
|
"description": "A small util to manage environment variables for your monorepo",
|
|
7
7
|
"keywords": [
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
"type": "module",
|
|
13
13
|
"main": "bin/index.js",
|
|
14
14
|
"files": [
|
|
15
|
-
"bin"
|
|
15
|
+
"bin",
|
|
16
|
+
"src"
|
|
16
17
|
],
|
|
17
18
|
"bin": {
|
|
18
19
|
"wrenv": "bin/index.js"
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
|
|
3
|
+
// Constants for AES-256-GCM encryption
|
|
4
|
+
// GCM provides authenticated encryption (confidentiality + integrity)
|
|
5
|
+
// Using 256-bit keys for strong security
|
|
6
|
+
const ENCRYPTION_PREFIX = '$enc$';
|
|
7
|
+
const ALGORITHM = 'aes-256-gcm';
|
|
8
|
+
const IV_LENGTH = 16; // 128 bits
|
|
9
|
+
const SALT_LENGTH = 64; // 512 bits
|
|
10
|
+
const TAG_LENGTH = 16; // 128 bits
|
|
11
|
+
const KEY_LENGTH = 32; // 256 bits
|
|
12
|
+
const ITERATIONS = 100000; // PBKDF2 iterations
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Derive a 256-bit key from a secret using PBKDF2
|
|
16
|
+
* This ensures consistent key generation from variable-length secrets
|
|
17
|
+
* and adds computational cost to prevent brute-force attacks
|
|
18
|
+
*/
|
|
19
|
+
function deriveKey(secret, salt) {
|
|
20
|
+
return crypto.pbkdf2Sync(secret, salt, ITERATIONS, KEY_LENGTH, 'sha256');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Encrypt a string value using AES-256-GCM
|
|
25
|
+
* Returns a string with format: $enc$<base64(salt+iv+ciphertext+tag)>
|
|
26
|
+
* Each encryption uses a unique salt and IV, so the same plaintext produces different ciphertexts
|
|
27
|
+
*/
|
|
28
|
+
export function encrypt(plaintext, secret) {
|
|
29
|
+
if (!plaintext) return plaintext;
|
|
30
|
+
|
|
31
|
+
const salt = crypto.randomBytes(SALT_LENGTH);
|
|
32
|
+
const iv = crypto.randomBytes(IV_LENGTH);
|
|
33
|
+
const key = deriveKey(secret, salt);
|
|
34
|
+
|
|
35
|
+
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
|
36
|
+
|
|
37
|
+
let encrypted = cipher.update(plaintext, 'utf8');
|
|
38
|
+
encrypted = Buffer.concat([encrypted, cipher.final()]);
|
|
39
|
+
|
|
40
|
+
const tag = cipher.getAuthTag();
|
|
41
|
+
|
|
42
|
+
// Combine: salt + iv + encrypted + tag, then base64 encode
|
|
43
|
+
const combined = Buffer.concat([salt, iv, encrypted, tag]);
|
|
44
|
+
const encoded = combined.toString('base64');
|
|
45
|
+
|
|
46
|
+
return `${ENCRYPTION_PREFIX}${encoded}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Decrypt an encrypted string
|
|
51
|
+
* Strips the prefix and decodes the base64 payload
|
|
52
|
+
* GCM authentication tag ensures the data hasn't been tampered with
|
|
53
|
+
*/
|
|
54
|
+
export function decrypt(encrypted, secret) {
|
|
55
|
+
if (!encrypted || !isEncrypted(encrypted)) {
|
|
56
|
+
return encrypted;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Remove prefix and decode
|
|
60
|
+
const encoded = encrypted.slice(ENCRYPTION_PREFIX.length);
|
|
61
|
+
const combined = Buffer.from(encoded, 'base64');
|
|
62
|
+
|
|
63
|
+
// Extract components
|
|
64
|
+
const salt = combined.slice(0, SALT_LENGTH);
|
|
65
|
+
const iv = combined.slice(SALT_LENGTH, SALT_LENGTH + IV_LENGTH);
|
|
66
|
+
const tag = combined.slice(-TAG_LENGTH);
|
|
67
|
+
const encryptedData = combined.slice(
|
|
68
|
+
SALT_LENGTH + IV_LENGTH,
|
|
69
|
+
-TAG_LENGTH
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const key = deriveKey(secret, salt);
|
|
73
|
+
|
|
74
|
+
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
|
75
|
+
decipher.setAuthTag(tag);
|
|
76
|
+
|
|
77
|
+
let decrypted = decipher.update(encryptedData);
|
|
78
|
+
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
|
79
|
+
|
|
80
|
+
return decrypted.toString('utf8');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Check if a string is encrypted by looking for the prefix
|
|
85
|
+
* This allows the system to detect encrypted values without attempting decryption
|
|
86
|
+
*/
|
|
87
|
+
export function isEncrypted(value) {
|
|
88
|
+
return typeof value === 'string' && value.startsWith(ENCRYPTION_PREFIX);
|
|
89
|
+
}
|
|
90
|
+
|
package/src/utils-env.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { decrypt, isEncrypted } from './utils-crypto.js';
|
|
2
|
+
|
|
3
|
+
// ========================================
|
|
4
|
+
// Helpers for core logic
|
|
5
|
+
// ========================================
|
|
6
|
+
|
|
7
|
+
export function buildEnv(secret, target, env, names) {
|
|
8
|
+
const variablesForAllTargets = env.variables;
|
|
9
|
+
const overrides = env.overrides || {};
|
|
10
|
+
|
|
11
|
+
// For each variable, extract the value for the target env
|
|
12
|
+
const variables = Object.entries(variablesForAllTargets).reduce((acc, entry) => {
|
|
13
|
+
const [key, value] = entry;
|
|
14
|
+
return {
|
|
15
|
+
...acc,
|
|
16
|
+
[key]: extractValue(secret, value[target] || value['@@'] || '')
|
|
17
|
+
};
|
|
18
|
+
}, {});
|
|
19
|
+
|
|
20
|
+
// For each package, get all variables
|
|
21
|
+
// compute the overrides and merge them
|
|
22
|
+
// allVars is an object with the package name as key
|
|
23
|
+
// and the variables as value like so
|
|
24
|
+
// => { [name]: { [variable]: value } }
|
|
25
|
+
const allVars = names.reduce((acc, name) => {
|
|
26
|
+
acc[name] = {
|
|
27
|
+
...variables,
|
|
28
|
+
...parseOverrides(secret, target, overrides[name], variables)
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
return acc;
|
|
32
|
+
}, {});
|
|
33
|
+
|
|
34
|
+
return allVars;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parseOverrides(secret, target, overrides, vars) {
|
|
38
|
+
if (!secret) return {};
|
|
39
|
+
if (!overrides) return {};
|
|
40
|
+
|
|
41
|
+
return Object.entries(overrides).reduce((acc, entry) => {
|
|
42
|
+
const [key, val] = entry;
|
|
43
|
+
const value = extractValue(secret, val[target] || val['@@'] || '');
|
|
44
|
+
|
|
45
|
+
if (value === null) return acc;
|
|
46
|
+
|
|
47
|
+
acc[key] = Array.isArray(value)
|
|
48
|
+
? value.map(v => vars[v] || v).join('')
|
|
49
|
+
: value
|
|
50
|
+
return acc;
|
|
51
|
+
}, {});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function extractValue(secret, value) {
|
|
55
|
+
if (!isEncrypted(value)) return value;
|
|
56
|
+
if (!secret) return '';
|
|
57
|
+
return decrypt(value, secret);
|
|
58
|
+
}
|
package/src/utils-pkg.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
|
|
4
|
+
export async function findMonorepoPackages(rootDir, scope = null, maxDepth = 4) {
|
|
5
|
+
const packages = [];
|
|
6
|
+
|
|
7
|
+
async function processPackage(pkgPath, pkgDir) {
|
|
8
|
+
try {
|
|
9
|
+
const pkgContent = await fs.readFile(pkgPath, 'utf8');
|
|
10
|
+
const pkg = JSON.parse(pkgContent);
|
|
11
|
+
|
|
12
|
+
if (scope && !pkg.name?.startsWith(`${scope}/`)) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
name: pkg.name,
|
|
18
|
+
dir: pkgDir,
|
|
19
|
+
};
|
|
20
|
+
} catch (err) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function traverse(dir, currentDepth = 0) {
|
|
26
|
+
if (currentDepth > maxDepth) return;
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
30
|
+
|
|
31
|
+
for (const entry of entries) {
|
|
32
|
+
const fullPath = path.join(dir, entry.name);
|
|
33
|
+
|
|
34
|
+
if (entry.name === 'node_modules' || entry.isSymbolicLink()) {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!entry.isDirectory()) continue;
|
|
39
|
+
|
|
40
|
+
const pkgPath = path.join(fullPath, 'package.json');
|
|
41
|
+
const exists = await fs.pathExists(pkgPath);
|
|
42
|
+
|
|
43
|
+
if (exists) {
|
|
44
|
+
const pkg = await processPackage(pkgPath, fullPath);
|
|
45
|
+
packages.push(pkg);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
await traverse(fullPath, currentDepth + 1);
|
|
49
|
+
}
|
|
50
|
+
} catch (err) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const rootPkgPath = path.join(rootDir, 'package.json');
|
|
56
|
+
const exists = await fs.pathExists(rootPkgPath);
|
|
57
|
+
|
|
58
|
+
if (!exists) return [];
|
|
59
|
+
|
|
60
|
+
const pkg = await processPackage(rootPkgPath, rootDir);
|
|
61
|
+
|
|
62
|
+
if (!pkg) return [];
|
|
63
|
+
|
|
64
|
+
packages.push(pkg);
|
|
65
|
+
await traverse(rootDir, 0);
|
|
66
|
+
|
|
67
|
+
return packages;
|
|
68
|
+
}
|