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:
Anso
2026-07-05 03:50:40 -04:00
committed by GitHub
parent ecd757270f
commit 122c1b8073
12 changed files with 416 additions and 25 deletions
+34 -16
View File
@@ -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,
+9 -1
View File
@@ -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) {
+28 -1
View File
@@ -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,
+4 -1
View File
@@ -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). */