Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/fix-error-output-to-stderr.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"varlock": patch
---

Fix: error messages in `varlock load` now go to stderr instead of stdout.

Previously, error output from `checkForSchemaErrors` and `checkForConfigErrors` was written to stdout via `console.log`, which polluted the JSON output when using `--format json-full`. This caused `import 'varlock/config'` to fail with a JSON parse error when a plugin (e.g. AWS secrets) encountered an error. Error messages are now written to stderr, keeping stdout clean for JSON output.
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# @defaultSensitive=false
# ---

PUBLIC_VAR=hello-world
API_URL=https://api.example.com

# @sensitive
SECRET_TOKEN=super-secret-token-12345
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Tests that varlock/auto-load works as a drop-in import
import 'varlock/auto-load';
import { ENV } from 'varlock/env';

if (ENV.PUBLIC_VAR !== 'hello-world') {
console.error('ENV.PUBLIC_VAR mismatch');
process.exit(1);
}
if (process.env.PUBLIC_VAR !== 'hello-world') {
console.error('process.env.PUBLIC_VAR mismatch');
process.exit(1);
}

// Log sensitive value to test that auto-load enables redaction
console.log('secret::', ENV.SECRET_TOKEN);

console.log('auto-load-ok');
37 changes: 37 additions & 0 deletions framework-tests/frameworks/vanilla-node/files/_base/check-env.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Verifies that env vars are loaded into process.env and ENV
import { ENV } from 'varlock/env';

const errors = [];

// Check process.env
if (process.env.PUBLIC_VAR !== 'hello-world') {
errors.push(`process.env.PUBLIC_VAR expected 'hello-world', got '${process.env.PUBLIC_VAR}'`);
}
if (process.env.API_URL !== 'https://api.example.com') {
errors.push(`process.env.API_URL expected 'https://api.example.com', got '${process.env.API_URL}'`);
}
if (process.env.SECRET_TOKEN !== 'super-secret-token-12345') {
errors.push(`process.env.SECRET_TOKEN expected 'super-secret-token-12345', got '${process.env.SECRET_TOKEN}'`);
}

// Check ENV proxy
if (ENV.PUBLIC_VAR !== 'hello-world') {
errors.push(`ENV.PUBLIC_VAR expected 'hello-world', got '${ENV.PUBLIC_VAR}'`);
}
if (ENV.API_URL !== 'https://api.example.com') {
errors.push(`ENV.API_URL expected 'https://api.example.com', got '${ENV.API_URL}'`);
}
if (ENV.SECRET_TOKEN !== 'super-secret-token-12345') {
errors.push(`ENV.SECRET_TOKEN expected 'super-secret-token-12345', got '${ENV.SECRET_TOKEN}'`);
}

if (errors.length > 0) {
console.error('ERRORS:', errors.join('; '));
process.exit(1);
}

console.log('process-env-ok');
console.log('env-proxy-ok');
console.log(`public::${process.env.PUBLIC_VAR}`);
console.log(`api::${process.env.API_URL}`);
console.log(`has-secret::${ENV.SECRET_TOKEN ? 'yes' : 'no'}`);
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Verifies that `varlock run` injects env vars into process.env
// WITHOUT importing anything from varlock
const errors = [];

if (process.env.PUBLIC_VAR !== 'hello-world') {
errors.push(`PUBLIC_VAR expected 'hello-world', got '${process.env.PUBLIC_VAR}'`);
}
if (process.env.API_URL !== 'https://api.example.com') {
errors.push(`API_URL expected 'https://api.example.com', got '${process.env.API_URL}'`);
}
if (process.env.SECRET_TOKEN !== 'super-secret-token-12345') {
errors.push(`SECRET_TOKEN expected 'super-secret-token-12345', got '${process.env.SECRET_TOKEN}'`);
}

if (errors.length > 0) {
console.error('ERRORS:', errors.join('; '));
Comment thread Dismissed
process.exit(1);
}

