Safe Env

Reads .env files and environment variables as data with a declared shape, rather than as whatever the shell happened to export.

The parser is bounded in bytes and in key count, and it does not expand or interpolate. It does not quietly pass an expansion through either: an unquoted $OTHER or ${OTHER} is refused, because a value a shell would expand and this parser would not is a difference that surfaces in production. A literal dollar sign is written by single-quoting the value, or by escaping it as \$ inside a double-quoted one; an unquoted value has no escape and refuses either way. An operator-supplied key pattern is checked for catastrophic backtracking before it is used, since a pattern applied to every key in a file is a denial of service if it can be made to backtrack.

Reading one variable goes through a schema that says the type, whether it is required, and what the default is. A variable holding a secret is read with strip, which removes it from the environment as it is read so no child process started afterwards inherits it. That is the limit of what stripping does: the environment the process was started with is still readable through /proc/<pid>/environ on Linux. A non-boolean where a boolean was expected is refused rather than read as false, because that is how a process starts without a variable its own schema calls required.

b.safeEnv.parse(input, opts?) #

stable0.0.16
{
  maxBytes: number,   // default 65536; clamped to 1 MiB
  maxKeys:  number,   // default 1000; clamped to 100000
  keyShape: object,   // RegExp every key must match
}

Parse .env text and return a plain object of key to string value.

Nothing is expanded, and nothing that looks like an expansion is quietly passed through either. A $VAR or ${VAR} raises env/expansion-banned naming the line, unquoted or double-quoted alike, because a value that a shell would have expanded and this parser would not is the kind of difference that is found in production.

Two forms keep a literal dollar sign. A single-quoted value is returned exactly as written, and a double-quoted value may escape it as \$. An unquoted value has neither: $USER, ${USER} and \$USER are all refused there, because a bare backslash is not an escape outside quotes.

The input is bounded at 64 KiB and 1,000 keys by default, and maxBytes and maxKeys are clamped to 1 MiB and 100,000 whatever is passed, so a caller cannot raise the limit past what the parser is prepared to walk.

keyShape constrains which key names are accepted. It must be a RegExp, and it is checked before being used: one that can backtrack catastrophically raises env/unsafe-pattern and a stateful one env/stateful-pattern, since the pattern runs against every key in the file. A key it does not match raises env/bad-key-shape.

The input itself raises env/wrong-input-type when it is neither a string nor a Buffer, env/too-large over maxBytes and env/too-many-keys over maxKeys. A maxBytes or maxKeys that is not a positive integer raises env/bad-opt.

Each line is parsed strictly, and each refusal names the line: env/bad-line for a line with no =, env/empty-key for a name that is blank, env/duplicate-key for a name already set in the same file, env/poisoned-key for one of the prototype-polluting names, env/tab-in-value for a raw tab, env/unterminated-string for a quote that never closes, and env/bad-escape for an escape sequence this parser does not define.

var b = require("@blamejs/core");
b.safeEnv.parse("PORT=8080\nGREETING='hi $USER'\n");
// → { PORT: "8080", GREETING: "hi $USER" }

// Unquoted, the same value is refused rather than guessed at:
try { b.safeEnv.parse("GREETING=hi $USER\n"); }
catch (e) { e.code; }        // → "env/expansion-banned"

b.safeEnv.load(filepath, opts?) #

stable0.1.5
{
  applyToProcess: boolean,  // default false; write into process.env
  allowOverwrite: boolean,  // default false; replace existing variables
  snapshotPath:   string,   // previous load's keys; compared then rewritten
  allow:          array,    // names whose "breaking" change is acknowledged
  expected:       object,   // schema for the keys the file should carry
  rejectUnknown:  boolean,  // default false; refuse unnamed keys
  maxBytes:       number,   // read and parse ceiling; default 65536
  maxKeys:        number,   // forwarded to parse; default 1000
  keyShape:       object,   // forwarded to parse; RegExp every key must match
  audit:          boolean,  // false silences the audit event
}

Read and parse a .env file, and optionally apply it to process.env.

Nothing reaches the process environment unless applyToProcess is true, and even then an existing variable is not replaced unless allowOverwrite is true. A file cannot silently change a variable the deployment already set.

