diff --git a/backend/src/__tests__/compose-doctor-service.test.ts b/backend/src/__tests__/compose-doctor-service.test.ts index 3124b094..60a374ab 100644 --- a/backend/src/__tests__/compose-doctor-service.test.ts +++ b/backend/src/__tests__/compose-doctor-service.test.ts @@ -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); diff --git a/backend/src/__tests__/env-deploy-guard.test.ts b/backend/src/__tests__/env-deploy-guard.test.ts new file mode 100644 index 00000000..d5a83c39 --- /dev/null +++ b/backend/src/__tests__/env-deploy-guard.test.ts @@ -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); +} + +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 }).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 }).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 }).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 }).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 }).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 }).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 }, '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 }, 'createAtomicBackup').mockResolvedValue(undefined); + await expect(compose.updateStack('s', undefined, true)).rejects.toThrow(/REQ/); + expect(backup).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/src/__tests__/env-file-resolution.test.ts b/backend/src/__tests__/env-file-resolution.test.ts new file mode 100644 index 00000000..d0a371f1 --- /dev/null +++ b/backend/src/__tests__/env-file-resolution.test.ts @@ -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): 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); + 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); + 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); + }); +}); diff --git a/backend/src/__tests__/env-inventory.test.ts b/backend/src/__tests__/env-inventory.test.ts new file mode 100644 index 00000000..535028f1 --- /dev/null +++ b/backend/src/__tests__/env-inventory.test.ts @@ -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): 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> | 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); + }); +}); diff --git a/backend/src/__tests__/env-var-parse.test.ts b/backend/src/__tests__/env-var-parse.test.ts new file mode 100644 index 00000000..64b6f5a2 --- /dev/null +++ b/backend/src/__tests__/env-var-parse.test.ts @@ -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']); + }); +}); diff --git a/backend/src/__tests__/preflight-rules.test.ts b/backend/src/__tests__/preflight-rules.test.ts index 5eddb896..89b34201 100644 --- a/backend/src/__tests__/preflight-rules.test.ts +++ b/backend/src/__tests__/preflight-rules.test.ts @@ -26,6 +26,7 @@ function ctx(over: Partial = {}): 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', diff --git a/backend/src/__tests__/secret-classification.test.ts b/backend/src/__tests__/secret-classification.test.ts new file mode 100644 index 00000000..2f1352cd --- /dev/null +++ b/backend/src/__tests__/secret-classification.test.ts @@ -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); + }); +}); diff --git a/backend/src/__tests__/settings-routes.test.ts b/backend/src/__tests__/settings-routes.test.ts index 010186fe..ffe56b48 100644 --- a/backend/src/__tests__/settings-routes.test.ts +++ b/backend/src/__tests__/settings-routes.test.ts @@ -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 }); diff --git a/backend/src/helpers/envFileResolution.ts b/backend/src/helpers/envFileResolution.ts new file mode 100644 index 00000000..825ab65d --- /dev/null +++ b/backend/src/helpers/envFileResolution.ts @@ -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; + /** `${}` 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).path; + if (typeof p === 'string') { + out.push({ rawPath: p, required: (entry as Record).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); + } + return []; +} + +async function existenceOf(fsService: FileSystemService, abs: string, baseDir: string): Promise { + 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 { + 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; + 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 | null; + const services = (parsed?.services && typeof parsed.services === 'object') + ? parsed.services as Record + : {}; + 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 { + 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(); + 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 = {}; + 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; + + 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), + }; +} diff --git a/backend/src/helpers/envVarParse.ts b/backend/src/helpers/envVarParse.ts new file mode 100644 index 00000000..d2720b48 --- /dev/null +++ b/backend/src/helpers/envVarParse.ts @@ -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 (?(); + 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 { + 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(); + 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(); + 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); +} diff --git a/backend/src/helpers/secretClassification.ts b/backend/src/helpers/secretClassification.ts new file mode 100644 index 00000000..7a435cb9 --- /dev/null +++ b/backend/src/helpers/secretClassification.ts @@ -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)); +} diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 3755a34a..674dc85e 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -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(); diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index 2e218e8e..a6a1a826 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -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 { - 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(); - - 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 diff --git a/backend/src/services/CapabilityRegistry.ts b/backend/src/services/CapabilityRegistry.ts index eb0c2e01..6794beae 100644 --- a/backend/src/services/CapabilityRegistry.ts +++ b/backend/src/services/CapabilityRegistry.ts @@ -38,6 +38,7 @@ export const CAPABILITIES = [ 'compose-doctor', 'update-guard', 'compose-networking', + 'env-inventory', ] as const; export type Capability = (typeof CAPABILITIES)[number]; diff --git a/backend/src/services/ComposeDoctorService.ts b/backend/src/services/ComposeDoctorService.ts index 56acfb96..4f9e8420 100644 --- a/backend/src/services/ComposeDoctorService.ts +++ b/backend/src/services/ComposeDoctorService.ts @@ -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(); - 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, diff --git a/backend/src/services/ComposeService.ts b/backend/src/services/ComposeService.ts index df5c4fae..9ddf4495 100644 --- a/backend/src/services/ComposeService.ts +++ b/backend/src/services/ComposeService.ts @@ -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 { + 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 { + 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 { + await this.assertRequiredEnvPresent(stackName); const stackDir = path.join(this.baseDir, stackName); const debug = isDebugEnabled(); const t0 = Date.now(); diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 553836cc..6e2ec43f 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -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; diff --git a/backend/src/services/EnvInventoryService.ts b/backend/src/services/EnvInventoryService.ts new file mode 100644 index 00000000..69db393a --- /dev/null +++ b/backend/src/services/EnvInventoryService.ts @@ -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 { + 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(); + const effectiveKeys = new Set(); + const effectiveKeysByService = new Map>(); + 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(); + const locations = new Map>(); // distinct physical definition locations + const itemSources = new Map>(); + const dotenvKeys = new Set(); + const injectionFileKeys = new Set(); + + const addLocation = (key: string, location: string) => { + const set = locations.get(key) ?? new Set(); + set.add(location); + locations.set(key, set); + }; + const addSource = (key: string, source: EnvSource) => { + const set = itemSources.get(key) ?? new Set(); + 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(); + 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([...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(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, + }, + }; +} diff --git a/backend/src/services/effectiveAnatomy.ts b/backend/src/services/effectiveAnatomy.ts index 8866e60f..b945eec2 100644 --- a/backend/src/services/effectiveAnatomy.ts +++ b/backend/src/services/effectiveAnatomy.ts @@ -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'; diff --git a/backend/src/services/network/composeNetworkInspector.ts b/backend/src/services/network/composeNetworkInspector.ts index 3d40810b..5176af18 100644 --- a/backend/src/services/network/composeNetworkInspector.ts +++ b/backend/src/services/network/composeNetworkInspector.ts @@ -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'; diff --git a/backend/src/services/preflight/rules.ts b/backend/src/services/preflight/rules.ts index 39855a62..a197216b 100644 --- a/backend/src/services/preflight/rules.ts +++ b/backend/src/services/preflight/rules.ts @@ -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, diff --git a/backend/src/services/preflight/types.ts b/backend/src/services/preflight/types.ts index c72cb67c..7aba3478 100644 --- a/backend/src/services/preflight/types.ts +++ b/backend/src/services/preflight/types.ts @@ -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 diff --git a/docs/docs.json b/docs/docs.json index a1dc1085..906bb2c9 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -107,6 +107,7 @@ "features/stack-drift", "features/compose-doctor", "features/compose-networking", + "features/environment-guardrails", "features/stack-labels", "features/sidebar" ] diff --git a/docs/features/environment-guardrails.mdx b/docs/features/environment-guardrails.mdx new file mode 100644 index 00000000..35bfb821 --- /dev/null +++ b/docs/features/environment-guardrails.mdx @@ -0,0 +1,73 @@ +--- +title: Environment and Secrets Guardrails +description: See every environment variable a stack uses, where it comes from, and whether it is likely a secret, without ever exposing a value. Sencho derives a per-stack env inventory, flags missing and duplicate variables, and can block a deploy when a required variable has no value. +--- + +The **Environment** tab in the right-hand **Anatomy** panel answers a question Compose makes surprisingly hard: *which environment variables does this stack actually use, where does each one come from, and is anything missing or sensitive?* Sencho derives the answer from the stack's Compose files and env files and presents it as a single inventory. + +The inventory is advisory and read only. It never changes a stack, and it works entirely from variable **names**: a value is never read into the report, the checklist, or the logs, so nothing sensitive is exposed. + +## Interpolation versus container injection + +Compose treats environment in two distinct ways, and mixing them up is a common source of "it works on one host but not another": + +- **Interpolation.** A `${VAR}` reference in the Compose file is resolved from the project `.env` file and the shell environment. It substitutes a value into the file before the container is created. +- **Container injection.** Values under a service's `environment:` block and in any `env_file:` are handed to the running container. They are never used to resolve `${VAR}` in the Compose file. + +The inventory labels each variable with how it is used, so you can tell at a glance whether a variable feeds Compose interpolation, is injected into a service, or both. + +## What the inventory shows + +For every variable, Sencho records its source, its scope, and a status: + +| Status | Meaning | +|--------|---------| +| **Present** | Referenced or injected, and defined in a stack-local source. | +| **Missing** | Referenced by the Compose file but not set anywhere, so Compose substitutes an empty string or fails on a required variable. | +| **Unused** | Defined in the project `.env` but never referenced or injected. | +| **Duplicate** | Defined in two different places, which can resolve to different effective values. | +| **Shell-only** | Referenced and resolved only from the shell of the host, not persisted with the stack, so it will not follow the stack to another node. | + +Variables whose name suggests a secret, such as `DB_PASSWORD`, `API_KEY`, or `CLIENT_SECRET`, are marked with a lock and show presence only. Their value is never read, so it cannot appear in the inventory, the checklist, or anywhere else. + +### Copy env checklist + +The **copy env checklist** action copies the inventory as a Markdown checklist of variable names, status, and source. It is built for sharing in a ticket or a runbook, so it deliberately contains no values, including for likely secrets. + +## Preflight: missing env files + +When a service declares an `env_file:` that does not exist in the stack directory, Compose refuses to start the stack. Sencho surfaces this as a **high-risk** finding in the [Compose Doctor](/features/compose-doctor) preflight, naming the file and the service that declares it, so you catch it before you deploy. An entry marked `required: false`, and a path that Sencho cannot resolve, are not reported. + +## Blocking a deploy on missing required variables + +A `${VAR:?message}` reference tells Compose the variable is required: the deploy fails if it is unset or empty. By default Sencho surfaces that as an advisory finding and lets Compose report it at deploy time. + +If you would rather fail fast with a clear message before anything runs, turn on **Block deploy on missing required env vars** under **Settings → Host alerts → Deploy guardrails**. With it on, a deploy or update is refused up front when a required variable is unset or empty, before any backup, image pull, or container change happens. It is off by default, applies to the node you set it on, and requires an admin to change. + +## Opening the inventory + +1. Click any stack in the left sidebar to open it. +2. Switch to the **Environment** tab in the Anatomy panel header. +3. Review the variables grouped by status, and use **copy env checklist** to share a values-free summary. + +The inventory is built against the **active node**, so selecting a remote node inspects the stack on the machine that owns it. + +## Troubleshooting + + + + The project `.env` is read for Compose `${VAR}` interpolation. A variable that lives only in `.env` and is never referenced by the Compose file, and never injected into a service, has nothing using it. Reference it with `${VAR}`, move it into a service's `environment:` or `env_file:` if the container needs it, or remove it. + + + The variable resolves from the host shell that Sencho runs in, not from a file stored with the stack. It works on this host but will not travel with the stack to another node. Add it to the project `.env` or an `env_file:` so the stack carries its own configuration. + + + When the project `.env` is also listed as an `env_file:`, it is one physical file doing two jobs: interpolation and injection. Sencho counts it once, so this common setup is never flagged as a duplicate. A duplicate means the key is genuinely defined in two different locations. + + + The classification is a heuristic based on the variable name, and it errs toward marking things as secret. Marking a non-secret as a likely secret only hides a value that the inventory never reads anyway, so there is no downside; the variable still shows its name, source, and status. + + + The tab appears when the active node reports that it supports the env inventory. A node running an older version of Sencho does not advertise it, so the tab is hidden for that node until it is updated. + + diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 5f7e77bb..9145e37b 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -820,6 +820,104 @@ paths: "500": $ref: "#/components/responses/InternalError" + /api/stacks/{stackName}/env-inventory: + get: + operationId: getStackEnvInventory + tags: [Stacks] + summary: Get environment inventory + description: >- + Returns a per-stack inventory of environment variables: each variable's + source, whether Compose interpolates it or injects it into a container, + and a status (present, missing, unused, duplicate, or unpersisted), plus + likely-secret classification. Names only: a variable value is never read + or returned. When the effective model cannot be rendered, `renderable` is + false and the inventory is derived from the authored source alone. + Requires `stack:read` permission. + parameters: + - $ref: "#/components/parameters/stackName" + - $ref: "#/components/parameters/nodeId" + responses: + "200": + description: The environment inventory. + content: + application/json: + schema: + type: object + required: [stackName, renderable, items, envFiles, summary] + properties: + stackName: + type: string + renderable: + type: boolean + description: False when the effective model could not be rendered; the inventory is then partial. + items: + type: array + items: + type: object + required: [key, sources, usedForInterpolation, injectedIntoService, required, hasDefault, likelySecret, status] + properties: + key: + type: string + sources: + type: array + items: + type: string + enum: [compose-inline, env-file, dotenv, process-env, compose-ref] + usedForInterpolation: + type: boolean + injectedIntoService: + type: boolean + required: + type: boolean + hasDefault: + type: boolean + likelySecret: + type: boolean + status: + type: string + enum: [present, missing, unused, duplicate, unpersisted] + envFiles: + type: array + items: + type: object + properties: + rawPaths: + type: array + items: + type: string + existence: + type: string + enum: [present, missing, unverifiable] + required: + type: boolean + isInterpolationSource: + type: boolean + isInjectionSource: + type: boolean + declaringServices: + type: array + items: + type: string + summary: + type: object + properties: + total: { type: integer } + missing: { type: integer } + unused: { type: integer } + duplicate: { type: integer } + unpersisted: { type: integer } + likelySecret: { type: integer } + "403": + $ref: "#/components/responses/Forbidden" + "404": + description: Stack not found. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "500": + $ref: "#/components/responses/InternalError" + /api/stacks/{stackName}/env: get: operationId: getStackEnv diff --git a/frontend/src/components/StackAnatomyPanel.tsx b/frontend/src/components/StackAnatomyPanel.tsx index ac48d312..eef7b39f 100644 --- a/frontend/src/components/StackAnatomyPanel.tsx +++ b/frontend/src/components/StackAnatomyPanel.tsx @@ -12,6 +12,7 @@ import { StackActivityTimeline } from './stack/StackActivityTimeline'; import StackDossierPanel from './stack/StackDossierPanel'; import DriftPanel from './stack/DriftPanel'; import PreflightPanel from './stack/PreflightPanel'; +import EnvironmentPanel from './stack/EnvironmentPanel'; import StackNetworkingPanel from './stack/StackNetworkingPanel'; import { useNodes } from '@/context/NodeContext'; import type { NotificationItem } from '@/components/dashboard/types'; @@ -92,6 +93,7 @@ export default function StackAnatomyPanel({ const { hasCapability, activeNode } = useNodes(); const doctorEnabled = hasCapability('compose-doctor'); const networkingEnabled = hasCapability('compose-networking'); + const envInventoryEnabled = hasCapability('env-inventory'); const [gitSource, setGitSource] = useState<{ stack: string; info: GitSourceInfo; multiFile: boolean } | null>(null); // Merged effective facts (services/ports/volumes/networks/restart) for a @@ -349,6 +351,9 @@ export default function StackAnatomyPanel({ Activity Dossier Drift + {envInventoryEnabled && ( + Environment + )} {networkingEnabled && ( Networking )} @@ -593,6 +598,11 @@ export default function StackAnatomyPanel({ )} + {envInventoryEnabled && ( + + + + )} {doctorEnabled && ( diff --git a/frontend/src/components/settings/HostAlertsSection.tsx b/frontend/src/components/settings/HostAlertsSection.tsx index ec9e8fc9..d48e9d51 100644 --- a/frontend/src/components/settings/HostAlertsSection.tsx +++ b/frontend/src/components/settings/HostAlertsSection.tsx @@ -30,7 +30,7 @@ function SectionSkeleton() { ); } -type HostAlertFields = Pick; +type HostAlertFields = Pick; const DEFAULT_HOST_ALERTS: HostAlertFields = { host_cpu_limit: DEFAULT_SETTINGS.host_cpu_limit, @@ -40,6 +40,7 @@ const DEFAULT_HOST_ALERTS: HostAlertFields = { global_crash: DEFAULT_SETTINGS.global_crash, health_gate_enabled: DEFAULT_SETTINGS.health_gate_enabled, health_gate_window_seconds: DEFAULT_SETTINGS.health_gate_window_seconds, + env_block_deploy_on_missing_required: DEFAULT_SETTINGS.env_block_deploy_on_missing_required, }; export function HostAlertsSection({ onDirtyChange }: HostAlertsSectionProps) { @@ -80,6 +81,7 @@ export function HostAlertsSection({ onDirtyChange }: HostAlertsSectionProps) { global_crash: (nodeData.global_crash as '0' | '1') ?? DEFAULT_SETTINGS.global_crash, health_gate_enabled: (nodeData.health_gate_enabled as '0' | '1') ?? DEFAULT_SETTINGS.health_gate_enabled, health_gate_window_seconds: nodeData.health_gate_window_seconds ?? DEFAULT_SETTINGS.health_gate_window_seconds, + env_block_deploy_on_missing_required: (nodeData.env_block_deploy_on_missing_required as '0' | '1') ?? DEFAULT_SETTINGS.env_block_deploy_on_missing_required, }; reset(safe); } catch (e) { @@ -212,6 +214,18 @@ export function HostAlertsSection({ onDirtyChange }: HostAlertsSectionProps) { + + + onSettingChange('env_block_deploy_on_missing_required', next ? '1' : '0')} + /> + + + {!readOnly && ( diff --git a/frontend/src/components/settings/__tests__/SectionSavePayloads.test.tsx b/frontend/src/components/settings/__tests__/SectionSavePayloads.test.tsx index 1853c7c1..7e2c7a0a 100644 --- a/frontend/src/components/settings/__tests__/SectionSavePayloads.test.tsx +++ b/frontend/src/components/settings/__tests__/SectionSavePayloads.test.tsx @@ -67,6 +67,7 @@ describe('split section save payloads', () => { fireEvent.click(save); await waitFor(() => expect(mockedFetch.mock.calls.some(c => c[1]?.method === 'PATCH')).toBe(true)); expect(patchedKeys()).toEqual([ + 'env_block_deploy_on_missing_required', 'global_crash', 'health_gate_enabled', 'health_gate_window_seconds', diff --git a/frontend/src/components/settings/types.ts b/frontend/src/components/settings/types.ts index 10d5b074..b34a7200 100644 --- a/frontend/src/components/settings/types.ts +++ b/frontend/src/components/settings/types.ts @@ -17,6 +17,7 @@ export interface PatchableSettings { snapshot_documentation?: '0' | '1'; health_gate_enabled?: '0' | '1'; health_gate_window_seconds?: string; + env_block_deploy_on_missing_required?: '0' | '1'; } export const DEFAULT_SETTINGS: PatchableSettings = { @@ -38,6 +39,7 @@ export const DEFAULT_SETTINGS: PatchableSettings = { snapshot_documentation: '0', health_gate_enabled: '1', health_gate_window_seconds: '90', + env_block_deploy_on_missing_required: '0', }; export type SectionId = diff --git a/frontend/src/components/stack/EnvironmentPanel.tsx b/frontend/src/components/stack/EnvironmentPanel.tsx new file mode 100644 index 00000000..d3eb9858 --- /dev/null +++ b/frontend/src/components/stack/EnvironmentPanel.tsx @@ -0,0 +1,235 @@ +import { useEffect, useState } from 'react'; +import { Lock, Copy, Info, TriangleAlert, ShieldAlert } from 'lucide-react'; +import { apiFetch } from '@/lib/api'; +import { cn } from '@/lib/utils'; +import { toast } from '@/components/ui/toast-store'; +import { copyToClipboard } from '@/lib/clipboard'; +import { useNodes } from '@/context/NodeContext'; +import { + buildEnvChecklistMarkdown, + SOURCE_LABELS, + STATUS_LABELS, + type EnvInventory, + type EnvInventoryItem, + type EnvItemStatus, + type EnvFileExistence, +} from '@/lib/envChecklist'; + +const LABEL_CLASS = 'font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle'; +const ACTION_CLASS = + 'inline-flex items-center gap-1 font-mono text-[10px] uppercase tracking-wide text-stat-subtitle hover:text-brand transition-colors disabled:opacity-40'; +const CARD_CLASS = 'rounded-lg border px-3 py-2.5'; + +const STATUS_META: Record = { + present: { tone: 'border-muted bg-card/40 text-stat-subtitle' }, + missing: { tone: 'border-destructive/40 bg-destructive/[0.06] text-destructive' }, + duplicate: { tone: 'border-warning/40 bg-warning/[0.06] text-warning' }, + unpersisted: { tone: 'border-warning/40 bg-warning/[0.06] text-warning' }, + unused: { tone: 'border-info/40 bg-info/[0.06] text-info' }, +}; + +const EXISTENCE_TONE: Record = { + present: 'border-muted bg-card/40 text-stat-subtitle', + missing: 'border-destructive/40 bg-destructive/[0.06] text-destructive', + unverifiable: 'border-warning/40 bg-warning/[0.06] text-warning', +}; + +/** Local status pill. Env statuses are not vuln-scan severities, so this is its own badge. */ +function EnvironmentStatusBadge({ status }: { status: EnvItemStatus }) { + return ( + + {STATUS_LABELS[status]} + + ); +} + +function scopeLabel(item: EnvInventoryItem): string { + const parts: string[] = []; + if (item.usedForInterpolation) parts.push('interpolation'); + if (item.injectedIntoService) parts.push('injected'); + return parts.join(' + ') || 'unused'; +} + +function ItemRow({ item }: { item: EnvInventoryItem }) { + const sources = item.sources.map(s => SOURCE_LABELS[s] ?? s).join(', ') || '-'; + return ( +
+
+ {item.key} + {item.likelySecret && ( + + secret + + )} + {item.required && ( + required + )} + +
+
+ source: {sources} + scope: {scopeLabel(item)} +
+
+ ); +} + +const STATUS_GROUPS: { status: EnvItemStatus; label: string }[] = [ + { status: 'missing', label: 'missing' }, + { status: 'duplicate', label: 'duplicate' }, + { status: 'unpersisted', label: 'shell-only' }, + { status: 'unused', label: 'unused' }, + { status: 'present', label: 'present' }, +]; + +export default function EnvironmentPanel({ stackName }: { stackName: string }) { + const { activeNode } = useNodes(); + const nodeId = activeNode?.id; + const [inventory, setInventory] = useState(null); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(false); + const [copying, setCopying] = useState(false); + + useEffect(() => { + let cancelled = false; + const run = async () => { + setLoading(true); + setLoadError(false); + try { + const res = await apiFetch(`/stacks/${stackName}/env-inventory`); + if (cancelled) return; + if (!res.ok) { + setLoadError(true); + toast.error('Failed to load the environment inventory.'); + return; + } + setInventory((await res.json()) as EnvInventory); + } catch { + if (!cancelled) { + setLoadError(true); + toast.error('Failed to load the environment inventory.'); + } + } finally { + if (!cancelled) setLoading(false); + } + }; + void run(); + return () => { cancelled = true; }; + }, [stackName, nodeId]); + + const copyChecklist = async () => { + if (!inventory) return; + setCopying(true); + try { + await copyToClipboard(buildEnvChecklistMarkdown(inventory)); + toast.success('Env checklist copied. Names and status only, no values.'); + } catch { + toast.error('Failed to copy the env checklist.'); + } finally { + setCopying(false); + } + }; + + return ( +
+
+ environment + +
+ +

+ Compose reads .env and the shell for {'${VAR}'} interpolation, + while env_file and inline environment are injected into the container. + Values are never read or shown: likely secrets show presence only. +

+ + {loadError ? ( +
+ + Could not load the environment inventory. +
+ ) : loading || !inventory ? ( +
Loading environment…
+ ) : ( + <> + {!inventory.renderable && ( +
+ + Effective model unavailable. Showing the authored env surface only. +
+ )} + +
+ {inventory.summary.total} vars + {inventory.summary.missing > 0 && {inventory.summary.missing} missing} + {inventory.summary.duplicate > 0 && {inventory.summary.duplicate} duplicate} + {inventory.summary.unpersisted > 0 && {inventory.summary.unpersisted} shell-only} + {inventory.summary.unused > 0 && {inventory.summary.unused} unused} + {inventory.summary.likelySecret > 0 && {inventory.summary.likelySecret} likely secret} +
+ + {(() => { + // Declared env files plus anything not cleanly present, so a missing or + // unreadable env_file is visible right here, alongside the variables. + const files = inventory.envFiles.filter(f => f.isInjectionSource || f.existence !== 'present'); + if (files.length === 0) return null; + return ( +
+
env files · {files.length}
+
+ {files.map((f, i) => ( +
+ {f.rawPaths.join(', ')} + + {f.existence} + + {f.declaringServices.length > 0 && ( + {f.declaringServices.join(', ')} + )} +
+ ))} +
+
+ ); + })()} + + {inventory.items.length === 0 ? ( +
+ + No environment variables are referenced or defined for this stack. +
+ ) : ( + STATUS_GROUPS.map(({ status, label }) => { + const items = inventory.items.filter(i => i.status === status); + if (items.length === 0) return null; + return ( +
+
{label} · {items.length}
+
+ {items.map(item => )} +
+
+ ); + }) + )} + + )} +
+ ); +} diff --git a/frontend/src/components/stack/__tests__/EnvironmentPanel.test.tsx b/frontend/src/components/stack/__tests__/EnvironmentPanel.test.tsx new file mode 100644 index 00000000..e8badf17 --- /dev/null +++ b/frontend/src/components/stack/__tests__/EnvironmentPanel.test.tsx @@ -0,0 +1,94 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; + +vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() })); +vi.mock('@/components/ui/toast-store', () => ({ + toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn() }, +})); +vi.mock('@/lib/clipboard', () => ({ copyToClipboard: vi.fn().mockResolvedValue(undefined) })); +vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ activeNode: { id: 'local' } }) })); + +import { apiFetch } from '@/lib/api'; +import { copyToClipboard } from '@/lib/clipboard'; +import EnvironmentPanel from '../EnvironmentPanel'; +import type { EnvInventory } from '@/lib/envChecklist'; + +const mockedFetch = apiFetch as unknown as ReturnType; +const mockedCopy = copyToClipboard as unknown as ReturnType; + +const INVENTORY: EnvInventory = { + stackName: 'demo', + renderable: true, + items: [ + { key: 'DB_PASSWORD', sources: ['env-file'], usedForInterpolation: false, injectedIntoService: true, required: false, hasDefault: false, likelySecret: true, status: 'present' }, + { key: 'MISSING_VAR', sources: ['compose-ref'], usedForInterpolation: true, injectedIntoService: false, required: true, hasDefault: false, likelySecret: false, status: 'missing' }, + ], + envFiles: [], + summary: { total: 2, missing: 1, unused: 0, duplicate: 0, unpersisted: 0, likelySecret: 1 }, +}; + +beforeEach(() => { + mockedFetch.mockReset(); + mockedCopy.mockReset().mockResolvedValue(undefined); +}); + +describe('EnvironmentPanel', () => { + it('renders items with status badges and a secret presence badge (never a value)', async () => { + mockedFetch.mockResolvedValue({ ok: true, json: async () => INVENTORY }); + render(); + expect(screen.getByText(/loading environment/i)).toBeTruthy(); + await screen.findByText('DB_PASSWORD'); + expect(screen.getByText('MISSING_VAR')).toBeTruthy(); + const badges = screen.getAllByTestId('env-status-badge'); + expect(badges.some(b => b.getAttribute('data-status') === 'missing')).toBe(true); + expect(screen.getByTestId('env-secret-badge')).toBeTruthy(); + }); + + it('copies a checklist that excludes values', async () => { + mockedFetch.mockResolvedValue({ ok: true, json: async () => INVENTORY }); + render(); + await screen.findByText('DB_PASSWORD'); + fireEvent.click(screen.getByTestId('env-copy-checklist-btn')); + await waitFor(() => expect(mockedCopy).toHaveBeenCalled()); + const md = mockedCopy.mock.calls[0][0] as string; + expect(md).toContain('DB_PASSWORD'); + expect(md).toContain('No values are included'); + }); + + it('renders an error state when the fetch fails', async () => { + mockedFetch.mockResolvedValue({ ok: false }); + render(); + await screen.findByText(/could not load the environment inventory/i); + }); + + it('renders the env files section, the shell-only status, and the partial-render banner', async () => { + const inv: EnvInventory = { + stackName: 'demo', + renderable: false, + items: [ + { key: 'SHELL_VAR', sources: ['process-env'], usedForInterpolation: true, injectedIntoService: false, required: false, hasDefault: false, likelySecret: false, status: 'unpersisted' }, + { key: 'DUP_VAR', sources: ['dotenv', 'compose-inline'], usedForInterpolation: true, injectedIntoService: true, required: false, hasDefault: false, likelySecret: false, status: 'duplicate' }, + ], + envFiles: [ + { rawPaths: ['./gone.env'], existence: 'missing', required: true, isInterpolationSource: false, isInjectionSource: true, declaringServices: ['web'] }, + ], + summary: { total: 2, missing: 0, unused: 0, duplicate: 1, unpersisted: 1, likelySecret: 0 }, + }; + mockedFetch.mockResolvedValue({ ok: true, json: async () => inv }); + render(); + await screen.findByText('SHELL_VAR'); + expect(screen.getAllByTestId('env-status-badge').some(b => b.getAttribute('data-status') === 'unpersisted')).toBe(true); + expect(screen.getByTestId('env-files-section')).toBeTruthy(); + expect(screen.getByText('./gone.env')).toBeTruthy(); + expect(screen.getByText(/effective model unavailable/i)).toBeTruthy(); + }); + + it('renders an empty state when no variables are present', async () => { + mockedFetch.mockResolvedValue({ + ok: true, + json: async () => ({ ...INVENTORY, items: [], summary: { total: 0, missing: 0, unused: 0, duplicate: 0, unpersisted: 0, likelySecret: 0 } }), + }); + render(); + await screen.findByText(/no environment variables are referenced/i); + }); +}); diff --git a/frontend/src/lib/__tests__/envChecklist.test.ts b/frontend/src/lib/__tests__/envChecklist.test.ts new file mode 100644 index 00000000..eceb0d34 --- /dev/null +++ b/frontend/src/lib/__tests__/envChecklist.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import { buildEnvChecklistMarkdown, type EnvInventory } from '../envChecklist'; + +const inv: EnvInventory = { + stackName: 'demo', + renderable: true, + items: [ + { key: 'DB_PASSWORD', sources: ['env-file'], usedForInterpolation: false, injectedIntoService: true, required: false, hasDefault: false, likelySecret: true, status: 'present' }, + { key: 'MISSING_VAR', sources: ['compose-ref'], usedForInterpolation: true, injectedIntoService: false, required: true, hasDefault: false, likelySecret: false, status: 'missing' }, + ], + envFiles: [], + summary: { total: 2, missing: 1, unused: 0, duplicate: 0, unpersisted: 0, likelySecret: 1 }, +}; + +describe('buildEnvChecklistMarkdown', () => { + it('lists names, status, and source, and marks likely secrets without a value', () => { + const md = buildEnvChecklistMarkdown(inv); + expect(md).toContain('# Environment checklist · demo'); + expect(md).toContain('No values are included'); + expect(md).toContain('DB_PASSWORD'); + expect(md).toContain('likely secret (value hidden)'); + expect(md).toContain('status: missing'); + expect(md).toMatch(/- \[x\] DB_PASSWORD/); // present → checked + expect(md).toMatch(/- \[ \] MISSING_VAR/); // actionable → unchecked + }); + + it('notes partial data when the model could not be rendered', () => { + expect(buildEnvChecklistMarkdown({ ...inv, renderable: false })).toContain('could not be rendered'); + }); + + it('handles an empty inventory', () => { + const md = buildEnvChecklistMarkdown({ ...inv, items: [], summary: { total: 0, missing: 0, unused: 0, duplicate: 0, unpersisted: 0, likelySecret: 0 } }); + expect(md).toContain('None referenced or defined.'); + }); +}); diff --git a/frontend/src/lib/capabilities.ts b/frontend/src/lib/capabilities.ts index 57d80533..b36fc191 100644 --- a/frontend/src/lib/capabilities.ts +++ b/frontend/src/lib/capabilities.ts @@ -26,6 +26,7 @@ export const CAPABILITIES = [ 'compose-doctor', 'update-guard', 'compose-networking', + 'env-inventory', ] as const; export type Capability = (typeof CAPABILITIES)[number]; diff --git a/frontend/src/lib/envChecklist.ts b/frontend/src/lib/envChecklist.ts new file mode 100644 index 00000000..90921407 --- /dev/null +++ b/frontend/src/lib/envChecklist.ts @@ -0,0 +1,112 @@ +/** + * Env inventory types (mirrors the backend payload; the frontend never imports + * backend) and the "copy env checklist" Markdown builder. The checklist carries + * variable NAMES, status, and source only. A value is never present in the + * inventory payload and never written here. + */ + +export type EnvSource = 'compose-inline' | 'env-file' | 'dotenv' | 'process-env' | 'compose-ref'; +export type EnvItemStatus = 'present' | 'missing' | 'unused' | 'duplicate' | 'unpersisted'; +export type EnvFileExistence = 'present' | 'missing' | 'unverifiable'; + +export interface EnvInventoryItem { + key: string; + sources: EnvSource[]; + usedForInterpolation: boolean; + injectedIntoService: boolean; + required: boolean; + hasDefault: boolean; + likelySecret: boolean; + status: EnvItemStatus; +} + +export interface EnvFileInfo { + rawPaths: string[]; + existence: EnvFileExistence; + required: boolean; + isInterpolationSource: boolean; + isInjectionSource: boolean; + declaringServices: string[]; +} + +export interface EnvInventory { + stackName: string; + renderable: boolean; + items: EnvInventoryItem[]; + envFiles: EnvFileInfo[]; + summary: { + total: number; + missing: number; + unused: number; + duplicate: number; + unpersisted: number; + likelySecret: number; + }; +} + +export const SOURCE_LABELS: Record = { + 'compose-inline': 'inline', + 'env-file': 'env_file', + 'dotenv': '.env', + 'process-env': 'shell', + 'compose-ref': 'referenced', +}; + +export const STATUS_LABELS: Record = { + present: 'present', + missing: 'missing', + unused: 'unused', + duplicate: 'duplicate', + unpersisted: 'shell-only', +}; + +function scopeLabel(item: EnvInventoryItem): string { + const parts: string[] = []; + if (item.usedForInterpolation) parts.push('interpolation'); + if (item.injectedIntoService) parts.push('injected'); + return parts.join('+') || 'unused'; +} + +/** + * Build a Markdown checklist of the inventory: names, status, source, and scope + * only. Never includes a value. Actionable statuses are unchecked boxes so the + * list reads as a to-do. + */ +export function buildEnvChecklistMarkdown(inv: EnvInventory): string { + const lines: string[] = []; + lines.push(`# Environment checklist · ${inv.stackName}`); + lines.push(''); + lines.push('> Variable names, status, and source only. No values are included.'); + if (!inv.renderable) { + lines.push('>'); + lines.push('> The effective model could not be rendered, so injected-key data is partial.'); + } + lines.push(''); + lines.push('## Summary'); + lines.push(`- Total: ${inv.summary.total}`); + lines.push(`- Missing: ${inv.summary.missing}`); + lines.push(`- Duplicate: ${inv.summary.duplicate}`); + lines.push(`- Unused: ${inv.summary.unused}`); + lines.push(`- Shell-only: ${inv.summary.unpersisted}`); + lines.push(`- Likely secrets: ${inv.summary.likelySecret}`); + lines.push(''); + lines.push('## Variables'); + if (inv.items.length === 0) { + lines.push('- None referenced or defined.'); + } else { + for (const item of inv.items) { + const done = item.status === 'present'; + const sources = item.sources.map(s => SOURCE_LABELS[s] ?? s).join(', ') || '-'; + const flags = [ + `status: ${STATUS_LABELS[item.status]}`, + `source: ${sources}`, + `scope: ${scopeLabel(item)}`, + ]; + if (item.required) flags.push('required'); + if (item.likelySecret) flags.push('likely secret (value hidden)'); + lines.push(`- [${done ? 'x' : ' '}] ${item.key} · ${flags.join(' · ')}`); + } + } + lines.push(''); + return lines.join('\n'); +}