mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 08:58:05 +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:
@@ -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);
|
||||
|
||||
@@ -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 }]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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()] };
|
||||
}
|
||||
@@ -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). */
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Run a preflight check on a stack before you deploy. Compose Doctor
|
||||
|
||||
The **Doctor** tab in the right-hand **Anatomy** panel answers one question before you apply a change: *what will Docker actually run, and is it safe on this node?* Compose Doctor renders the effective Compose model (the fully resolved result after interpolation, includes, profiles, `.env`, and `env_file` are applied) and then runs a set of deterministic checks against it and the live Docker state on the node it would deploy to.
|
||||
|
||||
The check is advisory. It never blocks a deploy and never changes a stack. It runs on demand: press **run preflight** and Sencho renders the model, runs all 30 checks, and stores the result so the tab still shows it the next time you open the stack.
|
||||
The check is advisory. It never blocks a deploy and never changes a stack. It runs on demand: press **run preflight** and Sencho renders the model, runs all 31 checks, and stores the result so the tab still shows it the next time you open the stack.
|
||||
|
||||
## Where to find it
|
||||
|
||||
@@ -24,7 +24,7 @@ Every preflight run follows three steps:
|
||||
|
||||
1. **Render** the effective model. Sencho calls `docker compose config` on the stack, which resolves all variable interpolation, `include` directives, profile overrides, and `env_file` references into a single, normalized model.
|
||||
2. **Snapshot** live Docker state. Sencho reads which host ports are in use, which containers are running, and which named networks and volumes exist on the target node.
|
||||
3. **Run 30 deterministic rules** against the combination. Each rule is pure and produces zero or more findings with a severity, a message, and a suggested fix.
|
||||
3. **Run 31 deterministic rules** against the combination. Each rule is pure and produces zero or more findings with a severity, a message, and a suggested fix.
|
||||
|
||||
Sencho stores exactly one run per stack per node, so a new run immediately overwrites the previous one; there is no history.
|
||||
|
||||
@@ -72,7 +72,7 @@ A small colored dot appears on the **Doctor** tab label when the last run found
|
||||
|
||||
## What it checks
|
||||
|
||||
All 30 rules are listed below, organized by topic.
|
||||
All 31 rules are listed below, organized by topic.
|
||||
|
||||
### Model rendering
|
||||
|
||||
@@ -84,7 +84,8 @@ All 30 rules are listed below, organized by topic.
|
||||
|
||||
| Rule | Severity | What it detects |
|
||||
|------|----------|----------------|
|
||||
| Unset variable | High | A variable referenced by the model has no value in the environment or any consulted env file. Compose silently substitutes an empty string, which often breaks the container without a clear error. |
|
||||
| Unset variable | High | An intentional `${VAR}` or `$VAR` reference has no value in the environment or any consulted env file. Compose silently substitutes an empty string, which often breaks the container without a clear error. |
|
||||
| Literal dollar in value | High | A literal `$` inside an environment value (common in bcrypt hashes and other secrets) was treated as Compose interpolation. Sencho reports the env key when it can identify one, never a fragment of the value. |
|
||||
| Missing env file | High | A path listed under `env_file:` does not exist in the stack directory. Compose fails to start the stack when a required env file is absent. |
|
||||
|
||||
### Port conflicts
|
||||
@@ -250,6 +251,9 @@ There is no tier gate: Compose Doctor is available on all plans.
|
||||
<Accordion title="Exposure intent rules are firing unexpectedly">
|
||||
The five exposure intent rules activate when the stack publishes at least one host port. The "unclassified" warning clears as soon as you set an exposure intent in the Networking tab. The "port not in dossier" warning clears once you add a matching access URL in the Stack Dossier. If an intent is already set and the finding still fires, check that the intent is configured on the correct node and for the correct service.
|
||||
</Accordion>
|
||||
<Accordion title="Doctor warns about literal dollar signs in a hash or secret">
|
||||
Compose treats unescaped `$` in environment values as variable references. Bcrypt hashes and other secrets often contain `$` characters, so an unquoted value in Compose YAML or a double-quoted `.env` line can be partially blanked out at runtime. Doctor flags this as **Literal dollar in value** and names the env key when it can, without showing part of the secret. Fix it by escaping each literal `$` as `$$` in Compose YAML, or by single-quoting the value in an env file.
|
||||
</Accordion>
|
||||
<Accordion title="I want to see who ran preflight last">
|
||||
The summary card shows the username and relative time: "ran 5 minutes ago by admin". This reflects the most recent run stored for this stack on the currently active node.
|
||||
</Accordion>
|
||||
|
||||
Reference in New Issue
Block a user