console.log('process-env-only-ok');
console.log(`public::${process.env.PUBLIC_VAR}`);
console.log(`api::${process.env.API_URL}`);
console.log(`has-secret::${process.env.SECRET_TOKEN ? 'yes' : 'no'}`);
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Tests that console redaction is working for sensitive values
import { ENV } from 'varlock/env';

// Log public vars (should appear in output)
console.log('public::', ENV.PUBLIC_VAR);

// Log sensitive value (should be redacted in output)
console.log('secret::', ENV.SECRET_TOKEN);
console.log(`interpolated secret: ${ENV.SECRET_TOKEN}`);
console.error(`stderr secret: ${process.env.SECRET_TOKEN}`);

// Verify the actual value is correct in memory
if (ENV.SECRET_TOKEN !== 'super-secret-token-12345') {
process.exit(1);
}

console.log('redaction-test-done');
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// Tests the varlock/config dotenv drop-in replacement
import 'varlock/config';

console.log(`public::${process.env.PUBLIC_VAR}`);
console.log(`has-secret::${process.env.SECRET_TOKEN ? 'yes' : 'no'}`);
console.log('dotenv-dropin-ok');
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Self-contained test: starts an HTTP server, makes requests, reports results
// auto-load patches ServerResponse to detect leaked sensitive values
import 'varlock/auto-load';
import http, { createServer } from 'node:http';
import { ENV } from 'varlock/env';

function httpGet(url) {
return new Promise((resolve, reject) => {
http.get(url, (res) => {
let body = '';
res.on('data', (chunk) => {
body += chunk;
});
res.on('end', () => resolve({ status: res.statusCode, body }));
}).on('error', (err) => reject(err));
});
}

const server = createServer((req, res) => {
if (req.url === '/safe') {
res.writeHead(200, { 'content-type': 'text/plain' });
res.end(`public::${ENV.PUBLIC_VAR}`);
} else if (req.url === '/leak') {
res.writeHead(200, { 'content-type': 'text/plain' });
// This should trigger leak detection
res.end(`secret::${ENV.SECRET_TOKEN}`);
} else {
res.writeHead(404);
res.end('not found');
}
});

