ultraenv 1.0.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/LICENSE +21 -0
- package/README.md +2058 -0
- package/bin/ultraenv.mjs +3 -0
- package/dist/chunk-2USZPWLZ.js +288 -0
- package/dist/chunk-3UV2QNJL.js +270 -0
- package/dist/chunk-3VYXPTYV.js +179 -0
- package/dist/chunk-4XUYMRK5.js +366 -0
- package/dist/chunk-5G2DU52U.js +189 -0
- package/dist/chunk-6KS56D6E.js +172 -0
- package/dist/chunk-AWN6ADV7.js +328 -0
- package/dist/chunk-CHVO6NWI.js +203 -0
- package/dist/chunk-CIFMBJ4H.js +3975 -0
- package/dist/chunk-GC7RXHLA.js +253 -0
- package/dist/chunk-HFXQGJY3.js +445 -0
- package/dist/chunk-IGFVP24Q.js +91 -0
- package/dist/chunk-IKPTKALB.js +78 -0
- package/dist/chunk-JB7RKV3C.js +66 -0
- package/dist/chunk-MNVFG7H4.js +611 -0
- package/dist/chunk-MSXMESFP.js +1910 -0
- package/dist/chunk-N5PAV4NM.js +127 -0
- package/dist/chunk-NBOABPHM.js +158 -0
- package/dist/chunk-OMAOROL4.js +49 -0
- package/dist/chunk-R7PZRSZ7.js +105 -0
- package/dist/chunk-TE7HPLA6.js +73 -0
- package/dist/chunk-TMT5KCO3.js +101 -0
- package/dist/chunk-UEWYFN6A.js +189 -0
- package/dist/chunk-WMHN5RW2.js +128 -0
- package/dist/chunk-XC65ORJ5.js +70 -0
- package/dist/chunk-YMMP4VQL.js +118 -0
- package/dist/chunk-YN2KGTCB.js +33 -0
- package/dist/chunk-YTICOB5M.js +65 -0
- package/dist/chunk-YVWLXFUT.js +107 -0
- package/dist/ci-check-sync-VBMSVWIV.js +48 -0
- package/dist/ci-scan-24MT5XGS.js +41 -0
- package/dist/ci-setup-C2NKEFRD.js +135 -0
- package/dist/ci-validate-7AW24LSQ.js +57 -0
- package/dist/cli/index.cjs +9217 -0
- package/dist/cli/index.d.cts +9 -0
- package/dist/cli/index.d.ts +9 -0
- package/dist/cli/index.js +339 -0
- package/dist/comparator-RDKX3OI7.js +13 -0
- package/dist/completion-MW35C2XO.js +168 -0
- package/dist/config-O5YRQP5Z.js +13 -0
- package/dist/debug-PTPXAF3K.js +131 -0
- package/dist/declaration-LEME4AFZ.js +10 -0
- package/dist/doctor-FZAUPKHS.js +129 -0
- package/dist/envs-compare-5K3HESX5.js +49 -0
- package/dist/envs-create-2XXHXMGA.js +58 -0
- package/dist/envs-list-NQM5252B.js +59 -0
- package/dist/envs-switch-6L2AQYID.js +50 -0
- package/dist/envs-validate-FL73Q76T.js +89 -0
- package/dist/fs-VH7ATUS3.js +31 -0
- package/dist/generator-LFZBMZZS.js +14 -0
- package/dist/git-BZS4DPAI.js +30 -0
- package/dist/help-3XJBXEHE.js +121 -0
- package/dist/index.cjs +12907 -0
- package/dist/index.d.cts +2562 -0
- package/dist/index.d.ts +2562 -0
- package/dist/index.js +3212 -0
- package/dist/init-Y7JQ2KYJ.js +146 -0
- package/dist/install-hook-SKXIV6NV.js +111 -0
- package/dist/json-schema-I26YNQBH.js +10 -0
- package/dist/key-manager-O3G55WPU.js +25 -0
- package/dist/middleware/express.cjs +103 -0
- package/dist/middleware/express.d.cts +115 -0
- package/dist/middleware/express.d.ts +115 -0
- package/dist/middleware/express.js +8 -0
- package/dist/middleware/fastify.cjs +91 -0
- package/dist/middleware/fastify.d.cts +111 -0
- package/dist/middleware/fastify.d.ts +111 -0
- package/dist/middleware/fastify.js +8 -0
- package/dist/module-IDIZPP4M.js +10 -0
- package/dist/protect-NCWPM6VC.js +161 -0
- package/dist/scan-TRLY36TT.js +58 -0
- package/dist/schema/index.cjs +4074 -0
- package/dist/schema/index.d.cts +1244 -0
- package/dist/schema/index.d.ts +1244 -0
- package/dist/schema/index.js +152 -0
- package/dist/sync-TMHMTLH2.js +186 -0
- package/dist/typegen-SQOSXBWM.js +80 -0
- package/dist/validate-IOAM5HWS.js +100 -0
- package/dist/vault-decrypt-U6HJZNBV.js +111 -0
- package/dist/vault-diff-B3ZOQTWI.js +132 -0
- package/dist/vault-encrypt-GUSLCSKS.js +112 -0
- package/dist/vault-init-GUBOTOUL.js +106 -0
- package/dist/vault-rekey-DAHT7JCN.js +132 -0
- package/dist/vault-status-GDLRU2OK.js +90 -0
- package/dist/vault-verify-CD76FJSF.js +102 -0
- package/package.json +106 -0
package/bin/ultraenv.mjs
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ENCRYPTED_PREFIX,
|
|
3
|
+
ENCRYPTION_ALGORITHM
|
|
4
|
+
} from "./chunk-XC65ORJ5.js";
|
|
5
|
+
import {
|
|
6
|
+
EncryptionError
|
|
7
|
+
} from "./chunk-5G2DU52U.js";
|
|
8
|
+
|
|
9
|
+
// src/utils/crypto.ts
|
|
10
|
+
import {
|
|
11
|
+
randomBytes as cryptoRandomBytes,
|
|
12
|
+
createHash,
|
|
13
|
+
createHmac,
|
|
14
|
+
createCipheriv,
|
|
15
|
+
createDecipheriv,
|
|
16
|
+
hkdfSync,
|
|
17
|
+
timingSafeEqual as cryptoTimingSafeEqual
|
|
18
|
+
} from "crypto";
|
|
19
|
+
function deriveKey(masterKey, salt, info, length = 32) {
|
|
20
|
+
if (length < 1 || length > 255) {
|
|
21
|
+
throw new RangeError(`deriveKey: length must be between 1 and 255 bytes, got ${length}`);
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
const derived = hkdfSync("sha256", masterKey, salt, Buffer.from(info, "utf-8"), length);
|
|
25
|
+
return Buffer.from(derived);
|
|
26
|
+
} catch {
|
|
27
|
+
return manualHkdf(masterKey, salt, info, length);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function manualHkdf(ikm, salt, info, length) {
|
|
31
|
+
const prk = createHmac("sha256", salt).update(ikm).digest();
|
|
32
|
+
const infoBuffer = Buffer.from(info, "utf-8");
|
|
33
|
+
const hashLen = 32;
|
|
34
|
+
const n = Math.ceil(length / hashLen);
|
|
35
|
+
if (n > 255) {
|
|
36
|
+
throw new Error("deriveKey: cannot derive more than 255 * 32 bytes");
|
|
37
|
+
}
|
|
38
|
+
const okm = Buffer.alloc(length);
|
|
39
|
+
let previous = Buffer.alloc(0);
|
|
40
|
+
for (let i = 1; i <= n; i++) {
|
|
41
|
+
const hmacData = Buffer.concat([
|
|
42
|
+
previous,
|
|
43
|
+
infoBuffer,
|
|
44
|
+
Buffer.from([i])
|
|
45
|
+
]);
|
|
46
|
+
previous = createHmac("sha256", prk).update(hmacData).digest();
|
|
47
|
+
const offset = (i - 1) * hashLen;
|
|
48
|
+
const copyLen = Math.min(hashLen, length - offset);
|
|
49
|
+
previous.copy(okm, offset, 0, copyLen);
|
|
50
|
+
}
|
|
51
|
+
return okm;
|
|
52
|
+
}
|
|
53
|
+
function encrypt(key, plaintext) {
|
|
54
|
+
if (key.length !== 16 && key.length !== 24 && key.length !== 32) {
|
|
55
|
+
throw new RangeError(
|
|
56
|
+
`encrypt: key must be 16, 24, or 32 bytes (AES-128/192/256), got ${key.length}`
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
const iv = cryptoRandomBytes(12);
|
|
60
|
+
const algo = `aes-${key.length * 8}-gcm`;
|
|
61
|
+
const cipher = createCipheriv(algo, key, iv);
|
|
62
|
+
const ciphertext = Buffer.concat([
|
|
63
|
+
cipher.update(plaintext),
|
|
64
|
+
cipher.final()
|
|
65
|
+
]);
|
|
66
|
+
const authTag = cipher.getAuthTag();
|
|
67
|
+
return { iv, authTag, ciphertext };
|
|
68
|
+
}
|
|
69
|
+
function decrypt(key, iv, authTag, ciphertext) {
|
|
70
|
+
if (key.length !== 16 && key.length !== 24 && key.length !== 32) {
|
|
71
|
+
throw new RangeError(
|
|
72
|
+
`decrypt: key must be 16, 24, or 32 bytes (AES-128/192/256), got ${key.length}`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
const algo = `aes-${key.length * 8}-gcm`;
|
|
76
|
+
const decipher = createDecipheriv(algo, key, iv);
|
|
77
|
+
decipher.setAuthTag(authTag);
|
|
78
|
+
try {
|
|
79
|
+
return Buffer.concat([
|
|
80
|
+
decipher.update(ciphertext),
|
|
81
|
+
decipher.final()
|
|
82
|
+
]);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
85
|
+
throw new Error(
|
|
86
|
+
`Decryption failed: ${message}. This usually means the key is wrong or the ciphertext was tampered with.`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function bufferToBase64(buffer) {
|
|
91
|
+
return buffer.toString("base64");
|
|
92
|
+
}
|
|
93
|
+
function base64ToBuffer(b64) {
|
|
94
|
+
return Buffer.from(b64, "base64");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// src/vault/encryption.ts
|
|
98
|
+
var IV_LENGTH = 12;
|
|
99
|
+
var AUTH_TAG_LENGTH = 16;
|
|
100
|
+
function serializeEnvData(data) {
|
|
101
|
+
const keys = Object.keys(data).sort();
|
|
102
|
+
const lines = [];
|
|
103
|
+
for (const key of keys) {
|
|
104
|
+
const value = data[key];
|
|
105
|
+
const escapedValue = value.replace(/\n/g, "\\n");
|
|
106
|
+
lines.push(`${key}=${escapedValue}`);
|
|
107
|
+
}
|
|
108
|
+
return lines.join("\n");
|
|
109
|
+
}
|
|
110
|
+
function encryptEnvironment(data, key) {
|
|
111
|
+
if (key.length !== 32) {
|
|
112
|
+
throw new EncryptionError(
|
|
113
|
+
`Invalid key length: expected 32 bytes for AES-256, got ${key.length}`,
|
|
114
|
+
{ hint: "Generate a new key using generateMasterKey() or ensure you are using the correct key file." }
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
if (Object.keys(data).length === 0) {
|
|
118
|
+
throw new EncryptionError(
|
|
119
|
+
"Cannot encrypt empty environment data",
|
|
120
|
+
{ hint: "Provide at least one environment variable to encrypt." }
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
const serialized = serializeEnvData(data);
|
|
125
|
+
const plaintext = Buffer.from(serialized, "utf-8");
|
|
126
|
+
const result = encrypt(key, plaintext);
|
|
127
|
+
return {
|
|
128
|
+
iv: result.iv,
|
|
129
|
+
authTag: result.authTag,
|
|
130
|
+
ciphertext: result.ciphertext,
|
|
131
|
+
algorithm: ENCRYPTION_ALGORITHM
|
|
132
|
+
};
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (error instanceof EncryptionError) throw error;
|
|
135
|
+
throw new EncryptionError("Failed to encrypt environment data", {
|
|
136
|
+
cause: error instanceof Error ? error : void 0
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function decryptEnvironment(encrypted, key) {
|
|
141
|
+
if (key.length !== 32) {
|
|
142
|
+
throw new EncryptionError(
|
|
143
|
+
`Invalid key length: expected 32 bytes for AES-256, got ${key.length}`,
|
|
144
|
+
{ hint: "Ensure you are using the correct decryption key for this environment." }
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
if (encrypted.algorithm !== ENCRYPTION_ALGORITHM) {
|
|
148
|
+
throw new EncryptionError(
|
|
149
|
+
`Unsupported algorithm: "${encrypted.algorithm}". Expected "${ENCRYPTION_ALGORITHM}".`,
|
|
150
|
+
{ hint: "This vault was encrypted with a different algorithm. You may need to migrate the vault." }
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
if (encrypted.iv.length !== IV_LENGTH) {
|
|
154
|
+
throw new EncryptionError(
|
|
155
|
+
`Invalid IV length: expected ${IV_LENGTH} bytes, got ${encrypted.iv.length}`,
|
|
156
|
+
{ hint: "The encrypted data may be corrupted." }
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
if (encrypted.authTag.length !== AUTH_TAG_LENGTH) {
|
|
160
|
+
throw new EncryptionError(
|
|
161
|
+
`Invalid auth tag length: expected ${AUTH_TAG_LENGTH} bytes, got ${encrypted.authTag.length}`,
|
|
162
|
+
{ hint: "The encrypted data may be corrupted." }
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
const plaintext = decrypt(key, encrypted.iv, encrypted.authTag, encrypted.ciphertext);
|
|
167
|
+
return plaintext.toString("utf-8");
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if (error instanceof EncryptionError) throw error;
|
|
170
|
+
throw new EncryptionError(
|
|
171
|
+
"Failed to decrypt environment data. The key may be incorrect or the ciphertext was tampered with.",
|
|
172
|
+
/* v8 ignore start */
|
|
173
|
+
{ cause: error instanceof Error ? error : void 0 }
|
|
174
|
+
/* v8 ignore stop */
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function encryptValue(value, key) {
|
|
179
|
+
if (key.length !== 32) {
|
|
180
|
+
throw new EncryptionError(
|
|
181
|
+
`Invalid key length: expected 32 bytes for AES-256, got ${key.length}`,
|
|
182
|
+
{ hint: "Generate a new key using generateMasterKey()." }
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
const plaintext = Buffer.from(value, "utf-8");
|
|
187
|
+
const result = encrypt(key, plaintext);
|
|
188
|
+
const ivB64 = bufferToBase64(result.iv);
|
|
189
|
+
const authTagB64 = bufferToBase64(result.authTag);
|
|
190
|
+
const ciphertextB64 = bufferToBase64(result.ciphertext);
|
|
191
|
+
return `${ENCRYPTED_PREFIX}${ivB64}:${authTagB64}:${ciphertextB64}`;
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (error instanceof EncryptionError) throw error;
|
|
194
|
+
throw new EncryptionError("Failed to encrypt value", {
|
|
195
|
+
cause: error instanceof Error ? error : void 0
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
function decryptValue(encrypted, key) {
|
|
200
|
+
if (key.length !== 32) {
|
|
201
|
+
throw new EncryptionError(
|
|
202
|
+
`Invalid key length: expected 32 bytes for AES-256, got ${key.length}`,
|
|
203
|
+
{ hint: "Ensure you are using the correct decryption key." }
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
if (!encrypted.startsWith(ENCRYPTED_PREFIX)) {
|
|
207
|
+
throw new EncryptionError(
|
|
208
|
+
"Invalid encrypted value format: missing required prefix",
|
|
209
|
+
{
|
|
210
|
+
hint: `Expected the value to start with "${ENCRYPTED_PREFIX}". This value may not have been encrypted by ultraenv.`
|
|
211
|
+
}
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
const payload = encrypted.slice(ENCRYPTED_PREFIX.length);
|
|
215
|
+
const parts = payload.split(":");
|
|
216
|
+
if (parts.length !== 3) {
|
|
217
|
+
throw new EncryptionError(
|
|
218
|
+
`Invalid encrypted value format: expected 3 colon-separated components after prefix, got ${parts.length}`,
|
|
219
|
+
{
|
|
220
|
+
hint: "The encrypted value should be in the format: encrypted:v1:aes-256-gcm:{iv}:{authTag}:{ciphertext}"
|
|
221
|
+
}
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
const ivB64 = parts[0];
|
|
225
|
+
const authTagB64 = parts[1];
|
|
226
|
+
const ciphertextB64 = parts[2];
|
|
227
|
+
if (ivB64 === void 0 || authTagB64 === void 0 || ciphertextB64 === void 0) {
|
|
228
|
+
throw new EncryptionError(
|
|
229
|
+
"Invalid encrypted value: unexpected component structure",
|
|
230
|
+
{ hint: "The encrypted data may be corrupted. Try re-encrypting the value." }
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
if (ivB64.length === 0 || authTagB64.length === 0 || ciphertextB64.length === 0) {
|
|
234
|
+
throw new EncryptionError(
|
|
235
|
+
"Invalid encrypted value: one or more base64 components are empty",
|
|
236
|
+
{ hint: "The encrypted data may be corrupted. Try re-encrypting the value." }
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
const iv = base64ToBuffer(ivB64);
|
|
241
|
+
const authTag = base64ToBuffer(authTagB64);
|
|
242
|
+
const ciphertext = base64ToBuffer(ciphertextB64);
|
|
243
|
+
if (iv.length !== IV_LENGTH) {
|
|
244
|
+
throw new EncryptionError(
|
|
245
|
+
`Invalid IV length: expected ${IV_LENGTH} bytes, got ${iv.length}`,
|
|
246
|
+
{ hint: "The encrypted data may be corrupted or was produced by a different version of ultraenv." }
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
if (authTag.length !== AUTH_TAG_LENGTH) {
|
|
250
|
+
throw new EncryptionError(
|
|
251
|
+
`Invalid auth tag length: expected ${AUTH_TAG_LENGTH} bytes, got ${authTag.length}`,
|
|
252
|
+
{ hint: "The encrypted data may be corrupted." }
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
const plaintext = decrypt(key, iv, authTag, ciphertext);
|
|
256
|
+
return plaintext.toString("utf-8");
|
|
257
|
+
} catch (error) {
|
|
258
|
+
if (error instanceof EncryptionError) throw error;
|
|
259
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
260
|
+
if (message.includes("Invalid") || message.includes("base64")) {
|
|
261
|
+
throw new EncryptionError(
|
|
262
|
+
"Invalid base64 encoding in encrypted value",
|
|
263
|
+
{ hint: "The encrypted data may be corrupted. Ensure the value was not modified." }
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
throw new EncryptionError(
|
|
267
|
+
"Failed to decrypt value. The key may be incorrect or the ciphertext was tampered with.",
|
|
268
|
+
/* v8 ignore start */
|
|
269
|
+
{ cause: error instanceof Error ? error : void 0 }
|
|
270
|
+
/* v8 ignore stop */
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
function isEncryptedValue(value) {
|
|
275
|
+
if (value.length < ENCRYPTED_PREFIX.length) return false;
|
|
276
|
+
return value.startsWith(ENCRYPTED_PREFIX);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export {
|
|
280
|
+
deriveKey,
|
|
281
|
+
bufferToBase64,
|
|
282
|
+
base64ToBuffer,
|
|
283
|
+
encryptEnvironment,
|
|
284
|
+
decryptEnvironment,
|
|
285
|
+
encryptValue,
|
|
286
|
+
decryptValue,
|
|
287
|
+
isEncryptedValue
|
|
288
|
+
};
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isSecretKey,
|
|
3
|
+
isSecretLike,
|
|
4
|
+
maskValue
|
|
5
|
+
} from "./chunk-TE7HPLA6.js";
|
|
6
|
+
import {
|
|
7
|
+
parseEnvFile
|
|
8
|
+
} from "./chunk-HFXQGJY3.js";
|
|
9
|
+
import {
|
|
10
|
+
exists,
|
|
11
|
+
readFile
|
|
12
|
+
} from "./chunk-3VYXPTYV.js";
|
|
13
|
+
import {
|
|
14
|
+
FileSystemError
|
|
15
|
+
} from "./chunk-5G2DU52U.js";
|
|
16
|
+
|
|
17
|
+
// src/environments/comparator.ts
|
|
18
|
+
import { resolve, join } from "path";
|
|
19
|
+
function parseEnvironment(content) {
|
|
20
|
+
const parsed = parseEnvFile(content);
|
|
21
|
+
const vars = {};
|
|
22
|
+
const keys = [];
|
|
23
|
+
for (const envVar of parsed.vars) {
|
|
24
|
+
if (!(envVar.key in vars)) {
|
|
25
|
+
vars[envVar.key] = envVar.value;
|
|
26
|
+
keys.push(envVar.key);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return { vars, keys };
|
|
30
|
+
}
|
|
31
|
+
function isSecretVar(key, value) {
|
|
32
|
+
if (isSecretKey(key)) return true;
|
|
33
|
+
if (value.length > 0 && isSecretLike(value)) return true;
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
var DANGER_PATTERNS = [
|
|
37
|
+
{
|
|
38
|
+
pattern: /^(SECURITY|CSRF|CORS|AUTH|SESSION|COOKIE)/i,
|
|
39
|
+
message: "Security configuration differs between environments",
|
|
40
|
+
severity: "critical"
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
pattern: /^(DEBUG|LOG_LEVEL|VERBOSE|TRACE)/i,
|
|
44
|
+
message: "Debug/logging settings differ \u2014 debug mode may be enabled in production",
|
|
45
|
+
severity: "high"
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
pattern: /^(ALLOWED_ORIGINS|CORS_ORIGIN)/i,
|
|
49
|
+
message: "CORS origin configuration differs \u2014 may expose to unintended origins",
|
|
50
|
+
severity: "high"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
pattern: /^(DATABASE_URL|MONGO_URL|REDIS_URL)/i,
|
|
54
|
+
message: "Database connection differs \u2014 pointing to different databases",
|
|
55
|
+
severity: "medium"
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
pattern: /^(API_URL|BASE_URL|BACKEND_URL)/i,
|
|
59
|
+
message: "API endpoint differs \u2014 may point to wrong server",
|
|
60
|
+
severity: "medium"
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
pattern: /^(SSL|TLS|HTTPS|CERT|KEY|ENCRYPTION)/i,
|
|
64
|
+
message: "SSL/TLS configuration differs \u2014 security may be weakened",
|
|
65
|
+
severity: "critical"
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
pattern: /^(RATE_LIMIT|THROTTLE|MAX_REQUESTS)/i,
|
|
69
|
+
message: "Rate limiting differs \u2014 may be too permissive",
|
|
70
|
+
severity: "low"
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
pattern: /^(NODE_ENV|APP_ENV|ENVIRONMENT)/i,
|
|
74
|
+
message: "Environment mode differs",
|
|
75
|
+
severity: "high"
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
// Detect when a security feature is disabled in one env but enabled in another
|
|
79
|
+
pattern: /^(.*_ENABLED|.*_DISABLED|.*_ACTIVE|.*_PROTECTED)/i,
|
|
80
|
+
message: "Feature flag/security toggle differs between environments",
|
|
81
|
+
severity: "medium"
|
|
82
|
+
}
|
|
83
|
+
];
|
|
84
|
+
function detectDanger(key, value1, value2) {
|
|
85
|
+
for (const danger of DANGER_PATTERNS) {
|
|
86
|
+
if (danger.pattern.test(key)) {
|
|
87
|
+
return {
|
|
88
|
+
key,
|
|
89
|
+
message: danger.message,
|
|
90
|
+
severity: danger.severity,
|
|
91
|
+
value1: isSecretVar(key, value1) ? maskValue(value1) : value1,
|
|
92
|
+
value2: isSecretVar(key, value2) ? maskValue(value2) : value2
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const urlPattern = /^https?:\/\//i;
|
|
97
|
+
if (urlPattern.test(value1) && urlPattern.test(value2)) {
|
|
98
|
+
try {
|
|
99
|
+
const url1 = new URL(value1);
|
|
100
|
+
const url2 = new URL(value2);
|
|
101
|
+
if (url1.hostname !== url2.hostname) {
|
|
102
|
+
return {
|
|
103
|
+
key,
|
|
104
|
+
message: `URL host differs: "${url1.hostname}" vs "${url2.hostname}"`,
|
|
105
|
+
severity: "medium",
|
|
106
|
+
value1: isSecretVar(key, value1) ? maskValue(value1) : value1,
|
|
107
|
+
value2: isSecretVar(key, value2) ? maskValue(value2) : value2
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if ((value1.toLowerCase() === "true" || value1 === "1") && (value2.toLowerCase() === "false" || value2 === "0")) {
|
|
114
|
+
return {
|
|
115
|
+
key,
|
|
116
|
+
message: `Feature toggled off: "${key}" changed from enabled to disabled`,
|
|
117
|
+
severity: "medium",
|
|
118
|
+
value1,
|
|
119
|
+
value2
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
async function compareEnvironments(env1, env2, cwd, _schema) {
|
|
125
|
+
const baseDir = resolve(cwd ?? process.cwd());
|
|
126
|
+
const env1Path = join(baseDir, `.env.${env1}`);
|
|
127
|
+
const env2Path = join(baseDir, `.env.${env2}`);
|
|
128
|
+
const resolvedEnv1Path = env1.includes("/") || env1.includes("\\") ? resolve(env1) : env1Path;
|
|
129
|
+
const resolvedEnv2Path = env2.includes("/") || env2.includes("\\") ? resolve(env2) : env2Path;
|
|
130
|
+
if (!await exists(resolvedEnv1Path)) {
|
|
131
|
+
throw new FileSystemError(
|
|
132
|
+
`Environment file not found: "${resolvedEnv1Path}"`,
|
|
133
|
+
{
|
|
134
|
+
path: resolvedEnv1Path,
|
|
135
|
+
operation: "read",
|
|
136
|
+
hint: `Ensure ".env.${env1}" exists in the project directory.`
|
|
137
|
+
}
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
if (!await exists(resolvedEnv2Path)) {
|
|
141
|
+
throw new FileSystemError(
|
|
142
|
+
`Environment file not found: "${resolvedEnv2Path}"`,
|
|
143
|
+
{
|
|
144
|
+
path: resolvedEnv2Path,
|
|
145
|
+
operation: "read",
|
|
146
|
+
hint: `Ensure ".env.${env2}" exists in the project directory.`
|
|
147
|
+
}
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
const env1Content = await readFile(resolvedEnv1Path);
|
|
151
|
+
const env2Content = await readFile(resolvedEnv2Path);
|
|
152
|
+
const env1Parsed = parseEnvironment(env1Content);
|
|
153
|
+
const env2Parsed = parseEnvironment(env2Content);
|
|
154
|
+
return compareEnvironmentVars(env1, env2, env1Parsed, env2Parsed);
|
|
155
|
+
}
|
|
156
|
+
function compareEnvironmentVars(env1Name, env2Name, env1Parsed, env2Parsed) {
|
|
157
|
+
const env1Keys = new Set(env1Parsed.keys);
|
|
158
|
+
const env2Keys = new Set(env2Parsed.keys);
|
|
159
|
+
const onlyInEnv1 = [];
|
|
160
|
+
const onlyInEnv2 = [];
|
|
161
|
+
const different = [];
|
|
162
|
+
const same = [];
|
|
163
|
+
const warnings = [];
|
|
164
|
+
for (const key of env1Keys) {
|
|
165
|
+
if (!env2Keys.has(key)) {
|
|
166
|
+
const value = env1Parsed.vars[key] ?? "";
|
|
167
|
+
onlyInEnv1.push({
|
|
168
|
+
key,
|
|
169
|
+
value1: isSecretVar(key, value) ? maskValue(value) : value,
|
|
170
|
+
value2: "",
|
|
171
|
+
isSecret: isSecretVar(key, value)
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
for (const key of env2Keys) {
|
|
176
|
+
if (!env1Keys.has(key)) {
|
|
177
|
+
const value = env2Parsed.vars[key] ?? "";
|
|
178
|
+
onlyInEnv2.push({
|
|
179
|
+
key,
|
|
180
|
+
value1: "",
|
|
181
|
+
value2: isSecretVar(key, value) ? maskValue(value) : value,
|
|
182
|
+
isSecret: isSecretVar(key, value)
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
for (const key of env1Keys) {
|
|
187
|
+
if (!env2Keys.has(key)) continue;
|
|
188
|
+
const value1 = env1Parsed.vars[key] ?? "";
|
|
189
|
+
const value2 = env2Parsed.vars[key] ?? "";
|
|
190
|
+
if (value1 === value2) {
|
|
191
|
+
same.push(key);
|
|
192
|
+
} else {
|
|
193
|
+
const isSecret = isSecretVar(key, value1) || isSecretVar(key, value2);
|
|
194
|
+
different.push({
|
|
195
|
+
key,
|
|
196
|
+
value1: isSecret ? maskValue(value1) : value1,
|
|
197
|
+
value2: isSecret ? maskValue(value2) : value2,
|
|
198
|
+
isSecret
|
|
199
|
+
});
|
|
200
|
+
const danger = detectDanger(key, value1, value2);
|
|
201
|
+
if (danger !== null) {
|
|
202
|
+
warnings.push(danger);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
onlyInEnv1.sort((a, b) => a.key.localeCompare(b.key));
|
|
207
|
+
onlyInEnv2.sort((a, b) => a.key.localeCompare(b.key));
|
|
208
|
+
different.sort((a, b) => a.key.localeCompare(b.key));
|
|
209
|
+
same.sort();
|
|
210
|
+
warnings.sort((a, b) => {
|
|
211
|
+
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
212
|
+
const diff = severityOrder[a.severity] - severityOrder[b.severity];
|
|
213
|
+
return diff !== 0 ? diff : a.key.localeCompare(b.key);
|
|
214
|
+
});
|
|
215
|
+
return {
|
|
216
|
+
env1Name,
|
|
217
|
+
env2Name,
|
|
218
|
+
onlyInEnv1,
|
|
219
|
+
onlyInEnv2,
|
|
220
|
+
different,
|
|
221
|
+
same,
|
|
222
|
+
warnings
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
function formatComparison(comparison) {
|
|
226
|
+
const lines = [];
|
|
227
|
+
lines.push(`Comparing: ${comparison.env1Name} vs ${comparison.env2Name}`);
|
|
228
|
+
lines.push("\u2500".repeat(50));
|
|
229
|
+
if (comparison.warnings.length > 0) {
|
|
230
|
+
lines.push("");
|
|
231
|
+
lines.push(`\u26A0 ${comparison.warnings.length} warning(s):`);
|
|
232
|
+
for (const warning of comparison.warnings) {
|
|
233
|
+
const icon = warning.severity === "critical" ? "\u{1F534}" : warning.severity === "high" ? "\u{1F7E0}" : warning.severity === "medium" ? "\u{1F7E1}" : "\u{1F535}";
|
|
234
|
+
lines.push(` ${icon} ${warning.key}: ${warning.message}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (comparison.onlyInEnv1.length > 0) {
|
|
238
|
+
lines.push("");
|
|
239
|
+
lines.push(`Only in ${comparison.env1Name} (${comparison.onlyInEnv1.length}):`);
|
|
240
|
+
for (const diff of comparison.onlyInEnv1) {
|
|
241
|
+
lines.push(` - ${diff.key}${diff.isSecret ? " [SECRET]" : ""} = ${diff.value1 || "(empty)"}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (comparison.onlyInEnv2.length > 0) {
|
|
245
|
+
lines.push("");
|
|
246
|
+
lines.push(`Only in ${comparison.env2Name} (${comparison.onlyInEnv2.length}):`);
|
|
247
|
+
for (const diff of comparison.onlyInEnv2) {
|
|
248
|
+
lines.push(` + ${diff.key}${diff.isSecret ? " [SECRET]" : ""} = ${diff.value2 || "(empty)"}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (comparison.different.length > 0) {
|
|
252
|
+
lines.push("");
|
|
253
|
+
lines.push(`Different (${comparison.different.length}):`);
|
|
254
|
+
for (const diff of comparison.different) {
|
|
255
|
+
lines.push(` ~ ${diff.key}${diff.isSecret ? " [SECRET]" : ""}`);
|
|
256
|
+
lines.push(` ${comparison.env1Name}: ${diff.value1 || "(empty)"}`);
|
|
257
|
+
lines.push(` ${comparison.env2Name}: ${diff.value2 || "(empty)"}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (comparison.same.length > 0) {
|
|
261
|
+
lines.push("");
|
|
262
|
+
lines.push(`Same (${comparison.same.length}): ${comparison.same.join(", ")}`);
|
|
263
|
+
}
|
|
264
|
+
return lines.join("\n");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export {
|
|
268
|
+
compareEnvironments,
|
|
269
|
+
formatComparison
|
|
270
|
+
};
|