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.
This commit is contained in:
Anso
2026-06-20 11:58:42 -04:00
committed by GitHub
parent d26ab58189
commit 57a0856ffc
34 changed files with 2117 additions and 127 deletions
@@ -17,8 +17,6 @@ const SECRET = 'pw-7Q2x-never-store';
let tmpDir: string;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let ComposeDoctorService: typeof import('../services/ComposeDoctorService').ComposeDoctorService;
let parseUnsetEnvVars: typeof import('../services/ComposeDoctorService').parseUnsetEnvVars;
let parseMissingRequiredVars: typeof import('../services/ComposeDoctorService').parseMissingRequiredVars;
let nodeId: number;
function db() { return DatabaseService.getInstance(); }
@@ -50,36 +48,15 @@ beforeAll(async () => {
tmpDir = await setupTestDb();
await import('../index');
({ DatabaseService } = await import('../services/DatabaseService'));
({ ComposeDoctorService, parseUnsetEnvVars, parseMissingRequiredVars } = await import('../services/ComposeDoctorService'));
({ ComposeDoctorService } = await import('../services/ComposeDoctorService'));
nodeId = (db().getDb().prepare('SELECT id FROM nodes WHERE is_default = 1').get() as { id: number }).id;
});
afterAll(() => cleanupTestDb(tmpDir));
afterEach(() => vi.restoreAllMocks());
describe('parseUnsetEnvVars', () => {
it('extracts variable names from Compose stderr (real escaped, quoted, and bare forms)', () => {
// The escaped form is exactly what `docker compose config` emits in logfmt.
const stderr =
'time="2026-06-10T00:36:15-04:00" level=warning msg="The \\"DB_HOST\\" variable is not set. Defaulting to a blank string."\n'
+ 'The "TOKEN" variable is not set.\n'
+ 'The PLAIN variable is not set.';
expect(parseUnsetEnvVars(stderr).sort()).toEqual(['DB_HOST', 'PLAIN', 'TOKEN']);
});
it('returns nothing for clean stderr', () => {
expect(parseUnsetEnvVars('')).toEqual([]);
});
it('ignores lines that do not match the unset-variable phrase', () => {
expect(parseUnsetEnvVars('the DB connection variable is configured\nNODE_ENV is not set elsewhere')).toEqual([]);
});
});
describe('parseMissingRequiredVars', () => {
it('extracts the name from the real required-variable error (unquoted)', () => {
const stderr = 'error while interpolating services.web.environment.TOKEN: required variable REQ_TOKEN is missing a value: must be provided';
expect(parseMissingRequiredVars(stderr)).toEqual(['REQ_TOKEN']);
});
});
// parseUnsetEnvVars / parseMissingRequiredVars now live in helpers/envVarParse and
// are covered by env-var-parse.test.ts.
describe('ComposeService.renderConfig path guard', () => {
it('rejects an invalid stack name without spawning docker', async () => {
@@ -114,6 +91,26 @@ describe('runPreflight', () => {
expect(latest.ranBy).toBe('tester');
});
it('surfaces a missing required env_file as a finding and ignores an optional one', async () => {
const stack = 'envfilemissing';
const dir = path.join(process.env.COMPOSE_DIR as string, stack);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
path.join(dir, 'compose.yaml'),
'services:\n web:\n image: nginx:1.27\n env_file:\n - ./gone.env\n - path: ./optional.env\n required: false\n',
);
try {
stubDocker({ name: stack, services: { web: { image: 'nginx:1.27' } }, networks: {}, volumes: {} }, '');
const report = await doctor().runPreflight(nodeId, stack, 'tester');
const envFile = report.findings.filter(f => f.ruleId === 'env-file-missing');
expect(envFile).toHaveLength(1);
expect(envFile[0].sourcePath).toBe('./gone.env');
expect(envFile[0].severity).toBe('high');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it('never stores an environment value', async () => {
stubDocker({ name: STACK, services: { web: { image: 'nginx:1.27', environment: { APP_SECRET: SECRET } } }, networks: {}, volumes: {} });
const report = await doctor().runPreflight(nodeId, STACK, null);
@@ -0,0 +1,101 @@
/**
* The opt-in deploy guard: blocks a deploy/update when required env vars are
* missing, only when the setting is on. Compose's own stderr is the authoritative
* signal (so an empty `REQ=` with `${REQ:?err}` is caught, which a key-only check
* could not), and the guard runs before any backup/cleanup/pull/up side effect.
*/
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
import { ComposeService } from '../services/ComposeService';
let tmpDir: string;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let nodeId: number;
// The stderr `docker compose config` emits for an unset OR empty `${REQ:?err}`.
const REQUIRED_MISSING_STDERR = 'required variable REQ is missing a value: must be provided';
function setBlocking(on: boolean): void {
vi.spyOn(DatabaseService.getInstance(), 'getGlobalSettings')
.mockReturnValue({ env_block_deploy_on_missing_required: on ? '1' : '0' } as Record<string, string>);
}
function stubStderr(stderr: string, rendered: string | null = null) {
const compose = ComposeService.getInstance(nodeId);
const spy = vi.spyOn(compose, 'renderConfig').mockResolvedValue({ rendered, stderr, code: rendered === null ? 1 : 0, timedOut: false });
return { compose, spy };
}
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
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('assertRequiredEnvPresent', () => {
it('does not render when the setting is off', async () => {
setBlocking(false);
const { compose, spy } = stubStderr(REQUIRED_MISSING_STDERR);
await (compose as unknown as { assertRequiredEnvPresent(s: string): Promise<void> }).assertRequiredEnvPresent('s');
expect(spy).not.toHaveBeenCalled();
});
it('blocks when a required variable is unset or empty', async () => {
setBlocking(true);
const { compose } = stubStderr(REQUIRED_MISSING_STDERR);
await expect((compose as unknown as { assertRequiredEnvPresent(s: string): Promise<void> }).assertRequiredEnvPresent('s'))
.rejects.toThrow(/REQ/);
});
it('names every missing variable with plural grammar', async () => {
setBlocking(true);
const { compose } = stubStderr('required variable A is missing a value\nrequired variable B is missing a value');
await expect((compose as unknown as { assertRequiredEnvPresent(s: string): Promise<void> }).assertRequiredEnvPresent('s'))
.rejects.toThrow(/variables A, B are missing/);
});
it('allows when all required variables are present', async () => {
setBlocking(true);
const { compose } = stubStderr('', '{"services":{}}');
await expect((compose as unknown as { assertRequiredEnvPresent(s: string): Promise<void> }).assertRequiredEnvPresent('s'))
.resolves.toBeUndefined();
});
it('does not block on a render failure unrelated to required vars', async () => {
setBlocking(true);
const { compose } = stubStderr('yaml: line 2: mapping values are not allowed');
await expect((compose as unknown as { assertRequiredEnvPresent(s: string): Promise<void> }).assertRequiredEnvPresent('s'))
.resolves.toBeUndefined();
});
it('falls through without blocking when the settings read fails', async () => {
vi.spyOn(DatabaseService.getInstance(), 'getGlobalSettings').mockImplementation(() => { throw new Error('db down'); });
const { compose, spy } = stubStderr(REQUIRED_MISSING_STDERR);
await expect((compose as unknown as { assertRequiredEnvPresent(s: string): Promise<void> }).assertRequiredEnvPresent('s'))
.resolves.toBeUndefined();
expect(spy).not.toHaveBeenCalled();
});
});
describe('deployStack/updateStack guard ordering', () => {
it('deployStack throws before taking an atomic backup when a required var is missing', async () => {
setBlocking(true);
const compose = ComposeService.getInstance(nodeId);
vi.spyOn(compose, 'renderConfig').mockResolvedValue({ rendered: null, stderr: REQUIRED_MISSING_STDERR, code: 1, timedOut: false });
const backup = vi.spyOn(compose as unknown as { createAtomicBackup(...a: unknown[]): Promise<void> }, 'createAtomicBackup').mockResolvedValue(undefined);
await expect(compose.deployStack('s', undefined, true)).rejects.toThrow(/REQ/);
expect(backup).not.toHaveBeenCalled();
});
it('updateStack throws before taking an atomic backup when a required var is missing', async () => {
setBlocking(true);
const compose = ComposeService.getInstance(nodeId);
vi.spyOn(compose, 'renderConfig').mockResolvedValue({ rendered: null, stderr: REQUIRED_MISSING_STDERR, code: 1, timedOut: false });
const backup = vi.spyOn(compose as unknown as { createAtomicBackup(...a: unknown[]): Promise<void> }, 'createAtomicBackup').mockResolvedValue(undefined);
await expect(compose.updateStack('s', undefined, true)).rejects.toThrow(/REQ/);
expect(backup).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,113 @@
/**
* resolveStackEnvSources: env_file existence metadata, the project .env
* interpolation source, inline environment keys, interpolation refs, and the
* multi-file Git deploy-spec path. Real filesystem + DB; no Docker.
*/
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
import fs from 'fs';
import path from 'path';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
import { resolveStackEnvSources } from '../helpers/envFileResolution';
let tmpDir: 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)) {
const target = path.join(dir, name);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, content);
}
}
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
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('resolveStackEnvSources', () => {
it('models the project .env as the interpolation source and reads inline keys + refs', async () => {
writeStack('s1', {
'compose.yaml': 'services:\n web:\n image: nginx:${TAG:-latest}\n environment:\n APP_PORT: "8080"\n FROM_SHELL: ${FROM_SHELL}\n',
'.env': 'TAG=1.0\n',
});
const r = await resolveStackEnvSources(nodeId, 's1');
const dotenv = r.envFiles.find(f => f.isInterpolationSource);
expect(dotenv?.existence).toBe('present');
expect(dotenv?.isInjectionSource).toBe(false);
expect(r.inlineEnvKeysByService.web).toContain('APP_PORT');
expect(r.interpolationRefs.map(x => x.name).sort()).toEqual(['FROM_SHELL', 'TAG']);
});
it('flags a missing required env_file but not an optional one', async () => {
writeStack('s2', {
'compose.yaml': 'services:\n web:\n image: nginx\n env_file:\n - ./present.env\n - ./gone.env\n - path: ./optional.env\n required: false\n',
'present.env': 'A=1\n',
});
const r = await resolveStackEnvSources(nodeId, 's2');
const byRaw = (raw: string) => r.envFiles.find(f => f.rawPaths.includes(raw));
expect(byRaw('./present.env')?.existence).toBe('present');
expect(byRaw('./gone.env')).toMatchObject({ existence: 'missing', required: true, isInjectionSource: true });
expect(byRaw('./optional.env')).toMatchObject({ existence: 'missing', required: false });
});
it('marks interpolated and escaping env_file paths as unverifiable', async () => {
writeStack('s3', {
'compose.yaml': 'services:\n web:\n image: nginx\n env_file:\n - ${ENV_DIR}/x.env\n - ../escape.env\n',
});
const r = await resolveStackEnvSources(nodeId, 's3');
for (const f of r.envFiles.filter(x => x.isInjectionSource)) {
expect(f.existence).toBe('unverifiable');
expect(f.resolvedPath).toBeNull();
}
});
it('treats .env doubling as env_file: .env as one physical file with both roles', async () => {
writeStack('s4', {
'compose.yaml': 'services:\n web:\n image: nginx\n env_file:\n - .env\n',
'.env': 'SHARED=1\n',
});
const r = await resolveStackEnvSources(nodeId, 's4');
const dotenvFiles = r.envFiles.filter(f => f.isInterpolationSource);
expect(dotenvFiles).toHaveLength(1);
expect(dotenvFiles[0]).toMatchObject({ isInterpolationSource: true, isInjectionSource: true, existence: 'present' });
});
it('reads env_file declared only in a Git multi-file override', async () => {
writeStack('s5', {
'compose.yaml': 'services:\n web:\n image: nginx\n',
'override.yaml': 'services:\n web:\n env_file:\n - ./override.env\n',
'override.env': 'O=1\n',
});
vi.spyOn(DatabaseService.getInstance(), 'getGitSource').mockReturnValue({
applied_deploy_spec: { files: ['compose.yaml', 'override.yaml'], contextDir: null },
} as unknown as ReturnType<typeof DatabaseService.prototype.getGitSource>);
const r = await resolveStackEnvSources(nodeId, 's5');
expect(r.composeFiles).toHaveLength(2);
expect(r.envFiles.some(f => f.rawPaths.includes('./override.env') && f.existence === 'present')).toBe(true);
});
it('resolves a nested override env_file relative to its compose file, not the stack root', async () => {
writeStack('s6', {
'compose.yaml': 'services:\n web:\n image: nginx\n',
'infra/prod.yml': 'services:\n web:\n env_file:\n - ./prod.env\n',
'infra/prod.env': 'NESTED=1\n',
});
vi.spyOn(DatabaseService.getInstance(), 'getGitSource').mockReturnValue({
applied_deploy_spec: { files: ['compose.yaml', 'infra/prod.yml'], contextDir: null },
} as unknown as ReturnType<typeof DatabaseService.prototype.getGitSource>);
const r = await resolveStackEnvSources(nodeId, 's6');
const f = r.envFiles.find(x => x.rawPaths.includes('./prod.env'));
expect(f).toMatchObject({ existence: 'present', isInjectionSource: true });
expect(f?.resolvedPath?.endsWith(path.join('infra', 'prod.env'))).toBe(true);
});
});
+210
View File
@@ -0,0 +1,210 @@
/**
* 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);
});
});
+120
View File
@@ -0,0 +1,120 @@
import { describe, it, expect } from 'vitest';
import os from 'os';
import fs from 'fs';
import path from 'path';
import {
parseInterpolationRefs,
extractEnvKeyFromLine,
readEnvFileKeys,
parseUnsetEnvVars,
parseMissingRequiredVars,
} from '../helpers/envVarParse';
describe('parseInterpolationRefs', () => {
it('classifies all seven operator forms, including the no-colon variants', () => {
const src = [
'a: ${BARE}',
'b: ${DEF:-fallback}',
'c: ${DEF2-fallback}',
'd: ${REQ:?must be set}',
'e: ${REQ2?must be set}',
'f: ${ALT:+present}',
'g: ${ALT2+present}',
].join('\n');
const refs = new Map(parseInterpolationRefs(src).map(r => [r.name, r]));
expect(refs.get('BARE')).toMatchObject({ required: false, hasDefault: false, alternate: false });
expect(refs.get('DEF')).toMatchObject({ hasDefault: true, required: false });
expect(refs.get('DEF2')).toMatchObject({ hasDefault: true, required: false });
expect(refs.get('REQ')).toMatchObject({ required: true });
expect(refs.get('REQ2')).toMatchObject({ required: true });
expect(refs.get('ALT')).toMatchObject({ alternate: true, required: false, hasDefault: false });
expect(refs.get('ALT2')).toMatchObject({ alternate: true });
});
it('skips the $${ESCAPED} literal', () => {
expect(parseInterpolationRefs('x: $${ESCAPED}').map(r => r.name)).not.toContain('ESCAPED');
});
it('merges flags across repeated references of one name', () => {
const refs = parseInterpolationRefs('${X} then ${X:?e}');
expect(refs).toHaveLength(1);
expect(refs[0]).toMatchObject({ name: 'X', required: true });
});
it('ORs default and alternate flags across occurrences too', () => {
const refs = parseInterpolationRefs('${Y:-d} then ${Y:+a}');
expect(refs).toHaveLength(1);
expect(refs[0]).toMatchObject({ name: 'Y', hasDefault: true, alternate: true, required: false });
});
});
describe('extractEnvKeyFromLine', () => {
it('returns the key name only for each line form', () => {
expect(extractEnvKeyFromLine('FOO=bar')).toBe('FOO');
expect(extractEnvKeyFromLine('export BAZ=qux')).toBe('BAZ');
expect(extractEnvKeyFromLine('BARE')).toBe('BARE');
expect(extractEnvKeyFromLine('# comment')).toBeNull();
expect(extractEnvKeyFromLine(' ')).toBeNull();
expect(extractEnvKeyFromLine('1BAD=x')).toBeNull();
});
it('never returns the value', () => {
expect(extractEnvKeyFromLine('SECRET=supersecretvalue')).toBe('SECRET');
});
});
describe('readEnvFileKeys', () => {
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'envkeys-'));
it('reads key names only and never the value', async () => {
const p = path.join(base, '.env');
fs.writeFileSync(p, 'FOO=secretvalue\n# c\nexport BAR=2\nBARE\n');
const res = await readEnvFileKeys(p, base);
expect(res.keys.sort()).toEqual(['BAR', 'BARE', 'FOO']);
expect(res.unverifiable).toBe(false);
expect(JSON.stringify(res)).not.toContain('secretvalue');
});
it('caps bytes/lines on a large fixture, flags truncated, and leaks no value', async () => {
const p = path.join(base, 'big.env');
let content = '';
for (let i = 0; i < 20000; i++) content += `K${i}=verylongsecret${'x'.repeat(40)}\n`;
fs.writeFileSync(p, content);
const res = await readEnvFileKeys(p, base, { maxBytes: 4096, maxLines: 100, maxLineLen: 8192 });
expect(res.truncated).toBe(true);
expect(res.keys.length).toBeLessThanOrEqual(100);
expect(JSON.stringify(res)).not.toContain('verylongsecret');
});
it('marks a path escaping the base directory as unverifiable', async () => {
// A secure temp dir that is a sibling of `base`, so the file is outside it.
const otherBase = fs.mkdtempSync(path.join(os.tmpdir(), 'envkeys-out-'));
const outside = path.join(otherBase, 'x.env');
fs.writeFileSync(outside, 'X=1');
const res = await readEnvFileKeys(outside, base);
expect(res.unverifiable).toBe(true);
expect(res.keys).toEqual([]);
fs.rmSync(otherBase, { recursive: true, force: true });
});
it('marks a missing file as unverifiable', async () => {
const res = await readEnvFileKeys(path.join(base, 'nope.env'), base);
expect(res.unverifiable).toBe(true);
expect(res.keys).toEqual([]);
});
});
describe('parseUnsetEnvVars / parseMissingRequiredVars', () => {
it('extracts unset variable names (escaped, quoted, and bare forms)', () => {
const stderr =
'time="t" level=warning msg="The \\"DB_HOST\\" variable is not set. Defaulting to a blank string."\n'
+ 'The "TOKEN" variable is not set.\n'
+ 'The PLAIN variable is not set.';
expect(parseUnsetEnvVars(stderr).sort()).toEqual(['DB_HOST', 'PLAIN', 'TOKEN']);
});
it('extracts the name from a required-variable error', () => {
const stderr = 'error while interpolating services.web.environment.TOKEN: required variable REQ_TOKEN is missing a value: must be provided';
expect(parseMissingRequiredVars(stderr)).toEqual(['REQ_TOKEN']);
});
});
+15 -1
View File
@@ -26,6 +26,7 @@ function ctx(over: Partial<PreflightContext> = {}): PreflightContext {
const m = over.model !== undefined ? over.model : model([]);
return {
stackName: 'proj', platform: 'linux', model: m, renderable: true, renderError: null, unsetEnvVars: [],
missingEnvFiles: [],
sourceServiceNames: m ? m.services.map(s => s.name) : [], sourceReadable: true,
nodePorts: [], existingNetworkNames: new Set(), existingVolumeNames: new Set(),
existingContainers: [], bindChecks: [],
@@ -56,6 +57,19 @@ describe('env-unset', () => {
});
});
describe('env-file-missing', () => {
it('emits one high finding per missing required env file', () => {
const f = ids(runRules(ctx({ missingEnvFiles: [{ rawPath: './db.env', services: ['db'] }] })), 'env-file-missing');
expect(f).toHaveLength(1);
expect(f[0].severity).toBe('high');
expect(f[0].sourcePath).toBe('./db.env');
expect(f[0].service).toBe('db');
});
it('stays silent when there are no missing env files (optional/unverifiable are pre-filtered)', () => {
expect(ids(runRules(ctx({ missingEnvFiles: [] })), 'env-file-missing')).toHaveLength(0);
});
});
describe('port-conflict-node', () => {
const withPort = (proto = 'tcp', hostIp = '') => model([svc({ ports: [{ startPort: 8080, endPort: 8080, hostIp, protocol: proto }] })]);
@@ -324,7 +338,7 @@ describe('rule registry completeness', () => {
// The canonical rule set. Adding or removing a rule must update this list,
// which forces a deliberate pass over the docs and the frontend severity map.
const EXPECTED_RULE_IDS = [
'render-failed', 'env-unset', 'port-conflict-node', 'port-conflict-internal', 'port-exposed-all-interfaces',
'render-failed', 'env-unset', 'env-file-missing', 'port-conflict-node', 'port-conflict-internal', 'port-exposed-all-interfaces',
'bind-path-missing', 'bind-path-permission', 'docker-socket-mount', 'privileged', 'network-mode-host',
'uid-gid-risk', 'image-latest', 'no-restart-policy', 'no-healthcheck', 'deploy-swarm-only',
'external-network-missing', 'external-volume-missing', 'new-network', 'new-volume',
@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import { isLikelySecretKey } from '../helpers/secretClassification';
describe('isLikelySecretKey', () => {
it('flags keys whose segments are known secret words', () => {
for (const k of [
'DB_PASSWORD', 'API_KEY', 'PRIVATE_KEY', 'CLIENT_SECRET', 'WEBHOOK_SECRET',
'GITHUB_TOKEN', 'APP_PASS', 'JWT_SECRET', 'AUTH_TOKEN', 'REDIS_PASSWORD',
'SECRET_KEY_BASE', 'MAIL_PASSPHRASE',
]) {
expect(isLikelySecretKey(k), k).toBe(true);
}
});
it('flags connection-string keys whose segments are innocuous', () => {
for (const k of ['DATABASE_URL', 'REDIS_URL', 'MONGO_URI', 'MONGODB_URI', 'AMQP_URL', 'DSN']) {
expect(isLikelySecretKey(k), k).toBe(true);
}
});
it('does not flag innocuous keys that merely contain a secret word as a substring', () => {
for (const k of [
'KEYCLOAK_URL', 'APP_PORT', 'NODE_ENV', 'LOG_LEVEL', 'PUBLIC_URL',
'COMPASS_HOST', 'BYPASS_CACHE', 'TZ', 'SERVER_NAME', 'AUTHORS_FILE',
]) {
expect(isLikelySecretKey(k), k).toBe(false);
}
});
it('is case-insensitive and trims, and rejects empty', () => {
expect(isLikelySecretKey(' db_password ')).toBe(true);
expect(isLikelySecretKey('Api_Key')).toBe(true);
expect(isLikelySecretKey('')).toBe(false);
});
});
@@ -298,6 +298,45 @@ describe('health gate settings', () => {
});
});
describe('env_block_deploy_on_missing_required setting', () => {
it('seeds to "0" (opt-in) in a fresh database', () => {
expect(DatabaseService.getInstance().getGlobalSettings().env_block_deploy_on_missing_required).toBe('0');
});
it('is exposed through the settings GET projection', async () => {
const res = await request(app).get('/api/settings').set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.env_block_deploy_on_missing_required).toBeDefined();
});
it('rejects a non-admin write with 403', async () => {
const res = await request(app)
.post('/api/settings')
.set('Cookie', viewerCookie)
.send({ key: 'env_block_deploy_on_missing_required', value: '1' });
expect(res.status).toBe(403);
});
it('accepts a well-formed write and rejects a non-enum value', async () => {
const ok = await request(app)
.post('/api/settings')
.set('Cookie', adminCookie)
.send({ key: 'env_block_deploy_on_missing_required', value: '1' });
expect(ok.status).toBe(200);
expect(DatabaseService.getInstance().getGlobalSettings().env_block_deploy_on_missing_required).toBe('1');
const bad = await request(app)
.post('/api/settings')
.set('Cookie', adminCookie)
.send({ key: 'env_block_deploy_on_missing_required', value: 'banana' });
expect(bad.status).toBe(400);
expect(bad.body.error).toBe('Validation failed');
expect(DatabaseService.getInstance().getGlobalSettings().env_block_deploy_on_missing_required).toBe('1');
DatabaseService.getInstance().updateGlobalSetting('env_block_deploy_on_missing_required', '0');
});
});
describe('PATCH /api/settings (bulk update)', () => {
it('rejects unauthenticated requests with 401', async () => {
const res = await request(app).patch('/api/settings').send({ host_cpu_limit: 50 });