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 });
+253
View File
@@ -0,0 +1,253 @@
/**
* Authored-compose env analysis for a stack: the env_file declarations (with
* existence metadata), the project `.env` interpolation source, the inline
* `environment:` KEY names per service, and the `${}` interpolation references.
*
* This is the single reader of the authored compose file set, so the multi-file
* Git case is handled once and every consumer (the route's env-file wrapper that
* Fleet Secrets calls, the Compose Doctor preflight, and the env inventory) sees
* the same env_file set. It surfaces NAMES and structural
* facts only: an env-file value is never read here, and inline environment values
* are dropped immediately after their key names are taken.
*/
import path from 'path';
import YAML from 'yaml';
import { FileSystemService } from '../services/FileSystemService';
import { DatabaseService } from '../services/DatabaseService';
import { isPathWithinBase, isValidRelativeStackPath } from '../utils/validation';
import { parseInterpolationRefs, type InterpolationRef } from './envVarParse';
const MAX_COMPOSE_PARSE_BYTES = 1_048_576; // 1 MiB, matches the routes/stacks.ts bound
const ROOT_COMPOSE_CANDIDATES = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'];
export type EnvFileExistence = 'present' | 'missing' | 'unverifiable';
/**
* One physical env file, deduped by resolved absolute path. The project `.env`
* doubling as an `env_file: .env` entry is ONE physical file carrying both roles,
* so it is never double-counted as a duplicate definition.
*/
export interface PhysicalEnvFile {
/** Absolute path, or null when the raw path is interpolated or escapes the stack dir. */
resolvedPath: string | null;
/** Raw paths as written (or '.env' for the implicit project source). */
rawPaths: string[];
existence: EnvFileExistence;
/** A missing file matters only when at least one declaration required it. */
required: boolean;
/** True when this is the project `.env` Compose reads for `${}` interpolation. */
isInterpolationSource: boolean;
/** True when a service `env_file:` injects this file into a container. */
isInjectionSource: boolean;
/** Services that declared this file via `env_file:`. */
declaringServices: string[];
}
export interface StackEnvSources {
stackDir: string;
baseDir: string;
/** Absolute authored compose files actually read (multi-file Git aware). */
composeFiles: string[];
/** Project `.env` + every declared env_file, deduped by resolved path. */
envFiles: PhysicalEnvFile[];
/** Authored `environment:` KEY names per service (union across authored files). */
inlineEnvKeysByService: Record<string, string[]>;
/** `${}` references found across the authored compose source. */
interpolationRefs: InterpolationRef[];
}
interface EnvFileEntry {
rawPath: string;
required: boolean;
}
/** Normalize a service `env_file:` field (string, array of strings, or long-form objects). */
function normalizeEnvFileField(envFile: unknown): EnvFileEntry[] {
if (typeof envFile === 'string') return [{ rawPath: envFile, required: true }];
if (!Array.isArray(envFile)) return [];
const out: EnvFileEntry[] = [];
for (const entry of envFile) {
if (typeof entry === 'string') {
out.push({ rawPath: entry, required: true });
} else if (entry && typeof entry === 'object') {
const p = (entry as Record<string, unknown>).path;
if (typeof p === 'string') {
out.push({ rawPath: p, required: (entry as Record<string, unknown>).required !== false });
}
}
}
return out;
}
/** Inline `environment:` KEY names (object / `KEY=value` array / bare `KEY`), never values. */
function inlineEnvKeysOf(environment: unknown): string[] {
if (Array.isArray(environment)) {
return environment
.filter((e): e is string => typeof e === 'string')
.map(e => e.split('=')[0].trim())
.filter(Boolean);
}
if (environment && typeof environment === 'object') {
return Object.keys(environment as Record<string, unknown>);
}
return [];
}
async function existenceOf(fsService: FileSystemService, abs: string, baseDir: string): Promise<EnvFileExistence> {
if (!isPathWithinBase(abs, baseDir)) return 'unverifiable';
try {
await fsService.access(abs);
return 'present';
} catch (err) {
return (err as NodeJS.ErrnoException).code === 'ENOENT' ? 'missing' : 'unverifiable';
}
}
/** The authored compose files Compose would read: the applied Git deploy spec, else the root file. */
async function discoverAuthoredComposeFiles(
fsService: FileSystemService,
stackName: string,
stackDir: string,
): Promise<string[]> {
const spec = DatabaseService.getInstance().getGitSource(stackName)?.applied_deploy_spec;
if (spec && Array.isArray(spec.files) && spec.files.length > 0) {
const files: string[] = [];
for (const f of spec.files) {
if (typeof f !== 'string' || !isValidRelativeStackPath(f)) continue;
const abs = path.resolve(stackDir, f);
if (isPathWithinBase(abs, stackDir)) files.push(abs);
}
if (files.length > 0) return files;
}
for (const name of ROOT_COMPOSE_CANDIDATES) {
const abs = path.resolve(stackDir, name);
try {
await fsService.access(abs);
return [abs];
} catch {
// try next candidate
}
}
return [];
}
async function parseComposeServices(fsService: FileSystemService, absPath: string): Promise<{
services: Record<string, unknown>;
text: string;
} | null> {
let content: string;
try {
content = await fsService.readFile(absPath, 'utf-8');
} catch {
return null;
}
if (content.length > MAX_COMPOSE_PARSE_BYTES) return null;
try {
const parsed = YAML.parse(content) as Record<string, unknown> | null;
const services = (parsed?.services && typeof parsed.services === 'object')
? parsed.services as Record<string, unknown>
: {};
return { services, text: content };
} catch {
return { services: {}, text: content };
}
}
/**
* Resolve every authored env source for a stack. Reads each authored compose file
* once and returns env_file existence, inline key names, and interpolation refs.
*/
export async function resolveStackEnvSources(nodeId: number, stackName: string): Promise<StackEnvSources> {
const fsService = FileSystemService.getInstance(nodeId);
const baseDir = fsService.getBaseDir();
const stackDir = path.join(baseDir, stackName);
const composeFiles = await discoverAuthoredComposeFiles(fsService, stackName, stackDir);
// Physical env files, deduped by resolved absolute path. Seed with the project
// `.env`: always the interpolation source, regardless of any env_file entry.
const byPath = new Map<string, PhysicalEnvFile>();
const dotenvPath = path.resolve(stackDir, '.env');
const dotenv: PhysicalEnvFile = {
resolvedPath: dotenvPath,
rawPaths: ['.env'],
existence: await existenceOf(fsService, dotenvPath, baseDir),
required: false,
isInterpolationSource: true,
isInjectionSource: false,
declaringServices: [],
};
byPath.set(dotenvPath, dotenv);
const unresolved: PhysicalEnvFile[] = [];
const inlineEnvKeysByService: Record<string, string[]> = {};
let authoredText = '';
for (const file of composeFiles) {
const parsed = await parseComposeServices(fsService, file);
if (!parsed) continue;
authoredText += parsed.text + '\n';
for (const [serviceName, svcRaw] of Object.entries(parsed.services)) {
const svc = (svcRaw ?? {}) as Record<string, unknown>;
const inlineKeys = inlineEnvKeysOf(svc.environment);
if (inlineKeys.length > 0) {
const existing = inlineEnvKeysByService[serviceName] ?? [];
inlineEnvKeysByService[serviceName] = [...new Set([...existing, ...inlineKeys])];
}
for (const entry of normalizeEnvFileField(svc.env_file)) {
const interpolated = entry.rawPath.includes('${');
// Resolve relative to the directory of the compose file that declared it,
// so an env_file in a nested multi-file override (e.g. infra/prod.yml ->
// ./prod.env) lands next to that file, not at the stack root. For the root
// compose file this dir is the stack dir, so the common case is unchanged.
const abs = interpolated ? null : path.resolve(path.dirname(file), entry.rawPath);
const within = abs !== null && isPathWithinBase(abs, stackDir);
const resolvedPath = within ? abs : null;
if (resolvedPath) {
const existing = byPath.get(resolvedPath);
if (existing) {
existing.isInjectionSource = true;
existing.required ||= entry.required;
if (!existing.rawPaths.includes(entry.rawPath)) existing.rawPaths.push(entry.rawPath);
if (!existing.declaringServices.includes(serviceName)) existing.declaringServices.push(serviceName);
} else {
byPath.set(resolvedPath, {
resolvedPath,
rawPaths: [entry.rawPath],
existence: await existenceOf(fsService, resolvedPath, baseDir),
required: entry.required,
isInterpolationSource: false,
isInjectionSource: true,
declaringServices: [serviceName],
});
}
} else {
// Interpolated or escaping path: unverifiable, kept so the inventory can show it.
unresolved.push({
resolvedPath: null,
rawPaths: [entry.rawPath],
existence: 'unverifiable',
required: entry.required,
isInterpolationSource: false,
isInjectionSource: true,
declaringServices: [serviceName],
});
}
}
}
}
return {
stackDir,
baseDir,
composeFiles,
envFiles: [...byPath.values(), ...unresolved],
inlineEnvKeysByService,
interpolationRefs: parseInterpolationRefs(authoredText),
};
}
+159
View File
@@ -0,0 +1,159 @@
/**
* Name-only parsing of Compose interpolation and env-file keys, plus the shared
* stderr parsers Compose Doctor and the deploy guard both use. Every function
* here surfaces variable NAMES only; an env-file value is never returned or
* retained, and the bounded reader never loads a whole large file into memory.
*/
import { promises as fsp } from 'fs';
import path from 'path';
/** A `${...}` reference found in authored compose source. */
export interface InterpolationRef {
name: string;
/** `${VAR:?e}` / `${VAR?e}`: Compose errors if unset (`:?` also if empty). */
required: boolean;
/** `${VAR:-d}` / `${VAR-d}`: a default makes the value optional. */
hasDefault: boolean;
/** `${VAR:+x}` / `${VAR+x}`: alternate value; an unset VAR is intentional. */
alternate: boolean;
}
// ${VAR}, ${VAR:-d}, ${VAR-d}, ${VAR:?e}, ${VAR?e}, ${VAR:+x}, ${VAR+x}.
// The leading (?<!\$) skips Compose's `$${VAR}` escape (a literal, not a ref).
// Group 2 is the operator (':-','-',':?','?',':+','+') or undefined for a bare ref.
const INTERPOLATION_RE = /(?<!\$)\$\{([A-Za-z_][A-Za-z0-9_]*)(?:(:?[-?+])[^}]*)?\}/g;
/**
* Extract every distinct `${...}` reference from authored compose text, with its
* operator semantics. Operates on raw text (no YAML/value construction), so it
* never materializes an env value.
*/
export function parseInterpolationRefs(source: string): InterpolationRef[] {
const byName = new Map<string, InterpolationRef>();
for (const m of source.matchAll(INTERPOLATION_RE)) {
const name = m[1];
const op = m[2];
const required = op === ':?' || op === '?';
const hasDefault = op === ':-' || op === '-';
const alternate = op === ':+' || op === '+';
const existing = byName.get(name);
if (existing) {
existing.required ||= required;
existing.hasDefault ||= hasDefault;
existing.alternate ||= alternate;
} else {
byName.set(name, { name, required, hasDefault, alternate });
}
}
return [...byName.values()];
}
/**
* Pull the KEY name from a single env-file line, or null for a blank/comment line.
* Handles `KEY=value`, `export KEY=value`, and a bare `KEY` (value sourced from the
* shell). The value after `=` is never read or returned.
*/
export function extractEnvKeyFromLine(line: string): string | null {
let s = line.trim();
if (!s || s.startsWith('#')) return null;
if (s.startsWith('export ')) s = s.slice('export '.length).trim();
const eq = s.indexOf('=');
const key = (eq === -1 ? s : s.slice(0, eq)).trim();
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(key) ? key : null;
}
export interface EnvKeyReadLimits {
maxBytes: number;
maxLines: number;
maxLineLen: number;
}
export const DEFAULT_ENV_KEY_LIMITS: EnvKeyReadLimits = {
maxBytes: 256 * 1024,
maxLines: 5000,
maxLineLen: 8192,
};
export interface EnvKeyReadResult {
/** Distinct key names, in first-seen order. Never includes a value. */
keys: string[];
/** True when the file exceeded a limit and was only partially read. */
truncated: boolean;
/** True when the path escaped the base or the file could not be read/statted. */
unverifiable: boolean;
}
/**
* Read env-file KEY names from a file under `baseDir`, bounded by `limits`. The
* path containment barrier is inlined at the read sink (CodeQL does not credit a
* wrapped helper), and the read is capped so a large or adversarial file cannot
* exhaust heap. Values are never materialized: only the slice before the first
* `=` of each line is kept.
*/
export async function readEnvFileKeys(
filePath: string,
baseDir: string,
limits: EnvKeyReadLimits = DEFAULT_ENV_KEY_LIMITS,
): Promise<EnvKeyReadResult> {
const resolved = path.resolve(filePath);
const baseResolved = path.resolve(baseDir);
if (resolved !== baseResolved && !resolved.startsWith(baseResolved + path.sep)) {
return { keys: [], truncated: false, unverifiable: true };
}
let handle: fsp.FileHandle | undefined;
try {
// Open first, then fstat the open handle (not the path), so there is no
// check-then-use window between a path stat and the open.
handle = await fsp.open(resolved, 'r');
const stat = await handle.stat();
if (!stat.isFile()) return { keys: [], truncated: false, unverifiable: true };
const truncated = stat.size > limits.maxBytes;
const len = Math.min(stat.size, limits.maxBytes);
const buf = Buffer.alloc(len);
if (len > 0) await handle.read(buf, 0, len, 0);
const seen = new Set<string>();
const keys: string[] = [];
const lines = buf.toString('utf-8').split(/\r?\n/);
const lineBudget = Math.min(lines.length, limits.maxLines);
for (let i = 0; i < lineBudget; i++) {
const line = lines[i];
if (line.length > limits.maxLineLen) continue;
const key = extractEnvKeyFromLine(line);
if (key && !seen.has(key)) {
seen.add(key);
keys.push(key);
}
}
return { keys, truncated: truncated || lines.length > limits.maxLines, unverifiable: false };
} catch {
return { keys: [], truncated: false, unverifiable: true };
} finally {
await handle?.close();
}
}
/** Collect the deduplicated capture-group-1 matches of a global regex over stderr. */
function collectNames(stderr: string, re: RegExp): string[] {
const names = new Set<string>();
let m: RegExpExecArray | null;
while ((m = re.exec(stderr)) !== null) names.add(m[1]);
return [...names];
}
/**
* Pull the names of variables Compose reported as unset from its stderr.
* Compose prints this in logfmt (`msg="The \"VAR\" variable is not set..."`),
* so the name is wrapped in an escaped quote; the pattern tolerates the
* escaped, plain-quoted, and unquoted forms across Compose versions.
*/
export function parseUnsetEnvVars(stderr: string): string[] {
return collectNames(stderr, /([A-Za-z_][A-Za-z0-9_]*)\\?"?\s+variable is not set/gi);
}
/** Names of required (${VAR:?...}) variables Compose reported as missing. Names only, never values. */
export function parseMissingRequiredVars(stderr: string): string[] {
return collectNames(stderr, /required variable\s+\\?"?([A-Za-z_][A-Za-z0-9_]*)\\?"?\s+is missing/gi);
}
@@ -0,0 +1,33 @@
/**
* Deterministic "is this env key likely a secret" classification by key NAME only.
*
* Used to decide whether an env-inventory row is redacted (presence shown, value
* never read or rendered). The heuristic is segment-aware: a key is split on
* non-alphanumeric boundaries and each segment is matched against a known set, so
* `API_KEY` / `DB_PASSWORD` / `CLIENT_SECRET` match while a key that merely
* contains a secret word as part of a larger token (e.g. `KEYCLOAK_URL`, where the
* segment is `KEYCLOAK`, not `KEY`) does not. Over-flagging is safe: classification
* only hides a value the inventory already never reads.
*/
/** Whole-segment matches. Split on `_`/non-alnum, so `KEYCLOAK` never matches `KEY`. */
const SECRET_SEGMENTS = new Set([
'PASSWORD', 'PASSWD', 'PASS', 'PASSPHRASE',
'SECRET', 'SECRETS',
'TOKEN', 'KEY', 'APIKEY',
'CREDENTIAL', 'CREDENTIALS', 'AUTH',
]);
/** Connection strings whose value is sensitive but whose segments are innocuous. */
const SECRET_FULL_KEYS = new Set([
'DATABASE_URL', 'DATABASE_DSN', 'REDIS_URL', 'MONGO_URI', 'MONGODB_URI', 'AMQP_URL', 'DSN',
]);
/** True when the key name suggests its value is a secret. Names only, never values. */
export function isLikelySecretKey(rawKey: string): boolean {
const key = rawKey.trim().toUpperCase();
if (!key) return false;
if (SECRET_FULL_KEYS.has(key)) return true;
const segments = key.split(/[^A-Z0-9]+/).filter(Boolean);
return segments.some(seg => SECRET_SEGMENTS.has(seg));
}
+2
View File
@@ -29,6 +29,7 @@ const ALLOWED_SETTING_KEYS = new Set([
'snapshot_documentation',
'health_gate_enabled',
'health_gate_window_seconds',
'env_block_deploy_on_missing_required',
]);
// Keys whose write requires a paid license, not just an admin role.
@@ -56,6 +57,7 @@ const SettingsPatchSchema = z.object({
snapshot_documentation: z.enum(['0', '1']),
health_gate_enabled: z.enum(['0', '1']),
health_gate_window_seconds: z.coerce.number().int().min(15).max(600).transform(String),
env_block_deploy_on_missing_required: z.enum(['0', '1']),
}).partial();
export const settingsRouter = Router();
+34 -73
View File
@@ -18,6 +18,7 @@ import { DriftLedgerService, type DriftTemporal } from '../services/DriftLedgerS
import { ComposeDoctorService } from '../services/ComposeDoctorService';
import { buildStackNetworkFacts } from '../services/network/composeNetworkInspector';
import { buildEffectiveAnatomy } from '../services/effectiveAnatomy';
import { buildEnvInventory } from '../services/EnvInventoryService';
import { EXPOSURE_INTENTS, type ExposureIntent } from '../services/network/types';
import { UpdateGuardService } from '../services/UpdateGuardService';
import { HealthGateService } from '../services/HealthGateService';
@@ -27,7 +28,7 @@ import { NotificationService, type NotificationCategory } from '../services/Noti
import { StackOpLockService, type StackOpAction } from '../services/StackOpLockService';
import { StackOpMetricsService, type StackOpAction as StackMetricAction } from '../services/StackOpMetricsService';
import { FileExplorerMetricsService, type FileExplorerOp } from '../services/FileExplorerMetricsService';
import { isValidGitSourcePath, isValidStackName, isValidServiceName, isPathWithinBase, isValidRelativeStackPath } from '../utils/validation';
import { isValidGitSourcePath, isValidStackName, isValidServiceName, isValidRelativeStackPath } from '../utils/validation';
import { getErrorMessage } from '../utils/errors';
import { isDebugEnabled } from '../utils/debug';
import { sanitizeForLog } from '../utils/safeLog';
@@ -36,6 +37,7 @@ import { buildPolicyGateOptions, runPolicyGate, triggerPostDeployScan } from '..
import { parseComposePreview, type ComposePreview } from '../helpers/composePreview';
import { invalidateNodeCaches } from '../helpers/cacheInvalidation';
import { parseComposeSelection, defaultEnvPath } from '../helpers/gitSourceSelection';
import { resolveStackEnvSources } from '../helpers/envFileResolution';
import { STACK_STATUSES_CACHE_TTL_MS } from '../helpers/constants';
import { getTerminalWs, DEPLOY_SESSION_HEADER } from '../websocket/generic';
@@ -135,80 +137,21 @@ async function requireStackExists(nodeId: number, stackName: string, res: Respon
return true;
}
// Thin wrapper over the shared env-source resolver. Returns the absolute paths of
// the env files Compose would consult for this stack: the existing declared
// `env_file:` paths when any are declared (no project `.env` fallback in that
// case), otherwise the project `.env` when it exists. The multi-file Git case and
// path validation live in resolveStackEnvSources so every consumer agrees.
export async function resolveAllEnvFilePaths(nodeId: number, stackName: string): Promise<string[]> {
const fsService = FileSystemService.getInstance(nodeId);
const stackDir = path.join(fsService.getBaseDir(), stackName);
const defaultEnvPath = path.join(stackDir, '.env');
try {
const composeFiles = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'];
let composeContent: string | null = null;
for (const file of composeFiles) {
try {
composeContent = await fsService.readFile(path.join(stackDir, file), 'utf-8');
break;
} catch {
// Try next file
}
}
if (!composeContent) return [defaultEnvPath];
if (composeContent.length > MAX_COMPOSE_PARSE_BYTES) {
console.warn(`[Stacks] Compose for ${sanitizeForLog(stackName)} exceeds ${MAX_COMPOSE_PARSE_BYTES} bytes; skipping env_file resolution`);
return [defaultEnvPath];
}
const parsed = YAML.parse(composeContent);
if (!parsed?.services) return [defaultEnvPath];
const envFiles = new Set<string>();
for (const serviceName of Object.keys(parsed.services)) {
const service = parsed.services[serviceName];
if (!service?.env_file) continue;
const addEnvPath = (rawPath: string) => {
const resolved = path.resolve(stackDir, rawPath);
if (!isPathWithinBase(resolved, stackDir)) return;
envFiles.add(resolved);
};
if (typeof service.env_file === 'string') {
addEnvPath(service.env_file);
} else if (Array.isArray(service.env_file)) {
for (const entry of service.env_file) {
const entryPath = typeof entry === 'string' ? entry : (entry?.path || '');
if (entryPath) addEnvPath(entryPath);
}
}
}
if (envFiles.size === 0) {
envFiles.add(defaultEnvPath);
}
const existing: string[] = [];
for (const f of envFiles) {
try {
await fsService.access(f);
existing.push(f);
} catch {
// File does not exist, skip
}
}
return existing;
} catch (error) {
console.warn('Could not parse compose.yaml for env_file resolution in stack "%s":', sanitizeForLog(stackName), error);
}
try {
await fsService.access(defaultEnvPath);
return [defaultEnvPath];
} catch {
return [];
const sources = await resolveStackEnvSources(nodeId, stackName);
const injection = sources.envFiles.filter(f => f.isInjectionSource);
if (injection.length > 0) {
return injection
.filter(f => f.existence === 'present' && f.resolvedPath)
.map(f => f.resolvedPath as string);
}
const dotenv = sources.envFiles.find(f => f.isInterpolationSource && f.existence === 'present' && f.resolvedPath);
return dotenv ? [dotenv.resolvedPath as string] : [];
}
const upload = multer({
@@ -1170,6 +1113,24 @@ stacksRouter.get('/:stackName/effective-anatomy', async (req: Request, res: Resp
}
});
// Environment inventory: per-stack env vars with their source, scope (Compose
// interpolation vs container injection), and status (present/missing/unused/
// duplicate/unpersisted), plus likely-secret classification. Read-only and
// advisory; auto-proxies to the active node. Names only: an env value is never
// read into the payload, so stack:read is the correct gate.
stacksRouter.get('/:stackName/env-inventory', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return;
if (!(await requireStackExists(req.nodeId, stackName, res))) return;
try {
res.json(await buildEnvInventory(req.nodeId, stackName));
} catch (error) {
console.error('[Stacks] Failed to build env inventory for %s:', sanitizeForLog(stackName),
sanitizeForLog(inspect(error, { depth: 4 })));
res.status(500).json({ error: 'Failed to build env inventory' });
}
});
// Exposure intent: the user's per-stack (service '') and per-service exposure
// classification, stored separately from generated facts so mismatches stay
// detectable. Rows are stored independently; precedence (a service row taking
@@ -38,6 +38,7 @@ export const CAPABILITIES = [
'compose-doctor',
'update-guard',
'compose-networking',
'env-inventory',
] as const;
export type Capability = (typeof CAPABILITIES)[number];
+18 -24
View File
@@ -13,38 +13,17 @@ import { parseAccessUrlPorts } from './network/normalize';
import type { ExposureIntent } from './network/types';
import { runRules, SEVERITY_RANK, RULE_IDS, RENDER_FAILED_RULE_ID } from './preflight/rules';
import type {
BindCheck, NodePortBinding, PreflightContext, PreflightFinding, PreflightReport, PreflightSeverity, PreflightStatus,
BindCheck, NodePortBinding, PreflightContext, PreflightFinding, PreflightReport, PreflightSeverity, PreflightStatus, MissingEnvFile,
} from './preflight/types';
import { isPathWithinBase } from '../utils/validation';
import { getErrorMessage } from '../utils/errors';
import { redactSensitiveText, sanitizeForLog } from '../utils/safeLog';
import { parseUnsetEnvVars, parseMissingRequiredVars } from '../helpers/envVarParse';
import { resolveStackEnvSources } from '../helpers/envFileResolution';
const MAX_RENDER_ERROR = 600; // chars kept from a (redacted) render error
/** Collect the deduplicated capture-group-1 matches of a global regex over stderr. */
function collectNames(stderr: string, re: RegExp): string[] {
const names = new Set<string>();
let m: RegExpExecArray | null;
while ((m = re.exec(stderr)) !== null) names.add(m[1]);
return [...names];
}
/**
* Pull the names of variables Compose reported as unset from its stderr.
* Compose prints this in logfmt (`msg="The \"VAR\" variable is not set..."`),
* so the name is wrapped in an escaped quote; the pattern tolerates the
* escaped, plain-quoted, and unquoted forms across Compose versions.
*/
export function parseUnsetEnvVars(stderr: string): string[] {
return collectNames(stderr, /([A-Za-z_][A-Za-z0-9_]*)\\?"?\s+variable is not set/gi);
}
/** Names of required (${VAR:?...}) variables Compose reported as missing. Names only, never values. */
export function parseMissingRequiredVars(stderr: string): string[] {
return collectNames(stderr, /required variable\s+\\?"?([A-Za-z_][A-Za-z0-9_]*)\\?"?\s+is missing/gi);
}
const ruleOrder = new Map(RULE_IDS.map((id, i) => [id, i]));
/** Severity descending, then registry order, so output is deterministic. */
function sortFindings(findings: PreflightFinding[]): PreflightFinding[] {
@@ -200,6 +179,20 @@ export class ComposeDoctorService {
const bindChecks = model ? await this.resolveBindChecks(model, baseDir) : [];
const { stackIntent, serviceIntents, accessUrlPorts, hasAccessUrls } = this.exposureState(nodeId, stackName);
// Required `env_file:` declarations whose file is absent. Optional
// (required: false) and interpolated/escaping paths are excluded. Fail-soft:
// a resolution error simply yields no env-file findings.
let missingEnvFiles: MissingEnvFile[] = [];
try {
const envSources = await resolveStackEnvSources(nodeId, stackName);
missingEnvFiles = envSources.envFiles
.filter(f => f.isInjectionSource && f.required && f.existence === 'missing')
.map(f => ({ rawPath: f.rawPaths[0], services: f.declaringServices }));
} catch (err) {
console.warn('[ComposeDoctor] env-file resolution failed for %s:',
sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(err, 'unknown')));
}
return {
stackName,
platform: process.platform,
@@ -207,6 +200,7 @@ export class ComposeDoctorService {
renderable,
renderError,
unsetEnvVars,
missingEnvFiles,
sourceServiceNames,
sourceReadable,
nodePorts,
+29
View File
@@ -17,6 +17,7 @@ import { getErrorMessage } from '../utils/errors';
import { describeSpawnError } from '../utils/spawnErrors';
import { isPathWithinBase, isValidStackName } from '../utils/validation';
import { authoredComposeFileArgs, authoredComposeEnvFileArgs } from '../utils/authoredComposeArgs';
import { parseMissingRequiredVars } from '../helpers/envVarParse';
import { redactSensitiveText, sanitizeForLog } from '../utils/safeLog';
export class ComposeRollbackError extends Error {
@@ -364,7 +365,34 @@ export class ComposeService {
await this.execute('docker', await this.authoredComposeArgs(stackName, [action]), stackDir, ws);
}
/**
* Opt-in guard: when `env_block_deploy_on_missing_required` is enabled, refuse a
* deploy whose required `${VAR:?err}` variables are unset OR empty, before any
* backup, cleanup, pull, or `up` runs. Compose's own resolution is authoritative
* (it passes process.env), and on the failing path it emits no rendered model, so
* no env value is materialized. Default off and any settings-read failure both
* fall through without blocking.
*/
private async assertRequiredEnvPresent(stackName: string): Promise<void> {
let enabled = false;
try {
enabled = DatabaseService.getInstance().getGlobalSettings()['env_block_deploy_on_missing_required'] === '1';
} catch {
return; // safe default: a settings-read failure never blocks a deploy
}
if (!enabled) return;
const result = await this.renderConfig(stackName);
const missing = parseMissingRequiredVars(result.stderr);
if (missing.length === 0) return;
const plural = missing.length > 1;
throw new Error(
`Deploy blocked: required environment variable${plural ? 's' : ''} ${missing.join(', ')} ` +
`${plural ? 'are' : 'is'} missing. Define ${plural ? 'them' : 'it'} in a .env or env_file, then deploy again.`,
);
}
async deployStack(stackName: string, ws?: WebSocket, atomic?: boolean): Promise<void> {
await this.assertRequiredEnvPresent(stackName);
const stackDir = path.join(this.baseDir, stackName);
const debug = isDebugEnabled();
const t0 = Date.now();
@@ -541,6 +569,7 @@ export class ComposeService {
}
async updateStack(stackName: string, ws?: WebSocket, atomic?: boolean): Promise<void> {
await this.assertRequiredEnvPresent(stackName);
const stackDir = path.join(this.baseDir, stackName);
const debug = isDebugEnabled();
const t0 = Date.now();
+1
View File
@@ -1493,6 +1493,7 @@ export class DatabaseService {
stmt.run('health_gate_enabled', '1');
stmt.run('health_gate_window_seconds', '90');
stmt.run('image_update_check_interval_minutes', '120');
stmt.run('env_block_deploy_on_missing_required', '0');
// Seed the default local node if none exists
const nodeCount = (this.db.prepare('SELECT COUNT(*) as count FROM nodes').get() as any)?.count || 0;
+228
View File
@@ -0,0 +1,228 @@
/**
* Per-stack environment inventory: which env vars a stack references, where they
* come from, whether Compose interpolates them or injects them into a container,
* and whether each is likely a secret. Names, sources, and status ONLY: an env
* value is never read, returned, retained, or logged here.
*
* Compose env semantics this encodes:
* - `${VAR}` interpolation resolves from the project `.env` + the shell only.
* - `env_file:` and inline `environment:` inject values into a container.
* - `${VAR:?err}` fails when unset OR empty, so the unset/missing signal comes
* from Compose's own stderr (authoritative), not a key-only guess.
*
* Injected keys come from the merge-correct effective model; interpolation refs
* and inline-vs-env-file provenance come from the authored source. `process.env`
* is consulted ONLY to resolve interpolation refs the stack already references, so
* unrelated host/system env names never appear as inventory rows.
*/
import { ComposeService } from './ComposeService';
import { parseEffectiveModel } from './preflight/effectiveModel';
import { resolveStackEnvSources, type EnvFileExistence } from '../helpers/envFileResolution';
import { parseUnsetEnvVars, parseMissingRequiredVars, readEnvFileKeys } from '../helpers/envVarParse';
import { isLikelySecretKey } from '../helpers/secretClassification';
export type EnvSource = 'compose-inline' | 'env-file' | 'dotenv' | 'process-env' | 'compose-ref';
export type EnvItemStatus = 'present' | 'missing' | 'unused' | 'duplicate' | 'unpersisted';
export interface EnvInventoryItem {
key: string;
sources: EnvSource[];
/** Consumed by Compose `${}` interpolation. */
usedForInterpolation: boolean;
/** Injected into a container (effective model is authoritative). */
injectedIntoService: boolean;
required: boolean;
hasDefault: boolean;
likelySecret: boolean;
status: EnvItemStatus;
}
export interface EnvFileInfo {
/** Raw paths as written in compose (or '.env' for the project source). No absolute path. */
rawPaths: string[];
existence: EnvFileExistence;
required: boolean;
isInterpolationSource: boolean;
isInjectionSource: boolean;
declaringServices: string[];
}
export interface EnvInventory {
stackName: string;
/** False when the effective model could not be rendered; the inventory is then partial. */
renderable: boolean;
items: EnvInventoryItem[];
envFiles: EnvFileInfo[];
summary: {
total: number;
missing: number;
unused: number;
duplicate: number;
unpersisted: number;
likelySecret: number;
};
}
/** Build the env inventory for a stack on a node. Key names only; never any value. */
export async function buildEnvInventory(nodeId: number, stackName: string): Promise<EnvInventory> {
const sources = await resolveStackEnvSources(nodeId, stackName);
const refByName = new Map(sources.interpolationRefs.map(r => [r.name, r]));
// Render the effective model: the authoritative injected-key set and the
// unset/missing-required signal. Failure path materializes no values.
const result = await ComposeService.getInstance(nodeId).renderConfig(stackName);
const missingRequired = new Set(parseMissingRequiredVars(result.stderr));
let renderable = false;
let unsetVars = new Set<string>();
const effectiveKeys = new Set<string>();
const effectiveKeysByService = new Map<string, Set<string>>();
if (result.rendered !== null) {
unsetVars = new Set(parseUnsetEnvVars(result.stderr));
try {
const model = parseEffectiveModel(JSON.parse(result.rendered), stackName);
for (const svc of model.services) {
const svcKeys = new Set(svc.envKeys);
effectiveKeysByService.set(svc.name, svcKeys);
for (const k of svc.envKeys) effectiveKeys.add(k);
}
renderable = true;
} catch {
renderable = false;
}
}
const names = new Set<string>();
const locations = new Map<string, Set<string>>(); // distinct physical definition locations
const itemSources = new Map<string, Set<EnvSource>>();
const dotenvKeys = new Set<string>();
const injectionFileKeys = new Set<string>();
const addLocation = (key: string, location: string) => {
const set = locations.get(key) ?? new Set<string>();
set.add(location);
locations.set(key, set);
};
const addSource = (key: string, source: EnvSource) => {
const set = itemSources.get(key) ?? new Set<EnvSource>();
set.add(source);
itemSources.set(key, set);
};
for (const ref of sources.interpolationRefs) names.add(ref.name);
// Env-file provenance: each physical file contributes ONE source label and ONE
// location, so the project `.env` doubling as `env_file: .env` is not a duplicate.
for (const file of sources.envFiles) {
if (!file.resolvedPath || file.existence !== 'present') continue;
const { keys, unverifiable } = await readEnvFileKeys(file.resolvedPath, sources.baseDir);
if (unverifiable) {
// The existence probe said present, but the key read failed (a permission
// change, a race, or transient I/O). Surface that rather than silently
// reporting zero keys for a file the inventory claims is present.
file.existence = 'unverifiable';
continue;
}
const label: EnvSource = file.isInterpolationSource ? 'dotenv' : 'env-file';
for (const key of keys) {
names.add(key);
addLocation(key, file.resolvedPath);
addSource(key, label);
if (file.isInterpolationSource) dotenvKeys.add(key);
if (file.isInjectionSource) injectionFileKeys.add(key);
}
}
// Inline `environment:` keys, reconciled against the effective model PER SERVICE
// so a key an override removed from one service is not reported as inline just
// because another service defines the same name elsewhere.
const inlineAll = new Set<string>();
for (const keys of Object.values(sources.inlineEnvKeysByService)) for (const k of keys) inlineAll.add(k);
for (const [service, keys] of Object.entries(sources.inlineEnvKeysByService)) {
const svcEffective = effectiveKeysByService.get(service);
for (const key of keys) {
if (renderable && !svcEffective?.has(key)) continue;
names.add(key);
addLocation(key, 'inline');
addSource(key, 'compose-inline');
}
}
for (const key of effectiveKeys) names.add(key);
const injectedKeys = renderable
? effectiveKeys
: new Set<string>([...inlineAll, ...injectionFileKeys]);
const shellHas = (name: string): boolean => Object.prototype.hasOwnProperty.call(process.env, name);
const items: EnvInventoryItem[] = [];
for (const key of [...names].sort()) {
const ref = refByName.get(key);
const usedForInterpolation = !!ref;
const required = ref?.required ?? false;
const hasDefault = ref?.hasDefault ?? false;
const alternate = ref?.alternate ?? false;
const injected = injectedKeys.has(key);
const locationCount = locations.get(key)?.size ?? 0;
const sourceSet = new Set<EnvSource>(itemSources.get(key) ?? []);
// A referenced var defined in no stack-local source resolves from the shell or
// is missing. Surface that provenance without adding unreferenced shell keys.
if (usedForInterpolation && locationCount === 0 && !injected) {
if (shellHas(key)) sourceSet.add('process-env');
else sourceSet.add('compose-ref');
}
// Compose's own resolution is authoritative for unset/empty required vars; the
// heuristic only fills in when the model could not be rendered for other reasons.
const refUndefinedUnshelled = usedForInterpolation && !hasDefault && !alternate && locationCount === 0 && !shellHas(key);
const missing = missingRequired.has(key)
|| (renderable && unsetVars.has(key))
|| (!renderable && refUndefinedUnshelled);
const unpersisted = usedForInterpolation && locationCount === 0 && !missing && shellHas(key);
const unused = dotenvKeys.has(key) && !usedForInterpolation && !injected;
let status: EnvItemStatus;
if (missing) status = 'missing';
else if (locationCount >= 2) status = 'duplicate';
else if (unpersisted) status = 'unpersisted';
else if (unused) status = 'unused';
else status = 'present';
items.push({
key,
sources: [...sourceSet],
usedForInterpolation,
injectedIntoService: injected,
required,
hasDefault,
likelySecret: isLikelySecretKey(key),
status,
});
}
const envFiles: EnvFileInfo[] = sources.envFiles.map(f => ({
rawPaths: f.rawPaths,
existence: f.existence,
required: f.required,
isInterpolationSource: f.isInterpolationSource,
isInjectionSource: f.isInjectionSource,
declaringServices: f.declaringServices,
}));
return {
stackName,
renderable,
items,
envFiles,
summary: {
total: items.length,
missing: items.filter(i => i.status === 'missing').length,
unused: items.filter(i => i.status === 'unused').length,
duplicate: items.filter(i => i.status === 'duplicate').length,
unpersisted: items.filter(i => i.status === 'unpersisted').length,
likelySecret: items.filter(i => i.likelySecret).length,
},
};
}
+1 -1
View File
@@ -13,7 +13,7 @@
* resolved secret VALUE in the rendered model can never reach this payload.
*/
import { ComposeService } from './ComposeService';
import { parseMissingRequiredVars } from './ComposeDoctorService';
import { parseMissingRequiredVars } from '../helpers/envVarParse';
import { getErrorMessage } from '../utils/errors';
import { redactSensitiveText, sanitizeForLog } from '../utils/safeLog';
@@ -10,7 +10,7 @@ import DockerController, { type DependencySnapshot } from '../DockerController';
import { ComposeService } from '../ComposeService';
import { FileSystemService } from '../FileSystemService';
import { parseEffectiveModel, type EffectiveModel } from '../preflight/effectiveModel';
import { parseMissingRequiredVars } from '../ComposeDoctorService';
import { parseMissingRequiredVars } from '../../helpers/envVarParse';
import {
compareStackNetworks, fromEffectiveModel, isAllInterfaces, isLoopback,
} from './normalize';
+16
View File
@@ -87,6 +87,21 @@ const envUnset: PreflightRule = {
},
};
const envFileMissing: PreflightRule = {
id: 'env-file-missing',
run(ctx) {
return ctx.missingEnvFiles.map(f => ({
ruleId: 'env-file-missing',
severity: 'high' as const,
title: `Missing env file ${f.rawPath}`,
message: `The Compose file declares env_file "${f.rawPath}"${f.services.length ? ` for service ${f.services.join(', ')}` : ''}, but no such file exists in the stack directory. Compose fails to start the stack when a required env_file is absent.`,
sourcePath: f.rawPath,
remediation: `Create ${f.rawPath} in the stack directory, fix the path, or mark the entry optional with "required: false".`,
service: f.services[0],
}));
},
};
const portConflictNode: PreflightRule = {
id: 'port-conflict-node',
run(ctx) {
@@ -671,6 +686,7 @@ const sensitiveServiceBroadExposure: PreflightRule = {
export const PREFLIGHT_RULES: PreflightRule[] = [
renderFailed,
envUnset,
envFileMissing,
portConflictNode,
portConflictInternal,
portExposedAllInterfaces,
+8
View File
@@ -43,6 +43,12 @@ export interface PreflightReport {
findings: PreflightFinding[];
}
/** A declared `env_file:` that is required and absent on disk (names only). */
export interface MissingEnvFile {
rawPath: string;
services: string[];
}
/** A host port bound by a running container on the target node. */
export interface NodePortBinding {
publishedPort: number;
@@ -83,6 +89,8 @@ export interface PreflightContext {
renderError: string | null;
/** Variable names Compose reported as unset (defaulted to empty string). */
unsetEnvVars: string[];
/** Declared `env_file:` paths that are required but absent on disk (names only). */
missingEnvFiles: MissingEnvFile[];
/** Service names parsed from the literal source file (pre-render). */
sourceServiceNames: string[];
/** Whether the source file could be read; gates source-derived checks so an