diff --git a/scripts/hooks/gateguard-heredoc.js b/scripts/hooks/gateguard-heredoc.js index 998a7bec1..d29b58fe9 100644 --- a/scripts/hooks/gateguard-heredoc.js +++ b/scripts/hooks/gateguard-heredoc.js @@ -124,6 +124,35 @@ function findHeredocs(line) { return heredocs.includes(null) ? null : heredocs; } +/** @returns {boolean} */ +function hasLineContinuation(line) { + const trailing = line.match(/\\+$/); + return Boolean(trailing && trailing[0].length % 2 === 1); +} + +/** @returns {string} */ +function normalizeUnquotedHeredocLines(lines, stripTabs = false) { + const logical = lines + .map((line, index) => { + if (index === lines.length - 1) return line; + return hasLineContinuation(line) ? line.slice(0, -1) : `${line}\n`; + }) + .join(''); + return stripTabs ? logical.replace(/^\t+/, '') : logical; +} + +/** @returns {{ text: string, nextIndex: number }} */ +function readHeredocLine(lines, startIndex, quoted, stripTabs) { + if (quoted) { + const text = stripTabs ? lines[startIndex].replace(/^\t+/, '') : lines[startIndex]; + return { text, nextIndex: startIndex + 1 }; + } + let endIndex = startIndex; + while (endIndex < lines.length - 1 && hasLineContinuation(lines[endIndex])) endIndex += 1; + const text = normalizeUnquotedHeredocLines(lines.slice(startIndex, endIndex + 1), stripTabs); + return { text, nextIndex: endIndex + 1 }; +} + /** * Extract executable substitutions from an unquoted heredoc. Quote characters * in its payload are literal and do not suppress expansion. @@ -131,8 +160,8 @@ function findHeredocs(line) { * @param {string[]} body * @returns {string[]} */ -function extractHeredocCommandSubstitutions(body) { - const text = body.join('\n'); +function extractHeredocCommandSubstitutions(body, stripTabs) { + const text = normalizeUnquotedHeredocLines(body, stripTabs); return [...new Set(extractCommandSubstitutions(text, { literalOuterQuotes: true }))]; } @@ -145,14 +174,16 @@ function extractHeredocCommandSubstitutions(body) { * @returns {{ nextIndex: number, substitutions: string[] } | null} */ function consumeHeredocBody(lines, startIndex, heredoc) { - for (let lineIndex = startIndex; lineIndex < lines.length; lineIndex += 1) { - const line = lines[lineIndex]; - if (!heredoc.quoted && /\\$/.test(line)) return null; - const delimiterLine = heredoc.stripTabs ? line.replace(/^\t+/, '') : line; - if (delimiterLine !== heredoc.delimiter) continue; + let lineIndex = startIndex; + while (lineIndex < lines.length) { + const logical = readHeredocLine(lines, lineIndex, heredoc.quoted, heredoc.stripTabs); + if (logical.text !== heredoc.delimiter) { + lineIndex = logical.nextIndex; + continue; + } const body = lines.slice(startIndex, lineIndex); - const substitutions = heredoc.quoted ? [] : extractHeredocCommandSubstitutions(body); - return { nextIndex: lineIndex + 1, substitutions }; + const substitutions = heredoc.quoted ? [] : extractHeredocCommandSubstitutions(body, heredoc.stripTabs); + return { nextIndex: logical.nextIndex, substitutions }; } return null; } diff --git a/tests/hooks/gateguard-fact-force.test.js b/tests/hooks/gateguard-fact-force.test.js index b5b18cf8c..4c738c92a 100644 --- a/tests/hooks/gateguard-fact-force.test.js +++ b/tests/hooks/gateguard-fact-force.test.js @@ -1760,6 +1760,39 @@ function runTests() { passed++; else failed++; + if ( + test('denies split command names after heredoc line continuation', () => { + expectDestructiveDeny( + ['cat < { + expectDestructiveDeny( + ['cat <<-EOF', '\t$(rm\\', '\t-rf /tmp/expanded-target)', 'EOF'].join('\n'), + 'split option in tab-stripped unquoted heredoc substitution' + ); + }) + ) + passed++; + else failed++; + + if ( + test('preserves internal tabs after tab-stripped heredoc continuations', () => { + expectAllow( + ['cat <<-EOF', '\t$(r\\', '\tm -rf /tmp/expanded-target)', 'EOF'].join('\n'), + 'internal tab after tab-stripped heredoc continuation' + ); + }) + ) + passed++; + else failed++; + if ( test('fails closed on line-continued unquoted heredoc terminators', () => { expectDestructiveDeny(