/** * Name-only parsing of Compose interpolation and env-file keys, plus the shared * stderr parsers Compose Doctor and the deploy guard both use. Every function * here surfaces variable NAMES only; an env-file value is never returned or * retained, and the bounded reader never loads a whole large file into memory. */ import { promises as fsp } from 'fs'; import path from 'path'; /** A `${...}` reference found in authored compose source. */ export interface InterpolationRef { name: string; /** `${VAR:?e}` / `${VAR?e}`: Compose errors if unset (`:?` also if empty). */ required: boolean; /** `${VAR:-d}` / `${VAR-d}`: a default makes the value optional. */ hasDefault: boolean; /** `${VAR:+x}` / `${VAR+x}`: alternate value; an unset VAR is intentional. */ alternate: boolean; } // ${VAR}, ${VAR:-d}, ${VAR-d}, ${VAR:?e}, ${VAR?e}, ${VAR:+x}, ${VAR+x}. // The leading (?(); for (const m of source.matchAll(INTERPOLATION_RE)) { const name = m[1]; const op = m[2]; const required = op === ':?' || op === '?'; const hasDefault = op === ':-' || op === '-'; const alternate = op === ':+' || op === '+'; const existing = byName.get(name); if (existing) { existing.required ||= required; existing.hasDefault ||= hasDefault; existing.alternate ||= alternate; } else { byName.set(name, { name, required, hasDefault, alternate }); } } return [...byName.values()]; } // Bare $VAR (no braces). The leading (?(); 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(); 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 * shell). The value after `=` is never read or returned. */ export function extractEnvKeyFromLine(line: string): string | null { let s = line.trim(); if (!s || s.startsWith('#')) return null; if (s.startsWith('export ')) s = s.slice('export '.length).trim(); const eq = s.indexOf('='); const key = (eq === -1 ? s : s.slice(0, eq)).trim(); return /^[A-Za-z_][A-Za-z0-9_]*$/.test(key) ? key : null; } export interface EnvKeyReadLimits { maxBytes: number; maxLines: number; maxLineLen: number; } export const DEFAULT_ENV_KEY_LIMITS: EnvKeyReadLimits = { maxBytes: 256 * 1024, maxLines: 5000, maxLineLen: 8192, }; export interface EnvKeyReadResult { /** Distinct key names, in first-seen order. Never includes a value. */ keys: string[]; /** True when the file exceeded a limit and was only partially read. */ truncated: boolean; /** True when the path escaped the base or the file could not be read/statted. */ unverifiable: boolean; } /** * Read env-file KEY names from a file under `baseDir`, bounded by `limits`. The * path containment barrier is inlined at the read sink (CodeQL does not credit a * wrapped helper), and the read is capped so a large or adversarial file cannot * exhaust heap. Values are never materialized: only the slice before the first * `=` of each line is kept. */ export async function readEnvFileKeys( filePath: string, baseDir: string, limits: EnvKeyReadLimits = DEFAULT_ENV_KEY_LIMITS, ): Promise { const resolved = path.resolve(filePath); const baseResolved = path.resolve(baseDir); if (resolved !== baseResolved && !resolved.startsWith(baseResolved + path.sep)) { return { keys: [], truncated: false, unverifiable: true }; } let handle: fsp.FileHandle | undefined; try { // Open first, then fstat the open handle (not the path), so there is no // check-then-use window between a path stat and the open. handle = await fsp.open(resolved, 'r'); const stat = await handle.stat(); if (!stat.isFile()) return { keys: [], truncated: false, unverifiable: true }; const truncated = stat.size > limits.maxBytes; const len = Math.min(stat.size, limits.maxBytes); const buf = Buffer.alloc(len); if (len > 0) await handle.read(buf, 0, len, 0); const seen = new Set(); const keys: string[] = []; const lines = buf.toString('utf-8').split(/\r?\n/); const lineBudget = Math.min(lines.length, limits.maxLines); for (let i = 0; i < lineBudget; i++) { const line = lines[i]; if (line.length > limits.maxLineLen) continue; const key = extractEnvKeyFromLine(line); if (key && !seen.has(key)) { seen.add(key); keys.push(key); } } return { keys, truncated: truncated || lines.length > limits.maxLines, unverifiable: false }; } catch { return { keys: [], truncated: false, unverifiable: true }; } finally { await handle?.close(); } } /** Collect the deduplicated capture-group-1 matches of a global regex over stderr. */ function collectNames(stderr: string, re: RegExp): string[] { const names = new Set(); let m: RegExpExecArray | null; while ((m = re.exec(stderr)) !== null) names.add(m[1]); return [...names]; } /** * Pull the names of variables Compose reported as unset from its stderr. * Compose prints this in logfmt (`msg="The \"VAR\" variable is not set..."`), * so the name is wrapped in an escaped quote; the pattern tolerates the * escaped, plain-quoted, and unquoted forms across Compose versions. */ export function parseUnsetEnvVars(stderr: string): string[] { return collectNames(stderr, /([A-Za-z_][A-Za-z0-9_]*)\\?"?\s+variable is not set/gi); } /** Names of required (${VAR:?...}) variables Compose reported as missing. Names only, never values. */ export function parseMissingRequiredVars(stderr: string): string[] { return collectNames(stderr, /required variable\s+\\?"?([A-Za-z_][A-Za-z0-9_]*)\\?"?\s+is missing/gi); }