mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-25 09:46:47 +00:00
feat(stacks): per-stack environment inventory and secret-safe guardrails (#1397)
* feat(stacks): per-stack environment inventory and secret-safe guardrails
Add an Environment tab to Stack Anatomy that derives a per-stack inventory
of environment variables from the compose files and env files. Each variable
shows its source, whether Compose interpolates it or injects it into a
container, and a status (present, missing, unused, duplicate, or shell-only),
plus likely-secret classification. The inventory works from variable names
only: a value is never read, returned, or logged, and a likely secret shows
presence only. A copy env checklist action exports names and status without
values.
Surface a missing required env_file as a Compose Doctor preflight finding,
and add an opt-in node setting that refuses a deploy or update when a
required ${VAR:?...} variable is unset or empty, before any backup, pull, or
up runs. Default off.
The Environment tab is capability-gated so it hides on older remote nodes.
* fix(stacks): harden env-file reader against a stat-then-open race
Open the env-file handle first and fstat the open handle instead of
stat-ing the path before opening, removing the check-then-use window in
readEnvFileKeys. Use a secure mkdtemp directory for the out-of-base test
path instead of a predictable name in the temp root.
* fix(stacks): resolve nested env_file paths per compose file, reconcile inline keys per service
Resolve each env_file relative to the directory of the compose file that
declared it, so a nested multi-file Git override (infra/prod.yml referencing
./prod.env) lands next to that file instead of the stack root. The root
compose file is unaffected, since its directory is the stack directory.
Reconcile inline environment provenance per service, so a key an override
removed from one service's effective env is not labeled compose-inline just
because another service injects the same name from a different source.
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -18,6 +18,7 @@ import { DriftLedgerService, type DriftTemporal } from '../services/DriftLedgerS
|
||||
import { ComposeDoctorService } from '../services/ComposeDoctorService';
|
||||
import { buildStackNetworkFacts } from '../services/network/composeNetworkInspector';
|
||||
import { buildEffectiveAnatomy } from '../services/effectiveAnatomy';
|
||||
import { buildEnvInventory } from '../services/EnvInventoryService';
|
||||
import { EXPOSURE_INTENTS, type ExposureIntent } from '../services/network/types';
|
||||
import { UpdateGuardService } from '../services/UpdateGuardService';
|
||||
import { HealthGateService } from '../services/HealthGateService';
|
||||
@@ -27,7 +28,7 @@ import { NotificationService, type NotificationCategory } from '../services/Noti
|
||||
import { StackOpLockService, type StackOpAction } from '../services/StackOpLockService';
|
||||
import { StackOpMetricsService, type StackOpAction as StackMetricAction } from '../services/StackOpMetricsService';
|
||||
import { FileExplorerMetricsService, type FileExplorerOp } from '../services/FileExplorerMetricsService';
|
||||
import { isValidGitSourcePath, isValidStackName, isValidServiceName, isPathWithinBase, isValidRelativeStackPath } from '../utils/validation';
|
||||
import { isValidGitSourcePath, isValidStackName, isValidServiceName, isValidRelativeStackPath } from '../utils/validation';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
@@ -36,6 +37,7 @@ import { buildPolicyGateOptions, runPolicyGate, triggerPostDeployScan } from '..
|
||||
import { parseComposePreview, type ComposePreview } from '../helpers/composePreview';
|
||||
import { invalidateNodeCaches } from '../helpers/cacheInvalidation';
|
||||
import { parseComposeSelection, defaultEnvPath } from '../helpers/gitSourceSelection';
|
||||
import { resolveStackEnvSources } from '../helpers/envFileResolution';
|
||||
import { STACK_STATUSES_CACHE_TTL_MS } from '../helpers/constants';
|
||||
import { getTerminalWs, DEPLOY_SESSION_HEADER } from '../websocket/generic';
|
||||
|
||||
@@ -135,80 +137,21 @@ async function requireStackExists(nodeId: number, stackName: string, res: Respon
|
||||
return true;
|
||||
}
|
||||
|
||||
// Thin wrapper over the shared env-source resolver. Returns the absolute paths of
|
||||
// the env files Compose would consult for this stack: the existing declared
|
||||
// `env_file:` paths when any are declared (no project `.env` fallback in that
|
||||
// case), otherwise the project `.env` when it exists. The multi-file Git case and
|
||||
// path validation live in resolveStackEnvSources so every consumer agrees.
|
||||
export async function resolveAllEnvFilePaths(nodeId: number, stackName: string): Promise<string[]> {
|
||||
const fsService = FileSystemService.getInstance(nodeId);
|
||||
const stackDir = path.join(fsService.getBaseDir(), stackName);
|
||||
const defaultEnvPath = path.join(stackDir, '.env');
|
||||
|
||||
try {
|
||||
const composeFiles = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'];
|
||||
let composeContent: string | null = null;
|
||||
|
||||
for (const file of composeFiles) {
|
||||
try {
|
||||
composeContent = await fsService.readFile(path.join(stackDir, file), 'utf-8');
|
||||
break;
|
||||
} catch {
|
||||
// Try next file
|
||||
}
|
||||
}
|
||||
|
||||
if (!composeContent) return [defaultEnvPath];
|
||||
|
||||
if (composeContent.length > MAX_COMPOSE_PARSE_BYTES) {
|
||||
console.warn(`[Stacks] Compose for ${sanitizeForLog(stackName)} exceeds ${MAX_COMPOSE_PARSE_BYTES} bytes; skipping env_file resolution`);
|
||||
return [defaultEnvPath];
|
||||
}
|
||||
|
||||
const parsed = YAML.parse(composeContent);
|
||||
if (!parsed?.services) return [defaultEnvPath];
|
||||
|
||||
const envFiles = new Set<string>();
|
||||
|
||||
for (const serviceName of Object.keys(parsed.services)) {
|
||||
const service = parsed.services[serviceName];
|
||||
if (!service?.env_file) continue;
|
||||
|
||||
const addEnvPath = (rawPath: string) => {
|
||||
const resolved = path.resolve(stackDir, rawPath);
|
||||
if (!isPathWithinBase(resolved, stackDir)) return;
|
||||
envFiles.add(resolved);
|
||||
};
|
||||
|
||||
if (typeof service.env_file === 'string') {
|
||||
addEnvPath(service.env_file);
|
||||
} else if (Array.isArray(service.env_file)) {
|
||||
for (const entry of service.env_file) {
|
||||
const entryPath = typeof entry === 'string' ? entry : (entry?.path || '');
|
||||
if (entryPath) addEnvPath(entryPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (envFiles.size === 0) {
|
||||
envFiles.add(defaultEnvPath);
|
||||
}
|
||||
|
||||
const existing: string[] = [];
|
||||
for (const f of envFiles) {
|
||||
try {
|
||||
await fsService.access(f);
|
||||
existing.push(f);
|
||||
} catch {
|
||||
// File does not exist, skip
|
||||
}
|
||||
}
|
||||
return existing;
|
||||
} catch (error) {
|
||||
console.warn('Could not parse compose.yaml for env_file resolution in stack "%s":', sanitizeForLog(stackName), error);
|
||||
}
|
||||
|
||||
try {
|
||||
await fsService.access(defaultEnvPath);
|
||||
return [defaultEnvPath];
|
||||
} catch {
|
||||
return [];
|
||||
const sources = await resolveStackEnvSources(nodeId, stackName);
|
||||
const injection = sources.envFiles.filter(f => f.isInjectionSource);
|
||||
if (injection.length > 0) {
|
||||
return injection
|
||||
.filter(f => f.existence === 'present' && f.resolvedPath)
|
||||
.map(f => f.resolvedPath as string);
|
||||
}
|
||||
const dotenv = sources.envFiles.find(f => f.isInterpolationSource && f.existence === 'present' && f.resolvedPath);
|
||||
return dotenv ? [dotenv.resolvedPath as string] : [];
|
||||
}
|
||||
|
||||
const upload = multer({
|
||||
@@ -1170,6 +1113,24 @@ stacksRouter.get('/:stackName/effective-anatomy', async (req: Request, res: Resp
|
||||
}
|
||||
});
|
||||
|
||||
// Environment inventory: per-stack env vars with their source, scope (Compose
|
||||
// interpolation vs container injection), and status (present/missing/unused/
|
||||
// duplicate/unpersisted), plus likely-secret classification. Read-only and
|
||||
// advisory; auto-proxies to the active node. Names only: an env value is never
|
||||
// read into the payload, so stack:read is the correct gate.
|
||||
stacksRouter.get('/:stackName/env-inventory', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return;
|
||||
if (!(await requireStackExists(req.nodeId, stackName, res))) return;
|
||||
try {
|
||||
res.json(await buildEnvInventory(req.nodeId, stackName));
|
||||
} catch (error) {
|
||||
console.error('[Stacks] Failed to build env inventory for %s:', sanitizeForLog(stackName),
|
||||
sanitizeForLog(inspect(error, { depth: 4 })));
|
||||
res.status(500).json({ error: 'Failed to build env inventory' });
|
||||
}
|
||||
});
|
||||
|
||||
// Exposure intent: the user's per-stack (service '') and per-service exposure
|
||||
// classification, stored separately from generated facts so mismatches stay
|
||||
// detectable. Rows are stored independently; precedence (a service row taking
|
||||
|
||||
Reference in New Issue
Block a user