snapshotPath names the file holding the previous load's keys and values. Give it and the load compares the file against that snapshot, then rewrites the snapshot at mode 0600 with what it just read. Leave it out and there is nothing to compare against, so every key is reported as added, no key is reported as changed, and the two gates below that act on a change never fire. A snapshot that is missing or will not parse is treated as empty, and a snapshot that cannot be written back is skipped; neither stops the load.

allow is a different gate: it names the keys whose change the caller acknowledges when the comparison against the snapshot marks them sensitivity: "breaking", which otherwise raises env/breaking-change naming the key. It does not permit an overwrite, so a name listed there and already present in the environment is still skipped.

expected is a schema for the keys the file should carry, and rejectUnknown refuses a file carrying keys the schema does not name. The boolean is validated rather than coerced: anything non-boolean used to be read as false, which loaded the file instead of refusing it.

Every expected entry is checked before the file is opened. An entry is an object; its type is string, number, boolean or json; its required is a boolean; and its sensitivity is a non-empty string. A malformed entry raises env/bad-schema, except a non-boolean required, which raises env/bad-opt. Checking it up front is what turns expected: { PORT: null } into a named refusal instead of a TypeError raised while reading the entry, and it catches an unknown type on a key the file happens not to carry, which the per-key coercion never reaches.

It returns { values, diff }. values is the parsed map after the schema is applied. diff compares the file against the snapshot and is computed before anything is written to process.env, so it reports a key as added or changed whether or not the value reached the environment: with applyToProcess false nothing is applied at all, and with allowOverwrite false a key already set in the environment is skipped while still appearing in the diff. Read it as what the file says relative to the last snapshot, not as what is now live. The audit rows are written from it.

The load is recorded in the audit chain unless audit is false, with the key names and not their values.

The call's own requirements raise env/bad-arg for a path that is not a string, and env/bad-opt for an option of the wrong shape: a non-boolean applyToProcess, allowOverwrite, rejectUnknown or audit, an allow that is not an array of names, a snapshotPath that is not a non-empty string, or an expected that is not a plain object. They are checked before the file is opened, since a typo in one of them otherwise reads as the default and the load looks like it worked. The expected schema raises env/bad-schema for a type it does not define, env/bad-type for a value that will not coerce to the type named, env/missing-required for a required key the file omits, and env/unknown-keys under rejectUnknown for keys the schema does not name.

Reading the file goes through b.safeEnv.parse, so every refusal it makes reaches this caller: env/wrong-input-type, env/too-large, env/too-many-keys, env/bad-line, env/empty-key, env/duplicate-key, env/poisoned-key, env/bad-key-shape, env/tab-in-value, env/bad-escape, env/unterminated-string, env/expansion-banned, env/unsafe-pattern and env/stateful-pattern, each described there.

var b = require("@blamejs/core");
var loaded = b.safeEnv.load(".env", {
  applyToProcess: true,
  snapshotPath:   "./.env.snapshot.json",
});
loaded.values.PORT;
loaded.diff;                 // → the file against the last snapshot

b.safeEnv.readVar(name, schema?) #

stable0.1.5
{
  type:     string,   // string | number | boolean | buffer; default "string"
  required: boolean,  // raise when absent
  default:  string,   // value used when absent
  maxBytes: number,   // value ceiling
  strip:    boolean,  // remove from process.env as it is read
}

Read one environment variable against a schema and return it in the declared type.

An empty string counts as absent, since that is what an unset variable looks like once it has passed through a shell or an orchestrator. Absent takes the default when the schema has one, raises when required is true, and is otherwise undefined.

strip: true removes the variable from process.env as it is read, which is how a secret is read: nothing the process starts afterwards inherits it. type: "buffer" returns the value as a Buffer so it can be zeroed.

The strip does not reach the environment block the process started with. On Linux a variable present at exec time stays readable through /proc//environ for the life of the process, so a secret that must be kept from a local reader belongs in a file rather than the environment.

required must be a boolean. Anything else is refused rather than read as false, because that is exactly how a process starts up missing a variable its own schema calls required.

// requires: API_TOKEN set in the environment
var b = require("@blamejs/core");
var port = b.safeEnv.readVar("PORT", { type: "number", default: 8080 });
var token = b.safeEnv.readVar("API_TOKEN", { type: "buffer", strip: true });
token.fill(0);

Last updated 2026-10-12T14:21:56.879Z by seeder.