Files
sencho/backend/src/__tests__/env-inventory.test.ts
T
Anso 57a0856ffc feat(stacks): per-stack environment inventory and secret-safe guardrails (#1397)
* feat(stacks): per-stack environment inventory and secret-safe guardrails

Add an Environment tab to Stack Anatomy that derives a per-stack inventory
of environment variables from the compose files and env files. Each variable
shows its source, whether Compose interpolates it or injects it into a
container, and a status (present, missing, unused, duplicate, or shell-only),
plus likely-secret classification. The inventory works from variable names
only: a value is never read, returned, or logged, and a likely secret shows
presence only. A copy env checklist action exports names and status without
values.

Surface a missing required env_file as a Compose Doctor preflight finding,
and add an opt-in node setting that refuses a deploy or update when a
required ${VAR:?...} variable is unset or empty, before any backup, pull, or
up runs. Default off.

The Environment tab is capability-gated so it hides on older remote nodes.

* fix(stacks): harden env-file reader against a stat-then-open race

Open the env-file handle first and fstat the open handle instead of
stat-ing the path before opening, removing the check-then-use window in
readEnvFileKeys. Use a secure mkdtemp directory for the out-of-base test
path instead of a predictable name in the temp root.

* fix(stacks): resolve nested env_file paths per compose file, reconcile inline keys per service

Resolve each env_file relative to the directory of the compose file that
declared it, so a nested multi-file Git override (infra/prod.yml referencing
./prod.env) lands next to that file instead of the stack root. The root
compose file is unaffected, since its directory is the stack directory.

Reconcile inline environment provenance per service, so a key an override
removed from one service's effective env is not labeled compose-inline just
because another service injects the same name from a different source.
2026-06-20 11:58:42 -04:00

211 lines
10 KiB
TypeScript

/**
* buildEnvInventory status derivation, the hard no-value guarantee, and the
* GET /api/stacks/:stackName/env-inventory route. The effective model render is
* mocked; the filesystem and DB are real.
*/
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
import fs from 'fs';
import path from 'path';
import request from 'supertest';
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
import { ComposeService } from '../services/ComposeService';
import { buildEnvInventory, type EnvInventory } from '../services/EnvInventoryService';
let tmpDir: string;
let app: import('express').Express;
let authCookie: string;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let nodeId: number;
function composeDir(): string { return process.env.COMPOSE_DIR as string; }
function writeStack(stack: string, files: Record<string, string>): void {
const dir = path.join(composeDir(), stack);
fs.mkdirSync(dir, { recursive: true });
for (const [name, content] of Object.entries(files)) fs.writeFileSync(path.join(dir, name), content);
}
/** Mock the effective model render with the given injected env keys per service. */
function stubRender(serviceEnv: Record<string, Record<string, string>> | null, stderr = ''): void {
const rendered = serviceEnv === null
? null
: JSON.stringify({ name: 'proj', services: Object.fromEntries(Object.entries(serviceEnv).map(([s, env]) => [s, { environment: env }])) });
vi.spyOn(ComposeService, 'getInstance').mockReturnValue({
renderConfig: vi.fn().mockResolvedValue({ rendered, stderr, code: rendered === null ? 1 : 0, timedOut: false }),
} as unknown as ComposeService);
}
const itemFor = (inv: EnvInventory, key: string) => inv.items.find(i => i.key === key);
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
({ DatabaseService } = await import('../services/DatabaseService'));
authCookie = await loginAsTestAdmin(app);
nodeId = (DatabaseService.getInstance().getDb().prepare('SELECT id FROM nodes WHERE is_default = 1').get() as { id: number }).id;
});
afterAll(() => cleanupTestDb(tmpDir));
afterEach(() => vi.restoreAllMocks());
describe('buildEnvInventory status derivation', () => {
it('classifies present, unused, interpolation, and injection sources', async () => {
writeStack('inv1', {
'compose.yaml': 'services:\n web:\n image: nginx:${USED:-x}\n environment:\n INLINE_KEY: "1"\n env_file:\n - ./svc.env\n',
'.env': 'USED=1\nUNUSED_VAR=2\n',
'svc.env': 'FILE_KEY=3\n',
});
stubRender({ web: { INLINE_KEY: '1', FILE_KEY: '3' } });
const inv = await buildEnvInventory(nodeId, 'inv1');
expect(inv.renderable).toBe(true);
expect(itemFor(inv, 'USED')).toMatchObject({ status: 'present', usedForInterpolation: true, sources: expect.arrayContaining(['dotenv']) });
expect(itemFor(inv, 'UNUSED_VAR')).toMatchObject({ status: 'unused' });
expect(itemFor(inv, 'INLINE_KEY')).toMatchObject({ injectedIntoService: true, sources: expect.arrayContaining(['compose-inline']) });
const fileKey = itemFor(inv, 'FILE_KEY');
expect(fileKey?.sources).toContain('env-file');
expect(fileKey?.sources).not.toContain('compose-inline');
});
it('marks a referenced-but-unset variable as missing', async () => {
writeStack('inv2', { 'compose.yaml': 'services:\n web:\n image: nginx:${MISSING}\n' });
stubRender({ web: {} }, 'The "MISSING" variable is not set. Defaulting to a blank string.');
const inv = await buildEnvInventory(nodeId, 'inv2');
expect(itemFor(inv, 'MISSING')).toMatchObject({ status: 'missing', usedForInterpolation: true });
});
it('picks up inline array and bare key forms', async () => {
writeStack('inv3', { 'compose.yaml': 'services:\n web:\n image: nginx\n environment:\n - ARR_KEY=v\n - BARE_KEY\n' });
stubRender({ web: { ARR_KEY: 'v', BARE_KEY: '' } });
const inv = await buildEnvInventory(nodeId, 'inv3');
expect(itemFor(inv, 'ARR_KEY')?.sources).toContain('compose-inline');
expect(itemFor(inv, 'BARE_KEY')?.sources).toContain('compose-inline');
});
it('drops an inline key that an override removed from the effective model', async () => {
writeStack('inv4', { 'compose.yaml': 'services:\n web:\n image: nginx\n environment:\n KEPT: "1"\n GONE: "1"\n' });
stubRender({ web: { KEPT: '1' } }); // GONE not in effective model
const inv = await buildEnvInventory(nodeId, 'inv4');
expect(itemFor(inv, 'KEPT')).toBeTruthy();
expect(itemFor(inv, 'GONE')).toBeUndefined();
});
it('does not flag .env doubling as env_file: .env as a duplicate', async () => {
writeStack('inv5', {
'compose.yaml': 'services:\n web:\n image: nginx:${SHARED}\n env_file:\n - .env\n',
'.env': 'SHARED=1\n',
});
stubRender({ web: { SHARED: '1' } });
const inv = await buildEnvInventory(nodeId, 'inv5');
const shared = itemFor(inv, 'SHARED');
expect(shared?.status).not.toBe('duplicate');
expect(shared).toMatchObject({ usedForInterpolation: true, injectedIntoService: true });
});
it('reconciles inline provenance per service: an override-dropped inline key in one service is not inline because another service injects the same name', async () => {
writeStack('invx', {
'compose.yaml': 'services:\n web:\n image: nginx\n environment:\n FOO: "1"\n db:\n image: postgres\n env_file:\n - ./db.env\n',
'db.env': 'FOO=2\n',
});
// web's effective env dropped FOO (e.g. an override); db injects FOO via env_file.
stubRender({ web: {}, db: { FOO: '2' } });
const inv = await buildEnvInventory(nodeId, 'invx');
const foo = itemFor(inv, 'FOO');
expect(foo?.sources).toContain('env-file');
expect(foo?.sources).not.toContain('compose-inline');
expect(foo?.status).not.toBe('duplicate');
expect(foo?.injectedIntoService).toBe(true);
});
it('reports duplicate when a key is defined inline and in a separate file', async () => {
writeStack('inv6', {
'compose.yaml': 'services:\n web:\n image: nginx\n environment:\n DUP: "1"\n env_file:\n - ./other.env\n',
'other.env': 'DUP=2\n',
});
stubRender({ web: { DUP: '1' } });
const inv = await buildEnvInventory(nodeId, 'inv6');
expect(itemFor(inv, 'DUP')?.status).toBe('duplicate');
});
it('degrades to renderable:false but still lists authored refs when render fails', async () => {
writeStack('inv7', { 'compose.yaml': 'services:\n web:\n image: nginx:${REF}\n' });
stubRender(null, 'yaml: line 2: mapping values are not allowed');
const inv = await buildEnvInventory(nodeId, 'inv7');
expect(inv.renderable).toBe(false);
expect(itemFor(inv, 'REF')).toBeTruthy();
});
it('still marks inline and env_file keys as injected when the model cannot render', async () => {
writeStack('inv8', {
'compose.yaml': 'services:\n web:\n image: nginx\n environment:\n INLINE_X: "1"\n env_file:\n - ./svc.env\n',
'svc.env': 'FILE_X=1\n',
});
stubRender(null, 'yaml: line 5: bad mapping');
const inv = await buildEnvInventory(nodeId, 'inv8');
expect(inv.renderable).toBe(false);
expect(itemFor(inv, 'INLINE_X')).toMatchObject({ injectedIntoService: true, sources: expect.arrayContaining(['compose-inline']) });
expect(itemFor(inv, 'FILE_X')).toMatchObject({ injectedIntoService: true, sources: expect.arrayContaining(['env-file']) });
});
});
describe('buildEnvInventory secret safety', () => {
it('never includes an inline environment value, only the key', async () => {
const value = 'actual-secret-value-zzz';
writeStack('sec1', { 'compose.yaml': `services:\n web:\n image: nginx\n environment:\n SECRET: ${value}\n` });
stubRender({ web: { SECRET: value } });
const inv = await buildEnvInventory(nodeId, 'sec1');
expect(itemFor(inv, 'SECRET')).toMatchObject({ likelySecret: true });
expect(JSON.stringify(inv)).not.toContain(value);
});
it('never emits an inventory row for an unreferenced process.env key', async () => {
process.env.UNRELATED_HOST_SECRET_XYZ = 'leak-me';
try {
writeStack('sec2', { 'compose.yaml': 'services:\n web:\n image: nginx\n' });
stubRender({ web: {} });
const inv = await buildEnvInventory(nodeId, 'sec2');
expect(itemFor(inv, 'UNRELATED_HOST_SECRET_XYZ')).toBeUndefined();
expect(JSON.stringify(inv)).not.toContain('UNRELATED_HOST_SECRET_XYZ');
} finally {
delete process.env.UNRELATED_HOST_SECRET_XYZ;
}
});
it('marks a shell-resolved, unpersisted referenced var as unpersisted', async () => {
process.env.SHELL_ONLY_VAR_ABC = 'present-in-shell';
try {
writeStack('sec3', { 'compose.yaml': 'services:\n web:\n image: nginx:${SHELL_ONLY_VAR_ABC}\n' });
stubRender({ web: {} }); // resolved (no unset warning) because shell has it
const inv = await buildEnvInventory(nodeId, 'sec3');
expect(itemFor(inv, 'SHELL_ONLY_VAR_ABC')).toMatchObject({ status: 'unpersisted', sources: expect.arrayContaining(['process-env']) });
} finally {
delete process.env.SHELL_ONLY_VAR_ABC;
}
});
});
describe('GET /api/stacks/:stackName/env-inventory', () => {
it('requires authentication', async () => {
const res = await request(app).get('/api/stacks/inv1/env-inventory');
expect(res.status).toBe(401);
});
it('returns 404 for an unknown stack', async () => {
const res = await request(app).get('/api/stacks/does-not-exist/env-inventory').set('Cookie', authCookie);
expect(res.status).toBe(404);
});
it('returns the inventory without leaking a value', async () => {
const value = 'route-secret-value-qqq';
writeStack('route1', { 'compose.yaml': `services:\n web:\n image: nginx\n environment:\n SECRET: ${value}\n` });
stubRender({ web: { SECRET: value } });
const res = await request(app).get('/api/stacks/route1/env-inventory').set('Cookie', authCookie);
expect(res.status).toBe(200);
expect(res.body.stackName).toBe('route1');
// The key must appear (so the test fails if the row is dropped, not just if the
// value happens to be absent), and the value must never appear.
const secret = (res.body.items as { key: string; likelySecret: boolean }[]).find(i => i.key === 'SECRET');
expect(secret).toMatchObject({ likelySecret: true });
expect(JSON.stringify(res.body)).not.toContain(value);
});
});