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
@@ -85,7 +85,7 @@ describe('runPreflight', () => {
expect(report.renderable).toBe(true);
expect(report.status).toBe('high'); // env-unset + 0.0.0.0 exposure are high
expect(report.highestSeverity).toBe('high');
expect(report.findings.map(f => f.ruleId)).toEqual(expect.arrayContaining(['env-unset', 'port-exposed-all-interfaces', 'image-latest', 'no-healthcheck']));
expect(report.findings.map(f => f.ruleId)).toEqual(expect.arrayContaining(['env-literal-dollar', 'port-exposed-all-interfaces', 'image-latest', 'no-healthcheck']));
expect(report.ranBy).toBe('tester');
expect(report.sourceHash).toBeTruthy();
@@ -128,6 +128,40 @@ describe('runPreflight', () => {
expect(JSON.stringify(report)).not.toContain(SECRET);
});
it('does not expose hash fragments as unset variable names (#1550)', async () => {
const stack = 'hashfrag';
const dir = path.join(process.env.COMPOSE_DIR as string, stack);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
path.join(dir, 'compose.yaml'),
[
'services:',
' demo:',
' image: alpine:3',
' environment:',
' - EXAMPLE_AUTH_HASH=$2b$10$E6SDEbshpc$vCSrREDACTED',
].join('\n'),
);
try {
stubDocker(
{ name: stack, services: { demo: { image: 'alpine:3', environment: { EXAMPLE_AUTH_HASH: '' } } }, networks: {}, volumes: {} },
'WARN The "E6SDEbshpc" variable is not set. Defaulting to a blank string.\n'
+ 'WARN The "vCSr" variable is not set. Defaulting to a blank string.\n',
);
const report = await doctor().runPreflight(nodeId, stack, 'tester');
const literal = report.findings.filter(f => f.ruleId === 'env-literal-dollar');
const unset = report.findings.filter(f => f.ruleId === 'env-unset');
expect(literal.length).toBeGreaterThan(0);
expect(unset.some(f => f.title.includes('E6SDEbshpc') || f.title.includes('vCSr'))).toBe(false);
const persisted = JSON.stringify(db().getPreflightFindings(db().getLatestPreflightRun(nodeId, stack)!.id));
expect(persisted).not.toContain('E6SDEbshpc');
expect(persisted).not.toContain('vCSr');
expect(literal[0].title).toContain('likely secret');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it('replaces the prior run rather than accumulating', async () => {
stubDocker({ name: STACK, services: { web: { image: 'nginx:latest' } }, networks: {}, volumes: {} });
await doctor().runPreflight(nodeId, STACK, null);
@@ -8,6 +8,8 @@ import {
readEnvFileKeys,
parseUnsetEnvVars,
parseMissingRequiredVars,
parseBareDollarRefs,
parseIntentionalBareDollarRefs,
} from '../helpers/envVarParse';
describe('parseInterpolationRefs', () => {
@@ -35,6 +37,21 @@ describe('parseInterpolationRefs', () => {
expect(parseInterpolationRefs('x: $${ESCAPED}').map(r => r.name)).not.toContain('ESCAPED');
});
it('extracts bare $VAR refs but skips $$ escapes and ${VAR} forms', () => {
expect(parseBareDollarRefs('host: $DB_HOST and $${LIT} and ${BRACED}')).toEqual(['DB_HOST']);
});
it('keeps intentional bare refs for self-refs and whole-value refs only', () => {
const src = [
'- TOKEN=$TOKEN',
'- FOO=$BAR',
'- EXAMPLE_AUTH_HASH=$2b$10$E6SDEbshpc$vCSrREDACTED',
'EXAMPLE_AUTH_HASH: $2b$10$E6SDEbshpc$vCSrREDACTED',
'command: echo $HELLO',
].join('\n');
expect(parseIntentionalBareDollarRefs(src).sort()).toEqual(['BAR', 'HELLO', 'TOKEN']);
});
it('merges flags across repeated references of one name', () => {
const refs = parseInterpolationRefs('${X} then ${X:?e}');
expect(refs).toHaveLength(1);
+30 -1
View File
@@ -26,6 +26,7 @@ function ctx(over: Partial<PreflightContext> = {}): PreflightContext {
const m = over.model !== undefined ? over.model : model([]);
return {
stackName: 'proj', platform: 'linux', model: m, renderable: true, renderError: null, unsetEnvVars: [],
literalDollarWarnings: [],
missingEnvFiles: [],
sourceServiceNames: m ? m.services.map(s => s.name) : [], sourceReadable: true,
nodePorts: [], existingNetworkNames: new Set(), existingVolumeNames: new Set(),
@@ -55,6 +56,34 @@ describe('env-unset', () => {
expect(f[0].severity).toBe('high');
expect(f.map(x => x.sourcePath)).toEqual(['FOO', 'BAR']);
});
it('mentions literal-dollar escapes in remediation', () => {
const f = ids(runRules(ctx({ unsetEnvVars: ['FOO'] })), 'env-unset');
expect(f[0].remediation).toContain('$$');
expect(f[0].remediation).toContain('single-quote');
});
});
describe('env-literal-dollar', () => {
it('emits a safe finding for likely-secret literal dollar warnings', () => {
const f = ids(runRules(ctx({
literalDollarWarnings: [{ envKey: 'EXAMPLE_AUTH_HASH', likelySecret: true, service: 'demo' }],
})), 'env-literal-dollar');
expect(f).toHaveLength(1);
expect(f[0].severity).toBe('high');
expect(f[0].title).toContain('likely secret');
expect(f[0].sourcePath).toBe('EXAMPLE_AUTH_HASH');
expect(f[0].service).toBe('demo');
expect(f[0].title).not.toContain('E6SDEbshpc');
expect(f[0].remediation).toContain('$$');
});
it('omits fragment names from generic literal-dollar findings', () => {
const f = ids(runRules(ctx({
literalDollarWarnings: [{ likelySecret: false }],
})), 'env-literal-dollar');
expect(f).toHaveLength(1);
expect(f[0].sourcePath).toBeUndefined();
expect(f[0].title).toContain('environment value');
});
});
describe('env-file-missing', () => {
@@ -408,7 +437,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', 'env-file-missing', 'port-conflict-node', 'port-conflict-internal', 'port-exposed-all-interfaces',
'render-failed', 'env-unset', 'env-literal-dollar', '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',
'node-state-unavailable',
@@ -0,0 +1,85 @@
import { describe, it, expect } from 'vitest';
import { classifyUnsetEnvVars } from '../helpers/unsetEnvClassification';
import type { StackEnvSources } from '../helpers/envFileResolution';
function sources(over: Partial<StackEnvSources> = {}): StackEnvSources {
return {
stackDir: '/stack',
baseDir: '/compose',
composeFiles: [],
envFiles: [],
inlineEnvKeysByService: {},
interpolationRefs: [],
authoredComposeText: '',
...over,
};
}
describe('classifyUnsetEnvVars', () => {
it('keeps intentional ${VAR} refs as unset variables', () => {
const src = sources({
authoredComposeText: 'services:\n web:\n image: nginx\n environment:\n - DB_HOST=${DB_HOST}\n',
interpolationRefs: [{ name: 'DB_HOST', required: false, hasDefault: false, alternate: false }],
});
const result = classifyUnsetEnvVars(['DB_HOST', 'E6SDEbshpc'], src);
expect(result.intentional).toEqual(['DB_HOST']);
expect(result.literalDollar).toHaveLength(1);
expect(result.literalDollar[0].likelySecret).toBe(false);
});
it('classifies bcrypt hash fragments as literal-dollar warnings without exposing fragments', () => {
const compose = [
'services:',
' demo:',
' image: alpine:3',
' environment:',
' - EXAMPLE_AUTH_HASH=$2b$10$E6SDEbshpc$vCSrREDACTED',
].join('\n');
const src = sources({
authoredComposeText: compose,
inlineEnvKeysByService: { demo: ['EXAMPLE_AUTH_HASH'] },
});
const result = classifyUnsetEnvVars(['E6SDEbshpc', 'vCSr'], src);
expect(result.intentional).toEqual([]);
expect(result.literalDollar).toHaveLength(1);
expect(result.literalDollar[0]).toMatchObject({
envKey: 'EXAMPLE_AUTH_HASH',
likelySecret: true,
service: 'demo',
});
expect(JSON.stringify(result)).not.toContain('E6SDEbshpc');
expect(JSON.stringify(result)).not.toContain('vCSr');
});
it('classifies map-form bcrypt hash fragments as literal-dollar warnings', () => {
const compose = [
'services:',
' demo:',
' image: alpine:3',
' environment:',
' EXAMPLE_AUTH_HASH: $2b$10$E6SDEbshpc$vCSrREDACTED',
].join('\n');
const src = sources({
authoredComposeText: compose,
inlineEnvKeysByService: { demo: ['EXAMPLE_AUTH_HASH'] },
});
const result = classifyUnsetEnvVars(['E6SDEbshpc', 'vCSr'], src);
expect(result.intentional).toEqual([]);
expect(result.literalDollar[0]?.envKey).toBe('EXAMPLE_AUTH_HASH');
});
it('treats bare $VAR references as intentional', () => {
const compose = 'services:\n web:\n image: nginx\n environment:\n - TOKEN=$TOKEN\n';
const src = sources({ authoredComposeText: compose });
const result = classifyUnsetEnvVars(['TOKEN'], src);
expect(result.intentional).toEqual(['TOKEN']);
expect(result.literalDollar).toEqual([]);
});
it('attributes env-file-only spurious fragments to a lone likely-secret key', () => {
const src = sources({ authoredComposeText: 'services:\n web:\n image: nginx\n env_file:\n - ./secrets.env\n' });
const result = classifyUnsetEnvVars(['E6SDEbshpc'], src, ['EXAMPLE_AUTH_HASH']);
expect(result.intentional).toEqual([]);
expect(result.literalDollar).toEqual([{ envKey: 'EXAMPLE_AUTH_HASH', likelySecret: true }]);
});
});
+3
View File
@@ -55,6 +55,8 @@ export interface StackEnvSources {
inlineEnvKeysByService: Record<string, string[]>;
/** `${}` references found across the authored compose source. */
interpolationRefs: InterpolationRef[];
/** Concatenated authored compose file text (in-memory classification only). */
authoredComposeText: string;
}
interface EnvFileEntry {
@@ -281,6 +283,7 @@ export async function resolveStackEnvSources(nodeId: number, stackName: string):
envFiles: [...byPath.values(), ...unresolved],
inlineEnvKeysByService,
interpolationRefs: parseInterpolationRefs(authoredText),
authoredComposeText: authoredText,
};
}
+51
View File
@@ -49,6 +49,57 @@ export function parseInterpolationRefs(source: string): InterpolationRef[] {
return [...byName.values()];
}
// Bare $VAR (no braces). The leading (?<!\$) skips Compose's $$ literal escape.
const BARE_DOLLAR_RE = /(?<!\$)\$([A-Za-z_][A-Za-z0-9_]*)/g;
/** Extract distinct bare `$VAR` references from authored compose text (not `${VAR}`). */
export function parseBareDollarRefs(source: string): string[] {
const names = new Set<string>();
for (const m of source.matchAll(BARE_DOLLAR_RE)) names.add(m[1]);
return [...names];
}
/** True when a key name follows env-var naming (UPPER_SNAKE), not compose fields like `command`. */
function looksLikeEnvKey(key: string): boolean {
return key === key.toUpperCase() && /[A-Z]/.test(key);
}
/** Parse an inline environment key/value assignment from a compose source line. */
function parseEnvAssignment(line: string): { keyName: string; valuePart: string } | null {
const trimmed = line.trim();
const listEq = trimmed.match(/^-\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
if (listEq) return { keyName: listEq[1], valuePart: listEq[2].trim() };
const listColon = trimmed.match(/^-\s*([A-Za-z_][A-Za-z0-9_]*)\s*:\s+(.*)$/);
if (listColon) return { keyName: listColon[1], valuePart: listColon[2].trim() };
const mapEq = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
if (mapEq && looksLikeEnvKey(mapEq[1])) return { keyName: mapEq[1], valuePart: mapEq[2].trim() };
const mapColon = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*:\s+(.*)$/);
if (mapColon && looksLikeEnvKey(mapColon[1])) return { keyName: mapColon[1], valuePart: mapColon[2].trim() };
return null;
}
/**
* Bare `$VAR` references Compose is meant to interpolate: self-references
* (`TOKEN=$TOKEN`), whole-value refs (`FOO=$BAR`), and refs outside env values.
* Fragments embedded inside a literal value (e.g. bcrypt hashes) are excluded.
*/
export function parseIntentionalBareDollarRefs(source: string): string[] {
const names = new Set<string>();
for (const line of source.split(/\r?\n/)) {
const assignment = parseEnvAssignment(line);
if (assignment) {
const { keyName, valuePart } = assignment;
for (const m of valuePart.matchAll(BARE_DOLLAR_RE)) {
const name = m[1];
if (keyName === name || valuePart === `$${name}`) names.add(name);
}
} else {
for (const m of line.matchAll(BARE_DOLLAR_RE)) names.add(m[1]);
}
}
return [...names];
}
/**
* 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
@@ -0,0 +1,112 @@
/**
* Classify Compose stderr "unset variable" names into intentional references vs
* spurious fragments from literal `$` sequences inside env values (e.g. bcrypt
* hashes). Names only; env values are never read or returned.
*/
import type { StackEnvSources } from './envFileResolution';
import { parseIntentionalBareDollarRefs } from './envVarParse';
import { isLikelySecretKey } from './secretClassification';
export interface LiteralDollarWarning {
envKey?: string;
likelySecret: boolean;
service?: string;
}
export interface UnsetEnvClassification {
intentional: string[];
literalDollar: LiteralDollarWarning[];
}
function intentionalRefNames(envSources: StackEnvSources): Set<string> {
const names = new Set<string>();
for (const ref of envSources.interpolationRefs) names.add(ref.name);
for (const name of parseIntentionalBareDollarRefs(envSources.authoredComposeText)) names.add(name);
return names;
}
/** Parse an inline `environment:` key from a compose source line (names only). */
function extractEnvKeyFromComposeLine(line: string): string | null {
const trimmed = line.trim();
const listMatch = trimmed.match(/^-\s*([A-Za-z_][A-Za-z0-9_]*)\s*[:=]/);
if (listMatch) return listMatch[1];
const mapMatch = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*:/);
if (mapMatch) return mapMatch[1];
const eqMatch = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=/);
if (eqMatch) return eqMatch[1];
return null;
}
function findServiceForEnvKey(
inlineEnvKeysByService: Record<string, string[]>,
envKey: string,
): string | undefined {
for (const [service, keys] of Object.entries(inlineEnvKeysByService)) {
if (keys.includes(envKey)) return service;
}
return undefined;
}
function attributeFragmentToEnvKey(authoredText: string, fragment: string): string | null {
const needle = `$${fragment}`;
for (const line of authoredText.split(/\r?\n/)) {
if (!line.includes(needle)) continue;
const key = extractEnvKeyFromComposeLine(line);
if (key) return key;
}
return null;
}
/**
* Split stderr unset names into intentional Compose variable references and
* literal-dollar warnings safe to show in Doctor (no hash/secret fragments).
*/
export function classifyUnsetEnvVars(
unsetNames: string[],
envSources: StackEnvSources,
envFileKeys: string[] = [],
): UnsetEnvClassification {
const intentionalSet = intentionalRefNames(envSources);
const intentional: string[] = [];
const spurious: string[] = [];
for (const name of unsetNames) {
if (intentionalSet.has(name)) intentional.push(name);
else spurious.push(name);
}
if (spurious.length === 0) {
return { intentional, literalDollar: [] };
}
const warnings = new Map<string, LiteralDollarWarning>();
const addWarning = (w: LiteralDollarWarning) => {
const id = w.envKey ?? (w.likelySecret ? '__secret__' : '__generic__');
if (!warnings.has(id)) warnings.set(id, w);
};
for (const fragment of spurious) {
const envKey = attributeFragmentToEnvKey(envSources.authoredComposeText, fragment);
if (envKey) {
addWarning({
envKey,
likelySecret: isLikelySecretKey(envKey),
service: findServiceForEnvKey(envSources.inlineEnvKeysByService, envKey),
});
}
}
const unattributed = spurious.some(f => !attributeFragmentToEnvKey(envSources.authoredComposeText, f));
if (unattributed) {
const secretFileKeys = envFileKeys.filter(isLikelySecretKey);
if (secretFileKeys.length === 1) {
const key = secretFileKeys[0];
addWarning({ envKey: key, likelySecret: true });
} else {
addWarning({ likelySecret: secretFileKeys.length > 0 });
}
}
return { intentional, literalDollar: [...warnings.values()] };
}
+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). */