server.listen(0, async () => {
const port = server.address().port;
try {
// Safe endpoint should work
const safeResp = await httpGet(`http://localhost:${port}/safe`);
console.log(`safe-status::${safeResp.status}`);
console.log(`safe-body::${safeResp.body}`);

// Leaky endpoint should trigger leak detection
try {
await httpGet(`http://localhost:${port}/leak`);
console.log('leak-not-detected');
} catch (err) {
console.log('leak-request-error');
}
} finally {
server.close();
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# @defaultSensitive=false
# ---

PUBLIC_VAR=hello-world
API_URL=https://api.example.com

# @sensitive
SECRET_TOKEN=super-secret-token-12345
159 changes: 159 additions & 0 deletions framework-tests/frameworks/vanilla-node/vanilla-node.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import {
describe, test, expect, beforeAll, afterAll,
} from 'vitest';
import { writeFileSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { FrameworkTestEnv } from '../../harness/index';

const env = new FrameworkTestEnv({
testDir: import.meta.dirname,
framework: 'vanilla-node',
packageManager: 'pnpm',
dependencies: {
varlock: 'will-be-replaced',
},
});

const baseEnv = {
...process.env,
COREPACK_ENABLE_STRICT: '0',
COREPACK_ENABLE_PROJECT_SPEC: '0',
};

interface RunResult {
stdout: string;
stderr: string;
status: number | null;
output: string;
}

function run(cmd: string, args: Array<string>): RunResult {
const result = spawnSync(cmd, args, {
cwd: env.dir,
encoding: 'utf-8',
timeout: 30_000,
env: baseEnv,
});
return {
stdout: result.stdout ?? '',
stderr: result.stderr ?? '',
status: result.status,
output: (result.stdout ?? '') + (result.stderr ?? ''),
};
}

/** Run a node script inside the test project via `varlock run` */
function varlockRunNode(script: string) {
return run('pnpm', ['exec', 'varlock', 'run', '--', 'node', script]);
}

/** Run a node script directly (without varlock run) */
function runNode(script: string) {
return run('node', [script]);
}

describe('Vanilla Node.js', () => {
beforeAll(() => env.setup(), 180_000);
afterAll(() => env.teardown());

describe('env var loading via varlock run', () => {
test('vars are loaded into process.env and ENV proxy', () => {
const { status, output } = varlockRunNode('check-env.mjs');
expect(status).toBe(0);
expect(output).toContain('process-env-ok');
expect(output).toContain('env-proxy-ok');
expect(output).toContain('public::hello-world');
expect(output).toContain('api::https://api.example.com');
expect(output).toContain('has-secret::yes');
});

test('vars are injected into process.env without importing varlock', () => {
const { status, output } = varlockRunNode('check-process-env-only.mjs');
expect(status).toBe(0);
expect(output).toContain('process-env-only-ok');
expect(output).toContain('public::hello-world');
expect(output).toContain('api::https://api.example.com');
expect(output).toContain('has-secret::yes');
});
});

describe('varlock/auto-load', () => {
test('loads env vars and enables console redaction', () => {
const { status, output } = runNode('auto-load-check.mjs');
expect(status).toBe(0);
expect(output).toContain('auto-load-ok');
// auto-load enables console redaction, so the secret should be redacted
expect(output).not.toContain('super-secret-token-12345');
});
});

describe('varlock/config (dotenv drop-in)', () => {
test('loads env vars into process.env', () => {
const { status, output } = runNode('dotenv-dropin.mjs');
expect(status).toBe(0);
expect(output).toContain('dotenv-dropin-ok');
expect(output).toContain('public::hello-world');
expect(output).toContain('has-secret::yes');
});

test('with a bad schema, does not cause a JSON parse error', () => {
const schemaPath = join(env.dir, '.env.schema');
const validSchema = readFileSync(schemaPath, 'utf-8');

// Swap in the bad schema temporarily
writeFileSync(schemaPath, [
'# @defaultSensitive=false',
'# ---',
'PUBLIC_VAR=hello-world',
'',
'# @required',
'MISSING_REQUIRED=',
].join('\n'));

try {
const {
status, stdout, stderr, output,
} = runNode('dotenv-dropin.mjs');
expect(status).not.toBe(0);

// The failure must NOT be a JSON parse error
expect(output).not.toMatch(/JSON [Pp]arse [Ee]rror/);
expect(output).not.toMatch(/Unrecognized token/);
expect(output).not.toMatch(/SyntaxError.*JSON/);

// Error diagnostics must only appear on stderr, not stdout
expect(stdout).not.toContain('🚨');
expect(stderr).toContain('🚨');
} finally {
// Restore valid schema
writeFileSync(schemaPath, validSchema);
}
});
});

describe('console redaction', () => {
test('sensitive values are redacted in varlock run output', () => {
const { status, output } = varlockRunNode('check-redaction.mjs');
expect(status).toBe(0);
expect(output).toContain('redaction-test-done');
expect(output).toContain('public:: hello-world');
expect(output).not.toContain('super-secret-token-12345');
});
});

describe('leak prevention', () => {
// leaky-server.mjs imports varlock/auto-load which patches ServerResponse
test('safe endpoint serves public values', () => {
const { output } = runNode('leaky-server.mjs');
expect(output).toContain('safe-status::200');
expect(output).toContain('safe-body::public::hello-world');
});

test('leaky endpoint triggers leak detection', () => {
const { output } = runNode('leaky-server.mjs');
expect(output).toContain('DETECTED LEAKED SENSITIVE CONFIG');
expect(output).not.toContain('super-secret-token-12345');
});
});
});
3 changes: 2 additions & 1 deletion framework-tests/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"repack": "bun run harness/repack.ts",
"test:watch": "vitest",
"test:expo": "vitest run frameworks/expo",
"test:nextjs": "vitest run frameworks/nextjs"
"test:nextjs": "vitest run frameworks/nextjs",
"test:vanilla-node": "vitest run frameworks/vanilla-node"
},
"devDependencies": {
"@types/node": "^22.0.0",
Expand Down
Loading
Loading