mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-25 17:57:06 +00:00
fix: stop Doctor exposing hash fragments as unset variables (#1558)
Classify Compose stderr unset-variable warnings into intentional references vs literal-dollar fragments from secret values. Adds env-literal-dollar preflight rule and safe remediation text. Fixes #1550.
This commit is contained in:
@@ -19,8 +19,9 @@ import type {
|
||||
import { isPathWithinBase } from '../utils/validation';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { redactSensitiveText, sanitizeForLog } from '../utils/safeLog';
|
||||
import { parseUnsetEnvVars, parseMissingRequiredVars } from '../helpers/envVarParse';
|
||||
import { parseUnsetEnvVars, parseMissingRequiredVars, readEnvFileKeys } from '../helpers/envVarParse';
|
||||
import { resolveStackEnvSources } from '../helpers/envFileResolution';
|
||||
import { classifyUnsetEnvVars, type LiteralDollarWarning } from '../helpers/unsetEnvClassification';
|
||||
|
||||
const MAX_RENDER_ERROR = 600; // chars kept from a (redacted) render error
|
||||
|
||||
@@ -142,13 +143,43 @@ export class ComposeDoctorService {
|
||||
let renderError: string | null = null;
|
||||
let model: EffectiveModel | null = null;
|
||||
let unsetEnvVars: string[] = [];
|
||||
let literalDollarWarnings: LiteralDollarWarning[] = [];
|
||||
let missingEnvFiles: MissingEnvFile[] = [];
|
||||
|
||||
let envSources: Awaited<ReturnType<typeof resolveStackEnvSources>> | null = null;
|
||||
try {
|
||||
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')));
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ComposeService.getInstance(nodeId).renderConfig(stackName);
|
||||
if (result.rendered !== null) {
|
||||
// Unset-variable warnings come from stderr and do not depend on the
|
||||
// model parsing, so capture them before attempting the parse, so a parse
|
||||
// failure does not also suppress the env-unset findings.
|
||||
unsetEnvVars = parseUnsetEnvVars(result.stderr);
|
||||
const rawUnset = parseUnsetEnvVars(result.stderr);
|
||||
if (envSources) {
|
||||
const envFileKeys: string[] = [];
|
||||
for (const file of envSources.envFiles) {
|
||||
if (!file.resolvedPath || file.existence !== 'present') continue;
|
||||
const { keys, unverifiable } = await readEnvFileKeys(file.resolvedPath, baseDir);
|
||||
if (!unverifiable) envFileKeys.push(...keys);
|
||||
}
|
||||
const classified = classifyUnsetEnvVars(rawUnset, envSources, envFileKeys);
|
||||
unsetEnvVars = classified.intentional;
|
||||
literalDollarWarnings = classified.literalDollar;
|
||||
} else {
|
||||
// Without authored env context, never surface raw stderr names as unset
|
||||
// variables; they may be literal-dollar fragments from secret values.
|
||||
unsetEnvVars = [];
|
||||
literalDollarWarnings = rawUnset.length > 0 ? [{ likelySecret: false }] : [];
|
||||
}
|
||||
try {
|
||||
model = parseEffectiveModel(JSON.parse(result.rendered), stackName);
|
||||
renderable = true;
|
||||
@@ -179,20 +210,6 @@ 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,
|
||||
@@ -200,6 +217,7 @@ export class ComposeDoctorService {
|
||||
renderable,
|
||||
renderError,
|
||||
unsetEnvVars,
|
||||
literalDollarWarnings,
|
||||
missingEnvFiles,
|
||||
sourceServiceNames,
|
||||
sourceReadable,
|
||||
|
||||
@@ -20,6 +20,7 @@ import { ComposeService } from './ComposeService';
|
||||
import { parseEffectiveModel } from './preflight/effectiveModel';
|
||||
import { resolveStackEnvSources, type EnvFileExistence } from '../helpers/envFileResolution';
|
||||
import { parseUnsetEnvVars, parseMissingRequiredVars, readEnvFileKeys } from '../helpers/envVarParse';
|
||||
import { classifyUnsetEnvVars } from '../helpers/unsetEnvClassification';
|
||||
import { isLikelySecretKey } from '../helpers/secretClassification';
|
||||
|
||||
export type EnvSource = 'compose-inline' | 'env-file' | 'dotenv' | 'process-env' | 'compose-ref';
|
||||
@@ -78,7 +79,14 @@ export async function buildEnvInventory(nodeId: number, stackName: string): Prom
|
||||
const effectiveKeys = new Set<string>();
|
||||
const effectiveKeysByService = new Map<string, Set<string>>();
|
||||
if (result.rendered !== null) {
|
||||
unsetVars = new Set(parseUnsetEnvVars(result.stderr));
|
||||
const rawUnset = parseUnsetEnvVars(result.stderr);
|
||||
const envFileKeys: string[] = [];
|
||||
for (const file of sources.envFiles) {
|
||||
if (!file.resolvedPath || file.existence !== 'present') continue;
|
||||
const { keys, unverifiable } = await readEnvFileKeys(file.resolvedPath, sources.baseDir);
|
||||
if (!unverifiable) envFileKeys.push(...keys);
|
||||
}
|
||||
unsetVars = new Set(classifyUnsetEnvVars(rawUnset, sources, envFileKeys).intentional);
|
||||
try {
|
||||
const model = parseEffectiveModel(JSON.parse(result.rendered), stackName);
|
||||
for (const svc of model.services) {
|
||||
|
||||
@@ -82,11 +82,37 @@ const envUnset: PreflightRule = {
|
||||
title: `Unset variable ${name}`,
|
||||
message: `"${name}" is referenced by the Compose model but is not set in the environment or any consulted env file. Compose substitutes an empty string, which often breaks the container silently.`,
|
||||
sourcePath: name,
|
||||
remediation: `Define ${name} in a .env or env_file, or give it a default with \${${name}:-value}.`,
|
||||
remediation: `Define ${name} in a .env or env_file, or give it a default with \${${name}:-value}. If the value is a literal secret or hash containing \`$\`, escape \`$\` as \`$$\` in Compose YAML or single-quote the value in an env file.`,
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
const LITERAL_DOLLAR_REMEDIATION =
|
||||
'If this was intended as a Compose variable, define it in the project environment or give it a default. '
|
||||
+ 'If it is part of a literal secret or hash, escape literal dollar signs as `$$` in Compose YAML or single-quote the value in an env file.';
|
||||
|
||||
const envLiteralDollar: PreflightRule = {
|
||||
id: 'env-literal-dollar',
|
||||
run(ctx) {
|
||||
return ctx.literalDollarWarnings.map(w => {
|
||||
const likelySecret = w.likelySecret;
|
||||
const title = likelySecret
|
||||
? 'Literal dollar sign in likely secret value may be interpolated'
|
||||
: 'Literal dollar sign in environment value may be interpolated';
|
||||
const keyHint = w.envKey ? ` for "${w.envKey}"` : '';
|
||||
return {
|
||||
ruleId: 'env-literal-dollar',
|
||||
severity: 'high' as const,
|
||||
title,
|
||||
message: `Compose treated a literal $ sequence inside an environment value${keyHint} as variable interpolation and may substitute an empty string for part of the value.`,
|
||||
sourcePath: w.envKey,
|
||||
remediation: LITERAL_DOLLAR_REMEDIATION,
|
||||
service: w.service,
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const envFileMissing: PreflightRule = {
|
||||
id: 'env-file-missing',
|
||||
run(ctx) {
|
||||
@@ -730,6 +756,7 @@ const sensitiveServiceBroadExposure: PreflightRule = {
|
||||
export const PREFLIGHT_RULES: PreflightRule[] = [
|
||||
renderFailed,
|
||||
envUnset,
|
||||
envLiteralDollar,
|
||||
envFileMissing,
|
||||
portConflictNode,
|
||||
portConflictInternal,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { EffectiveModel } from './effectiveModel';
|
||||
import type { ExposureIntent } from '../network/types';
|
||||
import type { LiteralDollarWarning } from '../../helpers/unsetEnvClassification';
|
||||
|
||||
/** Graded severity of a single preflight finding. */
|
||||
export type PreflightSeverity = 'blocker' | 'high' | 'warning' | 'info';
|
||||
@@ -87,8 +88,10 @@ export interface PreflightContext {
|
||||
renderable: boolean;
|
||||
/** Redacted + truncated render error, or null. */
|
||||
renderError: string | null;
|
||||
/** Variable names Compose reported as unset (defaulted to empty string). */
|
||||
/** Variable names Compose reported as unset (intentional references only). */
|
||||
unsetEnvVars: string[];
|
||||
/** Literal `$` sequences misread as variables; never includes fragment names. */
|
||||
literalDollarWarnings: LiteralDollarWarning[];
|
||||
/** 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). */
|
||||
|
||||
Reference in New Issue
Block a user