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

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

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

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

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

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

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

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

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

229 lines
9.0 KiB
TypeScript

/**
* Per-stack environment inventory: which env vars a stack references, where they
* come from, whether Compose interpolates them or injects them into a container,
* and whether each is likely a secret. Names, sources, and status ONLY: an env
* value is never read, returned, retained, or logged here.
*
* Compose env semantics this encodes:
* - `${VAR}` interpolation resolves from the project `.env` + the shell only.
* - `env_file:` and inline `environment:` inject values into a container.
* - `${VAR:?err}` fails when unset OR empty, so the unset/missing signal comes
* from Compose's own stderr (authoritative), not a key-only guess.
*
* Injected keys come from the merge-correct effective model; interpolation refs
* and inline-vs-env-file provenance come from the authored source. `process.env`
* is consulted ONLY to resolve interpolation refs the stack already references, so
* unrelated host/system env names never appear as inventory rows.
*/
import { ComposeService } from './ComposeService';
import { parseEffectiveModel } from './preflight/effectiveModel';
import { resolveStackEnvSources, type EnvFileExistence } from '../helpers/envFileResolution';
import { parseUnsetEnvVars, parseMissingRequiredVars, readEnvFileKeys } from '../helpers/envVarParse';
import { isLikelySecretKey } from '../helpers/secretClassification';
export type EnvSource = 'compose-inline' | 'env-file' | 'dotenv' | 'process-env' | 'compose-ref';
export type EnvItemStatus = 'present' | 'missing' | 'unused' | 'duplicate' | 'unpersisted';
export interface EnvInventoryItem {
key: string;
sources: EnvSource[];
/** Consumed by Compose `${}` interpolation. */
usedForInterpolation: boolean;
/** Injected into a container (effective model is authoritative). */
injectedIntoService: boolean;
required: boolean;
hasDefault: boolean;
likelySecret: boolean;
status: EnvItemStatus;
}
export interface EnvFileInfo {
/** Raw paths as written in compose (or '.env' for the project source). No absolute path. */
rawPaths: string[];
existence: EnvFileExistence;
required: boolean;
isInterpolationSource: boolean;
isInjectionSource: boolean;
declaringServices: string[];
}
export interface EnvInventory {
stackName: string;
/** False when the effective model could not be rendered; the inventory is then partial. */
renderable: boolean;
items: EnvInventoryItem[];
envFiles: EnvFileInfo[];
summary: {
total: number;
missing: number;
unused: number;
duplicate: number;
unpersisted: number;
likelySecret: number;
};
}
/** Build the env inventory for a stack on a node. Key names only; never any value. */
export async function buildEnvInventory(nodeId: number, stackName: string): Promise<EnvInventory> {
const sources = await resolveStackEnvSources(nodeId, stackName);
const refByName = new Map(sources.interpolationRefs.map(r => [r.name, r]));
// Render the effective model: the authoritative injected-key set and the
// unset/missing-required signal. Failure path materializes no values.
const result = await ComposeService.getInstance(nodeId).renderConfig(stackName);
const missingRequired = new Set(parseMissingRequiredVars(result.stderr));
let renderable = false;
let unsetVars = new Set<string>();
const effectiveKeys = new Set<string>();
const effectiveKeysByService = new Map<string, Set<string>>();
if (result.rendered !== null) {
unsetVars = new Set(parseUnsetEnvVars(result.stderr));
try {
const model = parseEffectiveModel(JSON.parse(result.rendered), stackName);
for (const svc of model.services) {
const svcKeys = new Set(svc.envKeys);
effectiveKeysByService.set(svc.name, svcKeys);
for (const k of svc.envKeys) effectiveKeys.add(k);
}
renderable = true;
} catch {
renderable = false;
}
}
const names = new Set<string>();
const locations = new Map<string, Set<string>>(); // distinct physical definition locations
const itemSources = new Map<string, Set<EnvSource>>();
const dotenvKeys = new Set<string>();
const injectionFileKeys = new Set<string>();
const addLocation = (key: string, location: string) => {
const set = locations.get(key) ?? new Set<string>();
set.add(location);
locations.set(key, set);
};
const addSource = (key: string, source: EnvSource) => {
const set = itemSources.get(key) ?? new Set<EnvSource>();
set.add(source);
itemSources.set(key, set);
};
for (const ref of sources.interpolationRefs) names.add(ref.name);
// Env-file provenance: each physical file contributes ONE source label and ONE
// location, so the project `.env` doubling as `env_file: .env` is not a duplicate.
for (const file of sources.envFiles) {
if (!file.resolvedPath || file.existence !== 'present') continue;
const { keys, unverifiable } = await readEnvFileKeys(file.resolvedPath, sources.baseDir);
if (unverifiable) {
// The existence probe said present, but the key read failed (a permission
// change, a race, or transient I/O). Surface that rather than silently
// reporting zero keys for a file the inventory claims is present.
file.existence = 'unverifiable';
continue;
}
const label: EnvSource = file.isInterpolationSource ? 'dotenv' : 'env-file';
for (const key of keys) {
names.add(key);
addLocation(key, file.resolvedPath);
addSource(key, label);
if (file.isInterpolationSource) dotenvKeys.add(key);
if (file.isInjectionSource) injectionFileKeys.add(key);
}
}
// Inline `environment:` keys, reconciled against the effective model PER SERVICE
// so a key an override removed from one service is not reported as inline just
// because another service defines the same name elsewhere.
const inlineAll = new Set<string>();
for (const keys of Object.values(sources.inlineEnvKeysByService)) for (const k of keys) inlineAll.add(k);
for (const [service, keys] of Object.entries(sources.inlineEnvKeysByService)) {
const svcEffective = effectiveKeysByService.get(service);
for (const key of keys) {
if (renderable && !svcEffective?.has(key)) continue;
names.add(key);
addLocation(key, 'inline');
addSource(key, 'compose-inline');
}
}
for (const key of effectiveKeys) names.add(key);
const injectedKeys = renderable
? effectiveKeys
: new Set<string>([...inlineAll, ...injectionFileKeys]);
const shellHas = (name: string): boolean => Object.prototype.hasOwnProperty.call(process.env, name);
const items: EnvInventoryItem[] = [];
for (const key of [...names].sort()) {
const ref = refByName.get(key);
const usedForInterpolation = !!ref;
const required = ref?.required ?? false;
const hasDefault = ref?.hasDefault ?? false;
const alternate = ref?.alternate ?? false;
const injected = injectedKeys.has(key);
const locationCount = locations.get(key)?.size ?? 0;
const sourceSet = new Set<EnvSource>(itemSources.get(key) ?? []);
// A referenced var defined in no stack-local source resolves from the shell or
// is missing. Surface that provenance without adding unreferenced shell keys.
if (usedForInterpolation && locationCount === 0 && !injected) {
if (shellHas(key)) sourceSet.add('process-env');
else sourceSet.add('compose-ref');
}
// Compose's own resolution is authoritative for unset/empty required vars; the
// heuristic only fills in when the model could not be rendered for other reasons.
const refUndefinedUnshelled = usedForInterpolation && !hasDefault && !alternate && locationCount === 0 && !shellHas(key);
const missing = missingRequired.has(key)
|| (renderable && unsetVars.has(key))
|| (!renderable && refUndefinedUnshelled);
const unpersisted = usedForInterpolation && locationCount === 0 && !missing && shellHas(key);
const unused = dotenvKeys.has(key) && !usedForInterpolation && !injected;
let status: EnvItemStatus;
if (missing) status = 'missing';
else if (locationCount >= 2) status = 'duplicate';
else if (unpersisted) status = 'unpersisted';
else if (unused) status = 'unused';
else status = 'present';
items.push({
key,
sources: [...sourceSet],
usedForInterpolation,
injectedIntoService: injected,
required,
hasDefault,
likelySecret: isLikelySecretKey(key),
status,
});
}
const envFiles: EnvFileInfo[] = sources.envFiles.map(f => ({
rawPaths: f.rawPaths,
existence: f.existence,
required: f.required,
isInterpolationSource: f.isInterpolationSource,
isInjectionSource: f.isInjectionSource,
declaringServices: f.declaringServices,
}));
return {
stackName,
renderable,
items,
envFiles,
summary: {
total: items.length,
missing: items.filter(i => i.status === 'missing').length,
unused: items.filter(i => i.status === 'unused').length,
duplicate: items.filter(i => i.status === 'duplicate').length,
unpersisted: items.filter(i => i.status === 'unpersisted').length,
likelySecret: items.filter(i => i.likelySecret).length,
},
};
}