@blamejs/core 0.7.18 → 0.7.19
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/CHANGELOG.md +425 -423
- package/README.md +150 -150
- package/bin/blamejs.js +0 -0
- package/index.js +310 -308
- package/lib/api-key.js +660 -660
- package/lib/api-snapshot.js +338 -338
- package/lib/app-shutdown.js +385 -385
- package/lib/app.js +365 -365
- package/lib/archive.js +250 -250
- package/lib/atomic-file.js +544 -544
- package/lib/audit-chain.js +177 -177
- package/lib/audit-sign.js +344 -344
- package/lib/audit-tools.js +677 -677
- package/lib/audit.js +766 -766
- package/lib/auth/jwt-external.js +365 -0
- package/lib/auth/jwt.js +337 -311
- package/lib/auth/lockout.js +436 -436
- package/lib/auth/oauth.js +721 -721
- package/lib/auth/passkey.js +181 -181
- package/lib/auth/password.js +628 -594
- package/lib/backup/bundle.js +217 -217
- package/lib/backup/crypto.js +176 -176
- package/lib/backup/index.js +515 -515
- package/lib/backup/manifest.js +282 -282
- package/lib/break-glass.js +1338 -1338
- package/lib/bundler.js +441 -441
- package/lib/cache-redis.js +256 -256
- package/lib/cache.js +1206 -1206
- package/lib/canonical-json.js +115 -115
- package/lib/chain-writer.js +234 -234
- package/lib/cli-helpers.js +206 -206
- package/lib/cli.js +2334 -2334
- package/lib/cluster-provider-db.js +317 -317
- package/lib/cluster-storage.js +226 -226
- package/lib/cluster.js +703 -703
- package/lib/config-drift.js +301 -301
- package/lib/consent.js +222 -222
- package/lib/constants.js +191 -191
- package/lib/cookies.js +315 -315
- package/lib/credential-hash.js +322 -322
- package/lib/crypto.js +266 -266
- package/lib/csv.js +275 -275
- package/lib/db-declare-row-policy.js +267 -267
- package/lib/db-declare-view.js +420 -420
- package/lib/db-query.js +406 -406
- package/lib/db-schema.js +319 -319
- package/lib/db.js +1288 -1288
- package/lib/deprecate.js +222 -222
- package/lib/dev.js +335 -335
- package/lib/dual-control.js +473 -473
- package/lib/error-page.js +420 -420
- package/lib/external-db-migrate.js +441 -441
- package/lib/external-db.js +1061 -1061
- package/lib/file-type.js +273 -273
- package/lib/forms.js +422 -422
- package/lib/framework-error.js +293 -293
- package/lib/framework-schema.js +717 -717
- package/lib/handlers.js +350 -350
- package/lib/http-client-cookie-jar.js +508 -508
- package/lib/http-client.js +1195 -1195
- package/lib/i18n.js +878 -878
- package/lib/jobs.js +185 -185
- package/lib/log-stream-cloudwatch.js +369 -369
- package/lib/log-stream-local.js +146 -146
- package/lib/log-stream-otlp-grpc.js +410 -410
- package/lib/log-stream-otlp.js +286 -286
- package/lib/log-stream-syslog.js +302 -302
- package/lib/log-stream-webhook.js +199 -199
- package/lib/log-stream.js +330 -330
- package/lib/log.js +500 -500
- package/lib/mail-bounce.js +528 -528
- package/lib/mail-dkim.js +369 -369
- package/lib/mail.js +981 -981
- package/lib/metrics.js +683 -683
- package/lib/middleware/api-encrypt.js +936 -936
- package/lib/middleware/attach-user.js +157 -157
- package/lib/middleware/bearer-auth.js +152 -0
- package/lib/middleware/body-parser.js +1170 -1170
- package/lib/middleware/bot-guard.js +178 -178
- package/lib/middleware/compression.js +452 -452
- package/lib/middleware/cors.js +314 -314
- package/lib/middleware/csp-nonce.js +348 -348
- package/lib/middleware/csrf-protect.js +316 -316
- package/lib/middleware/db-role-for.js +264 -264
- package/lib/middleware/health.js +392 -392
- package/lib/middleware/index.js +82 -79
- package/lib/middleware/rate-limit.js +358 -358
- package/lib/middleware/request-id.js +61 -61
- package/lib/middleware/request-log.js +168 -168
- package/lib/middleware/require-auth.js +104 -104
- package/lib/middleware/security-headers.js +116 -116
- package/lib/middleware/sse.js +166 -166
- package/lib/migrations.js +383 -383
- package/lib/mtls-ca.js +518 -518
- package/lib/mtls-engine-default.js +481 -481
- package/lib/network-dns.js +632 -632
- package/lib/network-heartbeat.js +290 -290
- package/lib/network-nts.js +574 -574
- package/lib/network-proxy.js +265 -265
- package/lib/network-tls.js +328 -328
- package/lib/network.js +233 -233
- package/lib/notify.js +612 -612
- package/lib/ntp-check.js +229 -229
- package/lib/numeric-bounds.js +111 -111
- package/lib/object-store/azure-blob-bucket-ops.js +349 -349
- package/lib/object-store/azure-blob.js +488 -488
- package/lib/object-store/gcs-bucket-ops.js +351 -351
- package/lib/object-store/gcs.js +519 -519
- package/lib/object-store/http-put.js +153 -153
- package/lib/object-store/index.js +197 -197
- package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
- package/lib/object-store/sigv4.js +903 -903
- package/lib/observability.js +151 -151
- package/lib/otel-export.js +269 -269
- package/lib/pagination.js +464 -464
- package/lib/parsers/index.js +80 -80
- package/lib/parsers/safe-env.js +642 -642
- package/lib/parsers/safe-ini.js +292 -292
- package/lib/parsers/safe-toml.js +784 -784
- package/lib/parsers/safe-xml.js +390 -390
- package/lib/parsers/safe-yaml.js +1015 -1015
- package/lib/permissions.js +708 -708
- package/lib/pqc-agent.js +87 -87
- package/lib/pqc-gate.js +279 -279
- package/lib/protobuf-encoder.js +190 -190
- package/lib/protocol-dispatcher.js +161 -161
- package/lib/pubsub-redis.js +167 -167
- package/lib/pubsub.js +429 -429
- package/lib/queue-local.js +476 -476
- package/lib/queue-redis.js +745 -745
- package/lib/queue-sqs.js +319 -319
- package/lib/queue.js +695 -695
- package/lib/redis-client.js +519 -519
- package/lib/request-helpers.js +340 -340
- package/lib/restore-bundle.js +237 -237
- package/lib/restore-rollback.js +259 -259
- package/lib/restore.js +409 -409
- package/lib/retry.js +376 -376
- package/lib/router.js +748 -748
- package/lib/safe-async.js +735 -735
- package/lib/safe-buffer.js +237 -237
- package/lib/safe-json.js +541 -541
- package/lib/safe-schema.js +1266 -1266
- package/lib/safe-url.js +159 -159
- package/lib/scheduler.js +706 -706
- package/lib/security-assert.js +373 -373
- package/lib/seeders.js +618 -618
- package/lib/session.js +535 -478
- package/lib/slug.js +269 -269
- package/lib/ssrf-guard.js +401 -401
- package/lib/storage.js +471 -471
- package/lib/subject.js +281 -281
- package/lib/template.js +791 -791
- package/lib/testing.js +798 -798
- package/lib/time.js +310 -310
- package/lib/totp.js +302 -302
- package/lib/tracing.js +494 -494
- package/lib/uuid.js +132 -132
- package/lib/validate-opts.js +340 -340
- package/lib/vault/index.js +308 -308
- package/lib/vault/rotate.js +784 -784
- package/lib/vault/wrap.js +296 -296
- package/lib/vendor/noble-ciphers.cjs +9 -9
- package/lib/webhook.js +595 -595
- package/lib/websocket.js +1048 -1048
- package/package.json +77 -77
- package/sbom.cyclonedx.json +7 -7
package/lib/parsers/safe-env.js
CHANGED
|
@@ -1,642 +1,642 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
/**
|
|
3
|
-
* Security-focused .env loader.
|
|
4
|
-
*
|
|
5
|
-
* Parses dotenv-style files with the framework's standard guards plus
|
|
6
|
-
* change tracking and typo detection — env-var changes are
|
|
7
|
-
* operationally significant and the framework records them through the
|
|
8
|
-
* audit chain.
|
|
9
|
-
*
|
|
10
|
-
* The dotenv ecosystem has a long history of subtle footguns: implicit
|
|
11
|
-
* `$VAR` expansion that surprises consumers reading process.env later,
|
|
12
|
-
* silent overwrite of operator-set values, ambiguous quote handling
|
|
13
|
-
* across libraries, comment-vs-value confusion. This loader rejects or
|
|
14
|
-
* makes explicit each of those.
|
|
15
|
-
*
|
|
16
|
-
* Public API:
|
|
17
|
-
* env.parse(input, opts?) object | throws SafeEnvError
|
|
18
|
-
* env.load(filepath, opts?) { values, diff } | throws
|
|
19
|
-
* env.SafeEnvError error class
|
|
20
|
-
*
|
|
21
|
-
* env.parse returns the bare key/value object — pure, no I/O, no
|
|
22
|
-
* audit, no schema. Use it when you have the file content already in
|
|
23
|
-
* memory or when you want to compose `.env` parsing into a larger flow.
|
|
24
|
-
*
|
|
25
|
-
* env.load reads the file via atomicFile.readSync, applies schema
|
|
26
|
-
* validation, computes a diff against the previous snapshot, optionally
|
|
27
|
-
* applies values to process.env, and writes a system.config.changed
|
|
28
|
-
* audit row when the diff is non-empty. Followers in cluster mode see
|
|
29
|
-
* their own config; the audit write is leader-gated and is silently
|
|
30
|
-
* skipped on followers (their own audit row would have nothing to
|
|
31
|
-
* anchor to anyway since they don't write the chain).
|
|
32
|
-
*
|
|
33
|
-
* What is REJECTED:
|
|
34
|
-
* - Tabs at start of value (real spaces are fine; tabs in unquoted
|
|
35
|
-
* values are almost always a copy-paste accident)
|
|
36
|
-
* - $VAR / ${VAR} expansion (consumers reading process.env later
|
|
37
|
-
* would not know whether the value was literal or expanded —
|
|
38
|
-
* consistency over convenience). Operators who want expansion
|
|
39
|
-
* can do it themselves before passing values to the framework.
|
|
40
|
-
* - Multiline unquoted values (you must double-quote a value
|
|
41
|
-
* containing newlines). Common dotenv libraries auto-join lines;
|
|
42
|
-
* the heuristic regularly produces wrong results.
|
|
43
|
-
* - Keys outside `^[A-Z_][A-Z0-9_]*$` by default (POSIX env-var
|
|
44
|
-
* shape). Operators can override via `{ keyShape: /…/ }`.
|
|
45
|
-
* - Empty key (`=value` with no key)
|
|
46
|
-
*
|
|
47
|
-
* Defaults:
|
|
48
|
-
* maxBytes: 64 KiB (.env files are config; cap is small)
|
|
49
|
-
* maxKeys: 1000
|
|
50
|
-
* keyShape: /^[A-Z_][A-Z0-9_]*$/
|
|
51
|
-
* applyToProcess: false (return values; do not mutate process.env)
|
|
52
|
-
* allowOverwrite: false (only relevant when applyToProcess: true)
|
|
53
|
-
* rejectUnknown: false (warn on unregistered keys; do not refuse)
|
|
54
|
-
* audit: true (record diff in audit chain)
|
|
55
|
-
*/
|
|
56
|
-
|
|
57
|
-
var C = require("../constants");
|
|
58
|
-
var atomicFile = require("../atomic-file");
|
|
59
|
-
var lazyRequire = require("../lazy-require");
|
|
60
|
-
var numericBounds = require("../numeric-bounds");
|
|
61
|
-
var safeBuffer = require("../safe-buffer");
|
|
62
|
-
var safeJson = require("../safe-json");
|
|
63
|
-
var { FrameworkError } = require("../framework-error");
|
|
64
|
-
var { boot } = require("../log");
|
|
65
|
-
|
|
66
|
-
// Lazy require for audit — eager-loading audit at top of file forms a
|
|
67
|
-
// load cycle: vault.init → passphrase-source → safe-env → audit →
|
|
68
|
-
// (transitively) vault, which leaves safe-env's module.exports
|
|
69
|
-
// half-built when vault first reaches readVar. Defer audit resolution
|
|
70
|
-
// until the first emit-driven call.
|
|
71
|
-
var auditModule = lazyRequire(function () { return require("../audit"); });
|
|
72
|
-
|
|
73
|
-
var log = boot("env");
|
|
74
|
-
|
|
75
|
-
// parseInt radix — named so the call site doesn't carry a bare 16
|
|
76
|
-
// integer literal that reads as a byte count.
|
|
77
|
-
var RADIX_HEX = 0x10;
|
|
78
|
-
|
|
79
|
-
class SafeEnvError extends FrameworkError {
|
|
80
|
-
constructor(message, code, line) {
|
|
81
|
-
super(line != null ? message + " at line " + line : message);
|
|
82
|
-
this.name = "SafeEnvError";
|
|
83
|
-
this.code = code || "env/invalid";
|
|
84
|
-
this.line = line == null ? null : line;
|
|
85
|
-
this.isSafeEnvError = true;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
var DEFAULTS = {
|
|
90
|
-
maxBytes: C.BYTES.kib(64),
|
|
91
|
-
maxKeys: 1_000,
|
|
92
|
-
keyShape: /^[A-Z_][A-Z0-9_]*$/,
|
|
93
|
-
applyToProcess: false,
|
|
94
|
-
allowOverwrite: false,
|
|
95
|
-
rejectUnknown: false,
|
|
96
|
-
audit: true,
|
|
97
|
-
};
|
|
98
|
-
|
|
99
|
-
var POISONED_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
100
|
-
|
|
101
|
-
// ---- parse ----
|
|
102
|
-
|
|
103
|
-
function parse(input, opts) {
|
|
104
|
-
opts = opts || {};
|
|
105
|
-
// Validate operator-supplied numeric opts via lib/numeric-bounds —
|
|
106
|
-
// Infinity / NaN / negative / non-integer all bypass the `> 0` shape
|
|
107
|
-
// and silently lift the DoS cap they were meant to enforce.
|
|
108
|
-
if (opts.maxBytes !== undefined && !numericBounds.isPositiveFiniteInt(opts.maxBytes)) {
|
|
109
|
-
throw new SafeEnvError("env.parse: maxBytes must be a positive finite integer; got " +
|
|
110
|
-
numericBounds.shape(opts.maxBytes), "env/bad-opt");
|
|
111
|
-
}
|
|
112
|
-
if (opts.maxKeys !== undefined && !numericBounds.isPositiveFiniteInt(opts.maxKeys)) {
|
|
113
|
-
throw new SafeEnvError("env.parse: maxKeys must be a positive finite integer; got " +
|
|
114
|
-
numericBounds.shape(opts.maxKeys), "env/bad-opt");
|
|
115
|
-
}
|
|
116
|
-
var maxBytes = opts.maxBytes !== undefined
|
|
117
|
-
? Math.min(opts.maxBytes, C.BYTES.mib(1)) : DEFAULTS.maxBytes;
|
|
118
|
-
var maxKeys = opts.maxKeys !== undefined
|
|
119
|
-
? Math.min(opts.maxKeys, 100_000) : DEFAULTS.maxKeys;
|
|
120
|
-
var keyShape = opts.keyShape instanceof RegExp ? opts.keyShape : DEFAULTS.keyShape;
|
|
121
|
-
|
|
122
|
-
input = safeBuffer.normalizeText(input, {
|
|
123
|
-
maxBytes: maxBytes,
|
|
124
|
-
errorClass: SafeEnvError,
|
|
125
|
-
typeCode: "env/wrong-input-type",
|
|
126
|
-
sizeCode: "env/too-large",
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
var rawLines = input.split(/\r\n|\r|\n/);
|
|
130
|
-
var values = Object.create(null);
|
|
131
|
-
var seen = new Set();
|
|
132
|
-
|
|
133
|
-
for (var i = 0; i < rawLines.length; i++) {
|
|
134
|
-
var line = rawLines[i];
|
|
135
|
-
var lineNumber = i + 1;
|
|
136
|
-
// Trim leading whitespace (operators sometimes indent for readability)
|
|
137
|
-
var trimmed = line.replace(/^[ \t]+/, "");
|
|
138
|
-
if (trimmed.length === 0) continue;
|
|
139
|
-
if (trimmed.charAt(0) === "#") continue;
|
|
140
|
-
|
|
141
|
-
// Optional `export ` prefix (POSIX shell convention)
|
|
142
|
-
if (/^export\s+/.test(trimmed)) {
|
|
143
|
-
trimmed = trimmed.replace(/^export\s+/, "");
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
var eqIdx = trimmed.indexOf("=");
|
|
147
|
-
if (eqIdx < 0) {
|
|
148
|
-
throw new SafeEnvError("missing '=' separator", "env/bad-line", lineNumber);
|
|
149
|
-
}
|
|
150
|
-
var key = trimmed.substring(0, eqIdx).replace(safeBuffer.TRAILING_HSPACE_RE, "");
|
|
151
|
-
var rest = trimmed.substring(eqIdx + 1);
|
|
152
|
-
|
|
153
|
-
if (key.length === 0) {
|
|
154
|
-
throw new SafeEnvError("empty key", "env/empty-key", lineNumber);
|
|
155
|
-
}
|
|
156
|
-
if (POISONED_KEYS.has(key)) {
|
|
157
|
-
throw new SafeEnvError("forbidden key '" + key + "'", "env/poisoned-key", lineNumber);
|
|
158
|
-
}
|
|
159
|
-
if (!keyShape.test(key)) {
|
|
160
|
-
throw new SafeEnvError(
|
|
161
|
-
"key '" + key + "' does not match keyShape " + keyShape,
|
|
162
|
-
"env/bad-key-shape", lineNumber
|
|
163
|
-
);
|
|
164
|
-
}
|
|
165
|
-
if (seen.has(key)) {
|
|
166
|
-
throw new SafeEnvError("duplicate key '" + key + "'", "env/duplicate-key", lineNumber);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// Tab at start of value is almost always a paste accident
|
|
170
|
-
if (rest.charAt(0) === "\t") {
|
|
171
|
-
throw new SafeEnvError(
|
|
172
|
-
"tab at start of value (use spaces or quote the value)",
|
|
173
|
-
"env/tab-in-value", lineNumber
|
|
174
|
-
);
|
|
175
|
-
}
|
|
176
|
-
// Strip ONE leading space (operator-friendly: `KEY = value` → "value")
|
|
177
|
-
if (rest.charAt(0) === " ") rest = rest.substring(1);
|
|
178
|
-
|
|
179
|
-
var value;
|
|
180
|
-
if (rest.charAt(0) === '"') {
|
|
181
|
-
value = _decodeDoubleQuoted(rest, lineNumber);
|
|
182
|
-
} else if (rest.charAt(0) === "'") {
|
|
183
|
-
value = _decodeSingleQuoted(rest, lineNumber);
|
|
184
|
-
} else {
|
|
185
|
-
// Unquoted value: strip trailing whitespace + inline `# comment`.
|
|
186
|
-
// The comment marker MUST be preceded by whitespace to count
|
|
187
|
-
// (so a value like `KEY=color#red` keeps the literal `#`).
|
|
188
|
-
var commentMatch = rest.match(/^([^\s#]*(?:[ \t]+[^#\s]+)*)\s+#.*$/);
|
|
189
|
-
if (commentMatch) {
|
|
190
|
-
value = commentMatch[1].replace(safeBuffer.TRAILING_HSPACE_RE, "");
|
|
191
|
-
} else {
|
|
192
|
-
value = rest.replace(safeBuffer.TRAILING_HSPACE_RE, "");
|
|
193
|
-
}
|
|
194
|
-
// Reject `$VAR` style references — explicit error so operators
|
|
195
|
-
// see the policy rather than silently getting unexpanded text.
|
|
196
|
-
if (/\$(\{[A-Za-z_]|[A-Za-z_])/.test(value)) {
|
|
197
|
-
throw new SafeEnvError(
|
|
198
|
-
"$VAR / ${VAR} expansion not supported (escape with \\$ if literal, or quote and expand yourself)",
|
|
199
|
-
"env/expansion-banned", lineNumber
|
|
200
|
-
);
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
seen.add(key);
|
|
205
|
-
values[key] = value;
|
|
206
|
-
if (seen.size > maxKeys) {
|
|
207
|
-
throw new SafeEnvError("input exceeds maxKeys", "env/too-many-keys", lineNumber);
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
// Convert null-prototype to plain {} so JSON.stringify and for-in work.
|
|
212
|
-
var out = {};
|
|
213
|
-
for (var k in values) {
|
|
214
|
-
if (Object.prototype.hasOwnProperty.call(values, k)) out[k] = values[k];
|
|
215
|
-
}
|
|
216
|
-
return out;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
function _decodeDoubleQuoted(rest, lineNumber) {
|
|
220
|
-
// Find closing quote, handling \\ + \"
|
|
221
|
-
var i = 1;
|
|
222
|
-
var out = "";
|
|
223
|
-
while (i < rest.length) {
|
|
224
|
-
var ch = rest.charAt(i);
|
|
225
|
-
if (ch === "\\") {
|
|
226
|
-
var esc = rest.charAt(i + 1);
|
|
227
|
-
switch (esc) {
|
|
228
|
-
case '"': out += '"'; i += 2; continue;
|
|
229
|
-
case "\\": out += "\\"; i += 2; continue;
|
|
230
|
-
case "n": out += "\n"; i += 2; continue;
|
|
231
|
-
case "r": out += "\r"; i += 2; continue;
|
|
232
|
-
case "t": out += "\t"; i += 2; continue;
|
|
233
|
-
case "$": out += "$"; i += 2; continue; // literal $ escape
|
|
234
|
-
case "u": {
|
|
235
|
-
var hex = rest.substring(i + 2, i + 6);
|
|
236
|
-
if (!safeBuffer.isHex(hex, 4)) {
|
|
237
|
-
throw new SafeEnvError("bad \\u escape", "env/bad-escape", lineNumber);
|
|
238
|
-
}
|
|
239
|
-
out += String.fromCharCode(parseInt(hex, RADIX_HEX));
|
|
240
|
-
i += 6;
|
|
241
|
-
continue;
|
|
242
|
-
}
|
|
243
|
-
default:
|
|
244
|
-
throw new SafeEnvError("unknown escape '\\" + esc + "'", "env/bad-escape", lineNumber);
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
if (ch === '"') {
|
|
248
|
-
// Optional inline comment after closing quote — ignore.
|
|
249
|
-
return out;
|
|
250
|
-
}
|
|
251
|
-
if (ch === "$" && /^[{A-Za-z_]/.test(rest.charAt(i + 1) || "")) {
|
|
252
|
-
throw new SafeEnvError(
|
|
253
|
-
"$VAR / ${VAR} expansion not supported in double-quoted value (use \\$ for literal $)",
|
|
254
|
-
"env/expansion-banned", lineNumber
|
|
255
|
-
);
|
|
256
|
-
}
|
|
257
|
-
out += ch;
|
|
258
|
-
i += 1;
|
|
259
|
-
}
|
|
260
|
-
throw new SafeEnvError("unterminated double-quoted value", "env/unterminated-string", lineNumber);
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
function _decodeSingleQuoted(rest, lineNumber) {
|
|
264
|
-
// Single-quoted is fully literal: no escapes, no expansion. Closing
|
|
265
|
-
// quote is the first un-escaped `'` — but since there are no escapes,
|
|
266
|
-
// it's literally the next `'`.
|
|
267
|
-
var end = rest.indexOf("'", 1);
|
|
268
|
-
if (end < 0) {
|
|
269
|
-
throw new SafeEnvError("unterminated single-quoted value", "env/unterminated-string", lineNumber);
|
|
270
|
-
}
|
|
271
|
-
return rest.substring(1, end);
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
// ---- Schema validation + type coercion ----
|
|
275
|
-
//
|
|
276
|
-
// expected = { KEY: { type, sensitivity, default? } }
|
|
277
|
-
// type: "string" (default) | "number" | "boolean" | "json"
|
|
278
|
-
// sensitivity: "boot-only" | "runtime" | "breaking" (informational; used by diff)
|
|
279
|
-
// default: optional value to use when key is absent
|
|
280
|
-
|
|
281
|
-
function _coerceType(rawValue, type, key) {
|
|
282
|
-
if (type === "string" || type == null) return rawValue;
|
|
283
|
-
if (type === "number") {
|
|
284
|
-
if (rawValue === "") return null;
|
|
285
|
-
var n = Number(rawValue);
|
|
286
|
-
if (Number.isNaN(n)) {
|
|
287
|
-
throw new SafeEnvError("'" + rawValue + "' is not a number for key '" + key + "'",
|
|
288
|
-
"env/bad-type");
|
|
289
|
-
}
|
|
290
|
-
return n;
|
|
291
|
-
}
|
|
292
|
-
if (type === "boolean") {
|
|
293
|
-
// Only accept the unambiguous spellings — no yes/no/on/off
|
|
294
|
-
// (consistent with our YAML "Norway problem" stance).
|
|
295
|
-
if (rawValue === "true") return true;
|
|
296
|
-
if (rawValue === "false") return false;
|
|
297
|
-
throw new SafeEnvError(
|
|
298
|
-
"boolean key '" + key + "' must be 'true' or 'false' (got '" + rawValue + "')",
|
|
299
|
-
"env/bad-type"
|
|
300
|
-
);
|
|
301
|
-
}
|
|
302
|
-
if (type === "json") {
|
|
303
|
-
try { return safeJson.parse(rawValue); }
|
|
304
|
-
catch (e) {
|
|
305
|
-
throw new SafeEnvError("invalid JSON for key '" + key + "': " + e.message,
|
|
306
|
-
"env/bad-type");
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
throw new SafeEnvError("unknown type '" + type + "' for key '" + key + "'",
|
|
310
|
-
"env/bad-schema");
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
// ---- Levenshtein distance for typo detection ----
|
|
314
|
-
|
|
315
|
-
function _levenshtein(a, b) {
|
|
316
|
-
var m = a.length, n = b.length;
|
|
317
|
-
if (Math.abs(m - n) > 3) return 4; // early-exit: anything > 2 we don't care about
|
|
318
|
-
var prev = new Array(n + 1);
|
|
319
|
-
var curr = new Array(n + 1);
|
|
320
|
-
for (var j = 0; j <= n; j++) prev[j] = j;
|
|
321
|
-
for (var i = 1; i <= m; i++) {
|
|
322
|
-
curr[0] = i;
|
|
323
|
-
for (var k = 1; k <= n; k++) {
|
|
324
|
-
var cost = a.charAt(i - 1) === b.charAt(k - 1) ? 0 : 1;
|
|
325
|
-
curr[k] = Math.min(
|
|
326
|
-
prev[k] + 1, // deletion
|
|
327
|
-
curr[k - 1] + 1, // insertion
|
|
328
|
-
prev[k - 1] + cost // substitution
|
|
329
|
-
);
|
|
330
|
-
}
|
|
331
|
-
var tmp = prev; prev = curr; curr = tmp;
|
|
332
|
-
}
|
|
333
|
-
return prev[n];
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
function _detectSuspicious(values, expected) {
|
|
337
|
-
if (!expected) return [];
|
|
338
|
-
var expectedKeys = Object.keys(expected);
|
|
339
|
-
var expectedUpper = expectedKeys.map(function (k) { return k.toUpperCase(); });
|
|
340
|
-
var suspicious = [];
|
|
341
|
-
for (var key in values) {
|
|
342
|
-
if (Object.prototype.hasOwnProperty.call(expected, key)) continue; // registered
|
|
343
|
-
|
|
344
|
-
// Case-mismatch: an exact match exists if you upper-case
|
|
345
|
-
var upper = key.toUpperCase();
|
|
346
|
-
var caseIdx = expectedUpper.indexOf(upper);
|
|
347
|
-
if (caseIdx !== -1 && expectedKeys[caseIdx] !== key) {
|
|
348
|
-
suspicious.push({
|
|
349
|
-
key: key,
|
|
350
|
-
suggestion: expectedKeys[caseIdx],
|
|
351
|
-
reason: "case-mismatch",
|
|
352
|
-
});
|
|
353
|
-
continue;
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
// Levenshtein distance ≤ 2
|
|
357
|
-
var bestDist = Infinity;
|
|
358
|
-
var bestKey = null;
|
|
359
|
-
for (var j = 0; j < expectedKeys.length; j++) {
|
|
360
|
-
var d = _levenshtein(key, expectedKeys[j]);
|
|
361
|
-
if (d < bestDist) { bestDist = d; bestKey = expectedKeys[j]; }
|
|
362
|
-
}
|
|
363
|
-
if (bestDist <= 2 && bestKey) {
|
|
364
|
-
suspicious.push({
|
|
365
|
-
key: key,
|
|
366
|
-
suggestion: bestKey,
|
|
367
|
-
reason: "single-char-typo",
|
|
368
|
-
});
|
|
369
|
-
} else {
|
|
370
|
-
suspicious.push({ key: key, suggestion: null, reason: "unknown" });
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
return suspicious;
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
// ---- Diff computation ----
|
|
377
|
-
|
|
378
|
-
function _diff(prevValues, nextValues, expected) {
|
|
379
|
-
var added = [];
|
|
380
|
-
var removed = [];
|
|
381
|
-
var changed = [];
|
|
382
|
-
function _sensitivityOf(k) {
|
|
383
|
-
if (expected && expected[k] && expected[k].sensitivity) return expected[k].sensitivity;
|
|
384
|
-
return null;
|
|
385
|
-
}
|
|
386
|
-
for (var k in nextValues) {
|
|
387
|
-
if (!Object.prototype.hasOwnProperty.call(prevValues, k)) {
|
|
388
|
-
added.push(k);
|
|
389
|
-
} else if (prevValues[k] !== nextValues[k]) {
|
|
390
|
-
changed.push({ key: k, sensitivity: _sensitivityOf(k) });
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
for (var k2 in prevValues) {
|
|
394
|
-
if (!Object.prototype.hasOwnProperty.call(nextValues, k2)) {
|
|
395
|
-
removed.push(k2);
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
return { added: added, removed: removed, changed: changed };
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
// ---- load() ----
|
|
402
|
-
|
|
403
|
-
function load(filepath, opts) {
|
|
404
|
-
if (typeof filepath !== "string") {
|
|
405
|
-
throw new SafeEnvError("load requires a file path", "env/bad-arg");
|
|
406
|
-
}
|
|
407
|
-
opts = opts || {};
|
|
408
|
-
var applyToProcess = opts.applyToProcess === true;
|
|
409
|
-
var allowOverwrite = opts.allowOverwrite === true;
|
|
410
|
-
var rejectUnknown = opts.rejectUnknown === true;
|
|
411
|
-
var auditEnabled = opts.audit !== false;
|
|
412
|
-
var expected = opts.expected || null;
|
|
413
|
-
var allowChange = new Set((opts.allow || []).map(String));
|
|
414
|
-
|
|
415
|
-
var bytes = atomicFile.readSync(filepath, {
|
|
416
|
-
maxBytes: opts.maxBytes != null ? opts.maxBytes : DEFAULTS.maxBytes,
|
|
417
|
-
});
|
|
418
|
-
var rawValues = parse(bytes.toString("utf8"), {
|
|
419
|
-
maxBytes: opts.maxBytes,
|
|
420
|
-
maxKeys: opts.maxKeys,
|
|
421
|
-
keyShape: opts.keyShape,
|
|
422
|
-
});
|
|
423
|
-
|
|
424
|
-
// Schema-driven coercion + missing-required detection
|
|
425
|
-
var values = {};
|
|
426
|
-
if (expected) {
|
|
427
|
-
for (var k in expected) {
|
|
428
|
-
if (Object.prototype.hasOwnProperty.call(rawValues, k)) {
|
|
429
|
-
values[k] = _coerceType(rawValues[k], expected[k].type, k);
|
|
430
|
-
} else if ("default" in expected[k]) {
|
|
431
|
-
values[k] = expected[k].default;
|
|
432
|
-
} else if (expected[k].required === true) {
|
|
433
|
-
throw new SafeEnvError("required key '" + k + "' missing from " + filepath,
|
|
434
|
-
"env/missing-required");
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
// Also include unregistered keys in values so the operator's app can
|
|
438
|
-
// still read them; they just don't get coercion.
|
|
439
|
-
for (var k2 in rawValues) {
|
|
440
|
-
if (!Object.prototype.hasOwnProperty.call(values, k2)) {
|
|
441
|
-
values[k2] = rawValues[k2];
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
} else {
|
|
445
|
-
values = rawValues;
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
// Suspicious-key detection
|
|
449
|
-
var suspicious = _detectSuspicious(rawValues, expected);
|
|
450
|
-
if (rejectUnknown && suspicious.length > 0) {
|
|
451
|
-
var keys = suspicious.map(function (s) { return s.key; }).join(", ");
|
|
452
|
-
throw new SafeEnvError(
|
|
453
|
-
"rejectUnknown: unregistered keys present: " + keys,
|
|
454
|
-
"env/unknown-keys"
|
|
455
|
-
);
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
// Snapshot diff
|
|
459
|
-
var snapshotPath = opts.snapshotPath || null;
|
|
460
|
-
var prevValues = {};
|
|
461
|
-
if (snapshotPath && atomicFile.exists(snapshotPath)) {
|
|
462
|
-
try {
|
|
463
|
-
var snapBuf = atomicFile.readSync(snapshotPath);
|
|
464
|
-
prevValues = safeJson.parse(snapBuf) || {};
|
|
465
|
-
} catch (_e) { /* missing/corrupt snapshot → treat as empty */ }
|
|
466
|
-
}
|
|
467
|
-
var diff = _diff(prevValues, rawValues, expected);
|
|
468
|
-
diff.suspicious = suspicious;
|
|
469
|
-
|
|
470
|
-
// Refuse breaking changes unless explicitly allowed
|
|
471
|
-
if (expected) {
|
|
472
|
-
for (var i = 0; i < diff.changed.length; i++) {
|
|
473
|
-
var entry = diff.changed[i];
|
|
474
|
-
if (entry.sensitivity === "breaking" && !allowChange.has(entry.key)) {
|
|
475
|
-
throw new SafeEnvError(
|
|
476
|
-
"key '" + entry.key + "' is sensitivity:'breaking' — pass " +
|
|
477
|
-
"{ allow: ['" + entry.key + "'] } to acknowledge the change",
|
|
478
|
-
"env/breaking-change"
|
|
479
|
-
);
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
// Optionally apply to process.env
|
|
485
|
-
if (applyToProcess) {
|
|
486
|
-
for (var k3 in rawValues) {
|
|
487
|
-
if (Object.prototype.hasOwnProperty.call(process.env, k3) && !allowOverwrite) {
|
|
488
|
-
// Existing env var — leave untouched
|
|
489
|
-
continue;
|
|
490
|
-
}
|
|
491
|
-
process.env[k3] = rawValues[k3];
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
// Persist new snapshot (best-effort; failure to write doesn't crash
|
|
496
|
-
// the load — operators just lose change-tracking until next successful
|
|
497
|
-
// write).
|
|
498
|
-
if (snapshotPath) {
|
|
499
|
-
try {
|
|
500
|
-
atomicFile.writeSync(snapshotPath, JSON.stringify(rawValues), { fileMode: 0o600 });
|
|
501
|
-
} catch (_e) { /* best-effort */ }
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
// Audit hooks
|
|
505
|
-
if (auditEnabled && _hasNonEmptyDiff(diff)) {
|
|
506
|
-
_writeAuditRows(filepath, diff);
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
return { values: values, diff: diff };
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
function _hasNonEmptyDiff(d) {
|
|
513
|
-
return d.added.length > 0 || d.removed.length > 0 ||
|
|
514
|
-
d.changed.length > 0 || d.suspicious.length > 0;
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
function _writeAuditRows(filepath, diff) {
|
|
518
|
-
// Both audit.record and cluster.requireLeader sit downstream — if
|
|
519
|
-
// we're a follower, audit.record will throw NotLeaderError. Catch
|
|
520
|
-
// explicitly: a follower's local config-load shouldn't crash because
|
|
521
|
-
// the cluster's audit chain belongs to the leader.
|
|
522
|
-
var audit = auditModule(); // resolve the lazy-required audit module
|
|
523
|
-
|
|
524
|
-
function _safeRecord(action, metadata) {
|
|
525
|
-
try {
|
|
526
|
-
audit.emit({
|
|
527
|
-
actor: { kind: "system", id: "config-loader" },
|
|
528
|
-
action: action,
|
|
529
|
-
outcome: "success",
|
|
530
|
-
target: { kind: "config-file", id: filepath },
|
|
531
|
-
metadata: metadata,
|
|
532
|
-
});
|
|
533
|
-
} catch (e) {
|
|
534
|
-
if (e && e.code === "NOT_LEADER") return; // follower; expected
|
|
535
|
-
// Unexpected — but config-loading should not fail on audit failure
|
|
536
|
-
// (the audit chain is observability, not the loaded values).
|
|
537
|
-
// Surface via stderr so operators notice without crashing.
|
|
538
|
-
log.error("audit.record failed: " + e.message);
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
if (diff.added.length > 0 || diff.removed.length > 0 || diff.changed.length > 0) {
|
|
543
|
-
_safeRecord("system.config.changed", {
|
|
544
|
-
file: filepath,
|
|
545
|
-
added: diff.added,
|
|
546
|
-
removed: diff.removed,
|
|
547
|
-
changed: diff.changed,
|
|
548
|
-
});
|
|
549
|
-
}
|
|
550
|
-
if (diff.suspicious.length > 0) {
|
|
551
|
-
_safeRecord("system.config.suspicious", {
|
|
552
|
-
file: filepath,
|
|
553
|
-
suspicious: diff.suspicious,
|
|
554
|
-
});
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
// ---- readVar() — single process.env reader with schema validation ----
|
|
559
|
-
//
|
|
560
|
-
// Centralizes "read one process.env var, with size cap + type coercion +
|
|
561
|
-
// optional default + optional strip-after-read for secrets". Replaces
|
|
562
|
-
// scattered `process.env.BLAMEJS_*` reads across lib/* — every call site
|
|
563
|
-
// that previously did its own missing/empty/coerce handling now goes
|
|
564
|
-
// through this primitive.
|
|
565
|
-
//
|
|
566
|
-
// schema:
|
|
567
|
-
// type: "string" (default) | "number" | "boolean" | "json" | "buffer"
|
|
568
|
-
// required: bool — throw if missing/empty (default: false)
|
|
569
|
-
// default: fallback value when var is missing/empty (overrides required)
|
|
570
|
-
// maxBytes: cap on byte length (default 64 KiB; pass 4096 for secrets)
|
|
571
|
-
// strip: bool — `delete process.env[name]` after read (for secrets)
|
|
572
|
-
// enum: optional array of allowed raw string values (string type only)
|
|
573
|
-
|
|
574
|
-
var READVAR_DEFAULT_MAX_BYTES = C.BYTES.kib(64);
|
|
575
|
-
|
|
576
|
-
function readVar(name, schema) {
|
|
577
|
-
if (typeof name !== "string" || name.length === 0) {
|
|
578
|
-
throw new SafeEnvError("readVar requires a non-empty name", "env/bad-arg");
|
|
579
|
-
}
|
|
580
|
-
schema = schema || {};
|
|
581
|
-
var type = schema.type || "string";
|
|
582
|
-
var required = schema.required === true;
|
|
583
|
-
var hasDefault = "default" in schema;
|
|
584
|
-
if (schema.maxBytes !== undefined && !numericBounds.isPositiveFiniteInt(schema.maxBytes)) {
|
|
585
|
-
throw new SafeEnvError(
|
|
586
|
-
"readVar: maxBytes must be a positive finite integer; got " +
|
|
587
|
-
numericBounds.shape(schema.maxBytes),
|
|
588
|
-
"env/bad-opt"
|
|
589
|
-
);
|
|
590
|
-
}
|
|
591
|
-
var maxBytes = (schema.maxBytes !== undefined)
|
|
592
|
-
? schema.maxBytes : READVAR_DEFAULT_MAX_BYTES;
|
|
593
|
-
var strip = schema.strip === true;
|
|
594
|
-
|
|
595
|
-
var raw = process.env[name];
|
|
596
|
-
var present = raw !== undefined && raw !== null && raw !== "";
|
|
597
|
-
|
|
598
|
-
if (!present) {
|
|
599
|
-
if (hasDefault) return schema.default;
|
|
600
|
-
if (required) {
|
|
601
|
-
throw new SafeEnvError(name + " env var is not set or is empty", "env/missing-required");
|
|
602
|
-
}
|
|
603
|
-
return undefined;
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
// Size cap. Apply BEFORE strip so an oversized value still triggers
|
|
607
|
-
// the diagnostic (operator gets a clear error, not a silent truncation).
|
|
608
|
-
if (Buffer.byteLength(raw, "utf8") > maxBytes) {
|
|
609
|
-
throw new SafeEnvError(
|
|
610
|
-
name + " exceeds " + maxBytes + " byte limit",
|
|
611
|
-
"env/too-large"
|
|
612
|
-
);
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
// enum constraint (string type only, applied to RAW value before coercion)
|
|
616
|
-
if (Array.isArray(schema.enum) && schema.enum.indexOf(raw) === -1) {
|
|
617
|
-
throw new SafeEnvError(
|
|
618
|
-
name + "='" + raw + "' is not one of: " + schema.enum.join(", "),
|
|
619
|
-
"env/bad-value"
|
|
620
|
-
);
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
var value;
|
|
624
|
-
if (type === "buffer") {
|
|
625
|
-
value = Buffer.from(raw, "utf8");
|
|
626
|
-
} else {
|
|
627
|
-
value = _coerceType(raw, type, name);
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
if (strip) {
|
|
631
|
-
delete process.env[name];
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
return value;
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
module.exports = {
|
|
638
|
-
parse: parse,
|
|
639
|
-
load: load,
|
|
640
|
-
readVar: readVar,
|
|
641
|
-
SafeEnvError: SafeEnvError,
|
|
642
|
-
};
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Security-focused .env loader.
|
|
4
|
+
*
|
|
5
|
+
* Parses dotenv-style files with the framework's standard guards plus
|
|
6
|
+
* change tracking and typo detection — env-var changes are
|
|
7
|
+
* operationally significant and the framework records them through the
|
|
8
|
+
* audit chain.
|
|
9
|
+
*
|
|
10
|
+
* The dotenv ecosystem has a long history of subtle footguns: implicit
|
|
11
|
+
* `$VAR` expansion that surprises consumers reading process.env later,
|
|
12
|
+
* silent overwrite of operator-set values, ambiguous quote handling
|
|
13
|
+
* across libraries, comment-vs-value confusion. This loader rejects or
|
|
14
|
+
* makes explicit each of those.
|
|
15
|
+
*
|
|
16
|
+
* Public API:
|
|
17
|
+
* env.parse(input, opts?) object | throws SafeEnvError
|
|
18
|
+
* env.load(filepath, opts?) { values, diff } | throws
|
|
19
|
+
* env.SafeEnvError error class
|
|
20
|
+
*
|
|
21
|
+
* env.parse returns the bare key/value object — pure, no I/O, no
|
|
22
|
+
* audit, no schema. Use it when you have the file content already in
|
|
23
|
+
* memory or when you want to compose `.env` parsing into a larger flow.
|
|
24
|
+
*
|
|
25
|
+
* env.load reads the file via atomicFile.readSync, applies schema
|
|
26
|
+
* validation, computes a diff against the previous snapshot, optionally
|
|
27
|
+
* applies values to process.env, and writes a system.config.changed
|
|
28
|
+
* audit row when the diff is non-empty. Followers in cluster mode see
|
|
29
|
+
* their own config; the audit write is leader-gated and is silently
|
|
30
|
+
* skipped on followers (their own audit row would have nothing to
|
|
31
|
+
* anchor to anyway since they don't write the chain).
|
|
32
|
+
*
|
|
33
|
+
* What is REJECTED:
|
|
34
|
+
* - Tabs at start of value (real spaces are fine; tabs in unquoted
|
|
35
|
+
* values are almost always a copy-paste accident)
|
|
36
|
+
* - $VAR / ${VAR} expansion (consumers reading process.env later
|
|
37
|
+
* would not know whether the value was literal or expanded —
|
|
38
|
+
* consistency over convenience). Operators who want expansion
|
|
39
|
+
* can do it themselves before passing values to the framework.
|
|
40
|
+
* - Multiline unquoted values (you must double-quote a value
|
|
41
|
+
* containing newlines). Common dotenv libraries auto-join lines;
|
|
42
|
+
* the heuristic regularly produces wrong results.
|
|
43
|
+
* - Keys outside `^[A-Z_][A-Z0-9_]*$` by default (POSIX env-var
|
|
44
|
+
* shape). Operators can override via `{ keyShape: /…/ }`.
|
|
45
|
+
* - Empty key (`=value` with no key)
|
|
46
|
+
*
|
|
47
|
+
* Defaults:
|
|
48
|
+
* maxBytes: 64 KiB (.env files are config; cap is small)
|
|
49
|
+
* maxKeys: 1000
|
|
50
|
+
* keyShape: /^[A-Z_][A-Z0-9_]*$/
|
|
51
|
+
* applyToProcess: false (return values; do not mutate process.env)
|
|
52
|
+
* allowOverwrite: false (only relevant when applyToProcess: true)
|
|
53
|
+
* rejectUnknown: false (warn on unregistered keys; do not refuse)
|
|
54
|
+
* audit: true (record diff in audit chain)
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
var C = require("../constants");
|
|
58
|
+
var atomicFile = require("../atomic-file");
|
|
59
|
+
var lazyRequire = require("../lazy-require");
|
|
60
|
+
var numericBounds = require("../numeric-bounds");
|
|
61
|
+
var safeBuffer = require("../safe-buffer");
|
|
62
|
+
var safeJson = require("../safe-json");
|
|
63
|
+
var { FrameworkError } = require("../framework-error");
|
|
64
|
+
var { boot } = require("../log");
|
|
65
|
+
|
|
66
|
+
// Lazy require for audit — eager-loading audit at top of file forms a
|
|
67
|
+
// load cycle: vault.init → passphrase-source → safe-env → audit →
|
|
68
|
+
// (transitively) vault, which leaves safe-env's module.exports
|
|
69
|
+
// half-built when vault first reaches readVar. Defer audit resolution
|
|
70
|
+
// until the first emit-driven call.
|
|
71
|
+
var auditModule = lazyRequire(function () { return require("../audit"); });
|
|
72
|
+
|
|
73
|
+
var log = boot("env");
|
|
74
|
+
|
|
75
|
+
// parseInt radix — named so the call site doesn't carry a bare 16
|
|
76
|
+
// integer literal that reads as a byte count.
|
|
77
|
+
var RADIX_HEX = 0x10;
|
|
78
|
+
|
|
79
|
+
class SafeEnvError extends FrameworkError {
|
|
80
|
+
constructor(message, code, line) {
|
|
81
|
+
super(line != null ? message + " at line " + line : message);
|
|
82
|
+
this.name = "SafeEnvError";
|
|
83
|
+
this.code = code || "env/invalid";
|
|
84
|
+
this.line = line == null ? null : line;
|
|
85
|
+
this.isSafeEnvError = true;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
var DEFAULTS = {
|
|
90
|
+
maxBytes: C.BYTES.kib(64),
|
|
91
|
+
maxKeys: 1_000,
|
|
92
|
+
keyShape: /^[A-Z_][A-Z0-9_]*$/,
|
|
93
|
+
applyToProcess: false,
|
|
94
|
+
allowOverwrite: false,
|
|
95
|
+
rejectUnknown: false,
|
|
96
|
+
audit: true,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
var POISONED_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
100
|
+
|
|
101
|
+
// ---- parse ----
|
|
102
|
+
|
|
103
|
+
function parse(input, opts) {
|
|
104
|
+
opts = opts || {};
|
|
105
|
+
// Validate operator-supplied numeric opts via lib/numeric-bounds —
|
|
106
|
+
// Infinity / NaN / negative / non-integer all bypass the `> 0` shape
|
|
107
|
+
// and silently lift the DoS cap they were meant to enforce.
|
|
108
|
+
if (opts.maxBytes !== undefined && !numericBounds.isPositiveFiniteInt(opts.maxBytes)) {
|
|
109
|
+
throw new SafeEnvError("env.parse: maxBytes must be a positive finite integer; got " +
|
|
110
|
+
numericBounds.shape(opts.maxBytes), "env/bad-opt");
|
|
111
|
+
}
|
|
112
|
+
if (opts.maxKeys !== undefined && !numericBounds.isPositiveFiniteInt(opts.maxKeys)) {
|
|
113
|
+
throw new SafeEnvError("env.parse: maxKeys must be a positive finite integer; got " +
|
|
114
|
+
numericBounds.shape(opts.maxKeys), "env/bad-opt");
|
|
115
|
+
}
|
|
116
|
+
var maxBytes = opts.maxBytes !== undefined
|
|
117
|
+
? Math.min(opts.maxBytes, C.BYTES.mib(1)) : DEFAULTS.maxBytes;
|
|
118
|
+
var maxKeys = opts.maxKeys !== undefined
|
|
119
|
+
? Math.min(opts.maxKeys, 100_000) : DEFAULTS.maxKeys;
|
|
120
|
+
var keyShape = opts.keyShape instanceof RegExp ? opts.keyShape : DEFAULTS.keyShape;
|
|
121
|
+
|
|
122
|
+
input = safeBuffer.normalizeText(input, {
|
|
123
|
+
maxBytes: maxBytes,
|
|
124
|
+
errorClass: SafeEnvError,
|
|
125
|
+
typeCode: "env/wrong-input-type",
|
|
126
|
+
sizeCode: "env/too-large",
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
var rawLines = input.split(/\r\n|\r|\n/);
|
|
130
|
+
var values = Object.create(null);
|
|
131
|
+
var seen = new Set();
|
|
132
|
+
|
|
133
|
+
for (var i = 0; i < rawLines.length; i++) {
|
|
134
|
+
var line = rawLines[i];
|
|
135
|
+
var lineNumber = i + 1;
|
|
136
|
+
// Trim leading whitespace (operators sometimes indent for readability)
|
|
137
|
+
var trimmed = line.replace(/^[ \t]+/, "");
|
|
138
|
+
if (trimmed.length === 0) continue;
|
|
139
|
+
if (trimmed.charAt(0) === "#") continue;
|
|
140
|
+
|
|
141
|
+
// Optional `export ` prefix (POSIX shell convention)
|
|
142
|
+
if (/^export\s+/.test(trimmed)) {
|
|
143
|
+
trimmed = trimmed.replace(/^export\s+/, "");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
var eqIdx = trimmed.indexOf("=");
|
|
147
|
+
if (eqIdx < 0) {
|
|
148
|
+
throw new SafeEnvError("missing '=' separator", "env/bad-line", lineNumber);
|
|
149
|
+
}
|
|
150
|
+
var key = trimmed.substring(0, eqIdx).replace(safeBuffer.TRAILING_HSPACE_RE, "");
|
|
151
|
+
var rest = trimmed.substring(eqIdx + 1);
|
|
152
|
+
|
|
153
|
+
if (key.length === 0) {
|
|
154
|
+
throw new SafeEnvError("empty key", "env/empty-key", lineNumber);
|
|
155
|
+
}
|
|
156
|
+
if (POISONED_KEYS.has(key)) {
|
|
157
|
+
throw new SafeEnvError("forbidden key '" + key + "'", "env/poisoned-key", lineNumber);
|
|
158
|
+
}
|
|
159
|
+
if (!keyShape.test(key)) {
|
|
160
|
+
throw new SafeEnvError(
|
|
161
|
+
"key '" + key + "' does not match keyShape " + keyShape,
|
|
162
|
+
"env/bad-key-shape", lineNumber
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
if (seen.has(key)) {
|
|
166
|
+
throw new SafeEnvError("duplicate key '" + key + "'", "env/duplicate-key", lineNumber);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Tab at start of value is almost always a paste accident
|
|
170
|
+
if (rest.charAt(0) === "\t") {
|
|
171
|
+
throw new SafeEnvError(
|
|
172
|
+
"tab at start of value (use spaces or quote the value)",
|
|
173
|
+
"env/tab-in-value", lineNumber
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
// Strip ONE leading space (operator-friendly: `KEY = value` → "value")
|
|
177
|
+
if (rest.charAt(0) === " ") rest = rest.substring(1);
|
|
178
|
+
|
|
179
|
+
var value;
|
|
180
|
+
if (rest.charAt(0) === '"') {
|
|
181
|
+
value = _decodeDoubleQuoted(rest, lineNumber);
|
|
182
|
+
} else if (rest.charAt(0) === "'") {
|
|
183
|
+
value = _decodeSingleQuoted(rest, lineNumber);
|
|
184
|
+
} else {
|
|
185
|
+
// Unquoted value: strip trailing whitespace + inline `# comment`.
|
|
186
|
+
// The comment marker MUST be preceded by whitespace to count
|
|
187
|
+
// (so a value like `KEY=color#red` keeps the literal `#`).
|
|
188
|
+
var commentMatch = rest.match(/^([^\s#]*(?:[ \t]+[^#\s]+)*)\s+#.*$/);
|
|
189
|
+
if (commentMatch) {
|
|
190
|
+
value = commentMatch[1].replace(safeBuffer.TRAILING_HSPACE_RE, "");
|
|
191
|
+
} else {
|
|
192
|
+
value = rest.replace(safeBuffer.TRAILING_HSPACE_RE, "");
|
|
193
|
+
}
|
|
194
|
+
// Reject `$VAR` style references — explicit error so operators
|
|
195
|
+
// see the policy rather than silently getting unexpanded text.
|
|
196
|
+
if (/\$(\{[A-Za-z_]|[A-Za-z_])/.test(value)) {
|
|
197
|
+
throw new SafeEnvError(
|
|
198
|
+
"$VAR / ${VAR} expansion not supported (escape with \\$ if literal, or quote and expand yourself)",
|
|
199
|
+
"env/expansion-banned", lineNumber
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
seen.add(key);
|
|
205
|
+
values[key] = value;
|
|
206
|
+
if (seen.size > maxKeys) {
|
|
207
|
+
throw new SafeEnvError("input exceeds maxKeys", "env/too-many-keys", lineNumber);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Convert null-prototype to plain {} so JSON.stringify and for-in work.
|
|
212
|
+
var out = {};
|
|
213
|
+
for (var k in values) {
|
|
214
|
+
if (Object.prototype.hasOwnProperty.call(values, k)) out[k] = values[k];
|
|
215
|
+
}
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function _decodeDoubleQuoted(rest, lineNumber) {
|
|
220
|
+
// Find closing quote, handling \\ + \"
|
|
221
|
+
var i = 1;
|
|
222
|
+
var out = "";
|
|
223
|
+
while (i < rest.length) {
|
|
224
|
+
var ch = rest.charAt(i);
|
|
225
|
+
if (ch === "\\") {
|
|
226
|
+
var esc = rest.charAt(i + 1);
|
|
227
|
+
switch (esc) {
|
|
228
|
+
case '"': out += '"'; i += 2; continue;
|
|
229
|
+
case "\\": out += "\\"; i += 2; continue;
|
|
230
|
+
case "n": out += "\n"; i += 2; continue;
|
|
231
|
+
case "r": out += "\r"; i += 2; continue;
|
|
232
|
+
case "t": out += "\t"; i += 2; continue;
|
|
233
|
+
case "$": out += "$"; i += 2; continue; // literal $ escape
|
|
234
|
+
case "u": {
|
|
235
|
+
var hex = rest.substring(i + 2, i + 6);
|
|
236
|
+
if (!safeBuffer.isHex(hex, 4)) {
|
|
237
|
+
throw new SafeEnvError("bad \\u escape", "env/bad-escape", lineNumber);
|
|
238
|
+
}
|
|
239
|
+
out += String.fromCharCode(parseInt(hex, RADIX_HEX));
|
|
240
|
+
i += 6;
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
default:
|
|
244
|
+
throw new SafeEnvError("unknown escape '\\" + esc + "'", "env/bad-escape", lineNumber);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (ch === '"') {
|
|
248
|
+
// Optional inline comment after closing quote — ignore.
|
|
249
|
+
return out;
|
|
250
|
+
}
|
|
251
|
+
if (ch === "$" && /^[{A-Za-z_]/.test(rest.charAt(i + 1) || "")) {
|
|
252
|
+
throw new SafeEnvError(
|
|
253
|
+
"$VAR / ${VAR} expansion not supported in double-quoted value (use \\$ for literal $)",
|
|
254
|
+
"env/expansion-banned", lineNumber
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
out += ch;
|
|
258
|
+
i += 1;
|
|
259
|
+
}
|
|
260
|
+
throw new SafeEnvError("unterminated double-quoted value", "env/unterminated-string", lineNumber);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function _decodeSingleQuoted(rest, lineNumber) {
|
|
264
|
+
// Single-quoted is fully literal: no escapes, no expansion. Closing
|
|
265
|
+
// quote is the first un-escaped `'` — but since there are no escapes,
|
|
266
|
+
// it's literally the next `'`.
|
|
267
|
+
var end = rest.indexOf("'", 1);
|
|
268
|
+
if (end < 0) {
|
|
269
|
+
throw new SafeEnvError("unterminated single-quoted value", "env/unterminated-string", lineNumber);
|
|
270
|
+
}
|
|
271
|
+
return rest.substring(1, end);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ---- Schema validation + type coercion ----
|
|
275
|
+
//
|
|
276
|
+
// expected = { KEY: { type, sensitivity, default? } }
|
|
277
|
+
// type: "string" (default) | "number" | "boolean" | "json"
|
|
278
|
+
// sensitivity: "boot-only" | "runtime" | "breaking" (informational; used by diff)
|
|
279
|
+
// default: optional value to use when key is absent
|
|
280
|
+
|
|
281
|
+
function _coerceType(rawValue, type, key) {
|
|
282
|
+
if (type === "string" || type == null) return rawValue;
|
|
283
|
+
if (type === "number") {
|
|
284
|
+
if (rawValue === "") return null;
|
|
285
|
+
var n = Number(rawValue);
|
|
286
|
+
if (Number.isNaN(n)) {
|
|
287
|
+
throw new SafeEnvError("'" + rawValue + "' is not a number for key '" + key + "'",
|
|
288
|
+
"env/bad-type");
|
|
289
|
+
}
|
|
290
|
+
return n;
|
|
291
|
+
}
|
|
292
|
+
if (type === "boolean") {
|
|
293
|
+
// Only accept the unambiguous spellings — no yes/no/on/off
|
|
294
|
+
// (consistent with our YAML "Norway problem" stance).
|
|
295
|
+
if (rawValue === "true") return true;
|
|
296
|
+
if (rawValue === "false") return false;
|
|
297
|
+
throw new SafeEnvError(
|
|
298
|
+
"boolean key '" + key + "' must be 'true' or 'false' (got '" + rawValue + "')",
|
|
299
|
+
"env/bad-type"
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
if (type === "json") {
|
|
303
|
+
try { return safeJson.parse(rawValue); }
|
|
304
|
+
catch (e) {
|
|
305
|
+
throw new SafeEnvError("invalid JSON for key '" + key + "': " + e.message,
|
|
306
|
+
"env/bad-type");
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
throw new SafeEnvError("unknown type '" + type + "' for key '" + key + "'",
|
|
310
|
+
"env/bad-schema");
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ---- Levenshtein distance for typo detection ----
|
|
314
|
+
|
|
315
|
+
function _levenshtein(a, b) {
|
|
316
|
+
var m = a.length, n = b.length;
|
|
317
|
+
if (Math.abs(m - n) > 3) return 4; // early-exit: anything > 2 we don't care about
|
|
318
|
+
var prev = new Array(n + 1);
|
|
319
|
+
var curr = new Array(n + 1);
|
|
320
|
+
for (var j = 0; j <= n; j++) prev[j] = j;
|
|
321
|
+
for (var i = 1; i <= m; i++) {
|
|
322
|
+
curr[0] = i;
|
|
323
|
+
for (var k = 1; k <= n; k++) {
|
|
324
|
+
var cost = a.charAt(i - 1) === b.charAt(k - 1) ? 0 : 1;
|
|
325
|
+
curr[k] = Math.min(
|
|
326
|
+
prev[k] + 1, // deletion
|
|
327
|
+
curr[k - 1] + 1, // insertion
|
|
328
|
+
prev[k - 1] + cost // substitution
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
var tmp = prev; prev = curr; curr = tmp;
|
|
332
|
+
}
|
|
333
|
+
return prev[n];
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function _detectSuspicious(values, expected) {
|
|
337
|
+
if (!expected) return [];
|
|
338
|
+
var expectedKeys = Object.keys(expected);
|
|
339
|
+
var expectedUpper = expectedKeys.map(function (k) { return k.toUpperCase(); });
|
|
340
|
+
var suspicious = [];
|
|
341
|
+
for (var key in values) {
|
|
342
|
+
if (Object.prototype.hasOwnProperty.call(expected, key)) continue; // registered
|
|
343
|
+
|
|
344
|
+
// Case-mismatch: an exact match exists if you upper-case
|
|
345
|
+
var upper = key.toUpperCase();
|
|
346
|
+
var caseIdx = expectedUpper.indexOf(upper);
|
|
347
|
+
if (caseIdx !== -1 && expectedKeys[caseIdx] !== key) {
|
|
348
|
+
suspicious.push({
|
|
349
|
+
key: key,
|
|
350
|
+
suggestion: expectedKeys[caseIdx],
|
|
351
|
+
reason: "case-mismatch",
|
|
352
|
+
});
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Levenshtein distance ≤ 2
|
|
357
|
+
var bestDist = Infinity;
|
|
358
|
+
var bestKey = null;
|
|
359
|
+
for (var j = 0; j < expectedKeys.length; j++) {
|
|
360
|
+
var d = _levenshtein(key, expectedKeys[j]);
|
|
361
|
+
if (d < bestDist) { bestDist = d; bestKey = expectedKeys[j]; }
|
|
362
|
+
}
|
|
363
|
+
if (bestDist <= 2 && bestKey) {
|
|
364
|
+
suspicious.push({
|
|
365
|
+
key: key,
|
|
366
|
+
suggestion: bestKey,
|
|
367
|
+
reason: "single-char-typo",
|
|
368
|
+
});
|
|
369
|
+
} else {
|
|
370
|
+
suspicious.push({ key: key, suggestion: null, reason: "unknown" });
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return suspicious;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ---- Diff computation ----
|
|
377
|
+
|
|
378
|
+
function _diff(prevValues, nextValues, expected) {
|
|
379
|
+
var added = [];
|
|
380
|
+
var removed = [];
|
|
381
|
+
var changed = [];
|
|
382
|
+
function _sensitivityOf(k) {
|
|
383
|
+
if (expected && expected[k] && expected[k].sensitivity) return expected[k].sensitivity;
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
for (var k in nextValues) {
|
|
387
|
+
if (!Object.prototype.hasOwnProperty.call(prevValues, k)) {
|
|
388
|
+
added.push(k);
|
|
389
|
+
} else if (prevValues[k] !== nextValues[k]) {
|
|
390
|
+
changed.push({ key: k, sensitivity: _sensitivityOf(k) });
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
for (var k2 in prevValues) {
|
|
394
|
+
if (!Object.prototype.hasOwnProperty.call(nextValues, k2)) {
|
|
395
|
+
removed.push(k2);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return { added: added, removed: removed, changed: changed };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ---- load() ----
|
|
402
|
+
|
|
403
|
+
function load(filepath, opts) {
|
|
404
|
+
if (typeof filepath !== "string") {
|
|
405
|
+
throw new SafeEnvError("load requires a file path", "env/bad-arg");
|
|
406
|
+
}
|
|
407
|
+
opts = opts || {};
|
|
408
|
+
var applyToProcess = opts.applyToProcess === true;
|
|
409
|
+
var allowOverwrite = opts.allowOverwrite === true;
|
|
410
|
+
var rejectUnknown = opts.rejectUnknown === true;
|
|
411
|
+
var auditEnabled = opts.audit !== false;
|
|
412
|
+
var expected = opts.expected || null;
|
|
413
|
+
var allowChange = new Set((opts.allow || []).map(String));
|
|
414
|
+
|
|
415
|
+
var bytes = atomicFile.readSync(filepath, {
|
|
416
|
+
maxBytes: opts.maxBytes != null ? opts.maxBytes : DEFAULTS.maxBytes,
|
|
417
|
+
});
|
|
418
|
+
var rawValues = parse(bytes.toString("utf8"), {
|
|
419
|
+
maxBytes: opts.maxBytes,
|
|
420
|
+
maxKeys: opts.maxKeys,
|
|
421
|
+
keyShape: opts.keyShape,
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
// Schema-driven coercion + missing-required detection
|
|
425
|
+
var values = {};
|
|
426
|
+
if (expected) {
|
|
427
|
+
for (var k in expected) {
|
|
428
|
+
if (Object.prototype.hasOwnProperty.call(rawValues, k)) {
|
|
429
|
+
values[k] = _coerceType(rawValues[k], expected[k].type, k);
|
|
430
|
+
} else if ("default" in expected[k]) {
|
|
431
|
+
values[k] = expected[k].default;
|
|
432
|
+
} else if (expected[k].required === true) {
|
|
433
|
+
throw new SafeEnvError("required key '" + k + "' missing from " + filepath,
|
|
434
|
+
"env/missing-required");
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
// Also include unregistered keys in values so the operator's app can
|
|
438
|
+
// still read them; they just don't get coercion.
|
|
439
|
+
for (var k2 in rawValues) {
|
|
440
|
+
if (!Object.prototype.hasOwnProperty.call(values, k2)) {
|
|
441
|
+
values[k2] = rawValues[k2];
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
} else {
|
|
445
|
+
values = rawValues;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Suspicious-key detection
|
|
449
|
+
var suspicious = _detectSuspicious(rawValues, expected);
|
|
450
|
+
if (rejectUnknown && suspicious.length > 0) {
|
|
451
|
+
var keys = suspicious.map(function (s) { return s.key; }).join(", ");
|
|
452
|
+
throw new SafeEnvError(
|
|
453
|
+
"rejectUnknown: unregistered keys present: " + keys,
|
|
454
|
+
"env/unknown-keys"
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Snapshot diff
|
|
459
|
+
var snapshotPath = opts.snapshotPath || null;
|
|
460
|
+
var prevValues = {};
|
|
461
|
+
if (snapshotPath && atomicFile.exists(snapshotPath)) {
|
|
462
|
+
try {
|
|
463
|
+
var snapBuf = atomicFile.readSync(snapshotPath);
|
|
464
|
+
prevValues = safeJson.parse(snapBuf) || {};
|
|
465
|
+
} catch (_e) { /* missing/corrupt snapshot → treat as empty */ }
|
|
466
|
+
}
|
|
467
|
+
var diff = _diff(prevValues, rawValues, expected);
|
|
468
|
+
diff.suspicious = suspicious;
|
|
469
|
+
|
|
470
|
+
// Refuse breaking changes unless explicitly allowed
|
|
471
|
+
if (expected) {
|
|
472
|
+
for (var i = 0; i < diff.changed.length; i++) {
|
|
473
|
+
var entry = diff.changed[i];
|
|
474
|
+
if (entry.sensitivity === "breaking" && !allowChange.has(entry.key)) {
|
|
475
|
+
throw new SafeEnvError(
|
|
476
|
+
"key '" + entry.key + "' is sensitivity:'breaking' — pass " +
|
|
477
|
+
"{ allow: ['" + entry.key + "'] } to acknowledge the change",
|
|
478
|
+
"env/breaking-change"
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Optionally apply to process.env
|
|
485
|
+
if (applyToProcess) {
|
|
486
|
+
for (var k3 in rawValues) {
|
|
487
|
+
if (Object.prototype.hasOwnProperty.call(process.env, k3) && !allowOverwrite) {
|
|
488
|
+
// Existing env var — leave untouched
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
process.env[k3] = rawValues[k3];
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Persist new snapshot (best-effort; failure to write doesn't crash
|
|
496
|
+
// the load — operators just lose change-tracking until next successful
|
|
497
|
+
// write).
|
|
498
|
+
if (snapshotPath) {
|
|
499
|
+
try {
|
|
500
|
+
atomicFile.writeSync(snapshotPath, JSON.stringify(rawValues), { fileMode: 0o600 });
|
|
501
|
+
} catch (_e) { /* best-effort */ }
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Audit hooks
|
|
505
|
+
if (auditEnabled && _hasNonEmptyDiff(diff)) {
|
|
506
|
+
_writeAuditRows(filepath, diff);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
return { values: values, diff: diff };
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function _hasNonEmptyDiff(d) {
|
|
513
|
+
return d.added.length > 0 || d.removed.length > 0 ||
|
|
514
|
+
d.changed.length > 0 || d.suspicious.length > 0;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function _writeAuditRows(filepath, diff) {
|
|
518
|
+
// Both audit.record and cluster.requireLeader sit downstream — if
|
|
519
|
+
// we're a follower, audit.record will throw NotLeaderError. Catch
|
|
520
|
+
// explicitly: a follower's local config-load shouldn't crash because
|
|
521
|
+
// the cluster's audit chain belongs to the leader.
|
|
522
|
+
var audit = auditModule(); // resolve the lazy-required audit module
|
|
523
|
+
|
|
524
|
+
function _safeRecord(action, metadata) {
|
|
525
|
+
try {
|
|
526
|
+
audit.emit({
|
|
527
|
+
actor: { kind: "system", id: "config-loader" },
|
|
528
|
+
action: action,
|
|
529
|
+
outcome: "success",
|
|
530
|
+
target: { kind: "config-file", id: filepath },
|
|
531
|
+
metadata: metadata,
|
|
532
|
+
});
|
|
533
|
+
} catch (e) {
|
|
534
|
+
if (e && e.code === "NOT_LEADER") return; // follower; expected
|
|
535
|
+
// Unexpected — but config-loading should not fail on audit failure
|
|
536
|
+
// (the audit chain is observability, not the loaded values).
|
|
537
|
+
// Surface via stderr so operators notice without crashing.
|
|
538
|
+
log.error("audit.record failed: " + e.message);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (diff.added.length > 0 || diff.removed.length > 0 || diff.changed.length > 0) {
|
|
543
|
+
_safeRecord("system.config.changed", {
|
|
544
|
+
file: filepath,
|
|
545
|
+
added: diff.added,
|
|
546
|
+
removed: diff.removed,
|
|
547
|
+
changed: diff.changed,
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
if (diff.suspicious.length > 0) {
|
|
551
|
+
_safeRecord("system.config.suspicious", {
|
|
552
|
+
file: filepath,
|
|
553
|
+
suspicious: diff.suspicious,
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// ---- readVar() — single process.env reader with schema validation ----
|
|
559
|
+
//
|
|
560
|
+
// Centralizes "read one process.env var, with size cap + type coercion +
|
|
561
|
+
// optional default + optional strip-after-read for secrets". Replaces
|
|
562
|
+
// scattered `process.env.BLAMEJS_*` reads across lib/* — every call site
|
|
563
|
+
// that previously did its own missing/empty/coerce handling now goes
|
|
564
|
+
// through this primitive.
|
|
565
|
+
//
|
|
566
|
+
// schema:
|
|
567
|
+
// type: "string" (default) | "number" | "boolean" | "json" | "buffer"
|
|
568
|
+
// required: bool — throw if missing/empty (default: false)
|
|
569
|
+
// default: fallback value when var is missing/empty (overrides required)
|
|
570
|
+
// maxBytes: cap on byte length (default 64 KiB; pass 4096 for secrets)
|
|
571
|
+
// strip: bool — `delete process.env[name]` after read (for secrets)
|
|
572
|
+
// enum: optional array of allowed raw string values (string type only)
|
|
573
|
+
|
|
574
|
+
var READVAR_DEFAULT_MAX_BYTES = C.BYTES.kib(64);
|
|
575
|
+
|
|
576
|
+
function readVar(name, schema) {
|
|
577
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
578
|
+
throw new SafeEnvError("readVar requires a non-empty name", "env/bad-arg");
|
|
579
|
+
}
|
|
580
|
+
schema = schema || {};
|
|
581
|
+
var type = schema.type || "string";
|
|
582
|
+
var required = schema.required === true;
|
|
583
|
+
var hasDefault = "default" in schema;
|
|
584
|
+
if (schema.maxBytes !== undefined && !numericBounds.isPositiveFiniteInt(schema.maxBytes)) {
|
|
585
|
+
throw new SafeEnvError(
|
|
586
|
+
"readVar: maxBytes must be a positive finite integer; got " +
|
|
587
|
+
numericBounds.shape(schema.maxBytes),
|
|
588
|
+
"env/bad-opt"
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
var maxBytes = (schema.maxBytes !== undefined)
|
|
592
|
+
? schema.maxBytes : READVAR_DEFAULT_MAX_BYTES;
|
|
593
|
+
var strip = schema.strip === true;
|
|
594
|
+
|
|
595
|
+
var raw = process.env[name];
|
|
596
|
+
var present = raw !== undefined && raw !== null && raw !== "";
|
|
597
|
+
|
|
598
|
+
if (!present) {
|
|
599
|
+
if (hasDefault) return schema.default;
|
|
600
|
+
if (required) {
|
|
601
|
+
throw new SafeEnvError(name + " env var is not set or is empty", "env/missing-required");
|
|
602
|
+
}
|
|
603
|
+
return undefined;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Size cap. Apply BEFORE strip so an oversized value still triggers
|
|
607
|
+
// the diagnostic (operator gets a clear error, not a silent truncation).
|
|
608
|
+
if (Buffer.byteLength(raw, "utf8") > maxBytes) {
|
|
609
|
+
throw new SafeEnvError(
|
|
610
|
+
name + " exceeds " + maxBytes + " byte limit",
|
|
611
|
+
"env/too-large"
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// enum constraint (string type only, applied to RAW value before coercion)
|
|
616
|
+
if (Array.isArray(schema.enum) && schema.enum.indexOf(raw) === -1) {
|
|
617
|
+
throw new SafeEnvError(
|
|
618
|
+
name + "='" + raw + "' is not one of: " + schema.enum.join(", "),
|
|
619
|
+
"env/bad-value"
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
var value;
|
|
624
|
+
if (type === "buffer") {
|
|
625
|
+
value = Buffer.from(raw, "utf8");
|
|
626
|
+
} else {
|
|
627
|
+
value = _coerceType(raw, type, name);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
if (strip) {
|
|
631
|
+
delete process.env[name];
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
return value;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
module.exports = {
|
|
638
|
+
parse: parse,
|
|
639
|
+
load: load,
|
|
640
|
+
readVar: readVar,
|
|
641
|
+
SafeEnvError: SafeEnvError,
|
|
642
|
+
};
|