mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-18 07:37:59 +02:00
259 lines
8.4 KiB
JavaScript
259 lines
8.4 KiB
JavaScript
'use strict';
|
|
|
|
const { extractCommandSubstitutions } = require('../lib/shell-substitution');
|
|
|
|
/**
|
|
* Recognize the deliberately narrow passive sink supported by this parser.
|
|
* Shell operators and substitutions make the payload's destination ambiguous,
|
|
* so every other form retains the original input for fail-closed checks.
|
|
*
|
|
* @param {string} line
|
|
* @returns {boolean}
|
|
*/
|
|
function isProvenPassiveHeredocLine(line) {
|
|
const trimmed = line.trim();
|
|
return /^cat(?=\s|[<>])/.test(trimmed) && !/[;&|()`]/.test(trimmed);
|
|
}
|
|
|
|
/**
|
|
* Parse a heredoc delimiter after a verified `<<` operator.
|
|
*
|
|
* @param {string} line
|
|
* @param {number} operatorIndex
|
|
* @returns {{ heredoc: { delimiter: string, quoted: boolean, stripTabs: boolean }, endIndex: number } | null}
|
|
*/
|
|
function parseHeredocDelimiter(line, operatorIndex) {
|
|
let endIndex = operatorIndex + 2;
|
|
const stripTabs = line[endIndex] === '-';
|
|
if (stripTabs) endIndex += 1;
|
|
while (endIndex < line.length && /[ \t]/.test(line[endIndex])) endIndex += 1;
|
|
|
|
let delimiter = '';
|
|
let quoted = false;
|
|
const delimiterQuote = line[endIndex] === '"' || line[endIndex] === "'" ? line[endIndex] : null;
|
|
if (delimiterQuote) {
|
|
quoted = true;
|
|
const closingQuote = line.indexOf(delimiterQuote, endIndex + 1);
|
|
if (closingQuote < 0) return null;
|
|
delimiter = line.slice(endIndex + 1, closingQuote);
|
|
endIndex = closingQuote;
|
|
} else {
|
|
const match = line.slice(endIndex).match(/^[A-Za-z_][A-Za-z0-9_]*/);
|
|
if (!match) return null;
|
|
delimiter = match[0];
|
|
endIndex += delimiter.length - 1;
|
|
}
|
|
|
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(delimiter)) return null;
|
|
const next = line[endIndex + 1];
|
|
if (next && !/[\s;&|<>()]/.test(next)) return null;
|
|
return { heredoc: { delimiter, quoted, stripTabs }, endIndex };
|
|
}
|
|
|
|
/**
|
|
* Iterate over simple heredoc redirections on one complete shell command line.
|
|
* A null item marks ambiguous syntax so the caller can fail closed.
|
|
*
|
|
* @param {string} line
|
|
* @returns {Generator<{ delimiter: string, quoted: boolean, stripTabs: boolean } | null>}
|
|
*/
|
|
function* iterateHeredocs(line) {
|
|
let quote = null;
|
|
let escaped = false;
|
|
for (let i = 0; i < line.length; i += 1) {
|
|
const ch = line[i];
|
|
if (quote === "'") {
|
|
if (ch === "'") quote = null;
|
|
continue;
|
|
}
|
|
if (escaped) {
|
|
escaped = false;
|
|
continue;
|
|
}
|
|
if (ch === '\\') {
|
|
escaped = true;
|
|
continue;
|
|
}
|
|
if (quote === '"') {
|
|
if (ch === quote) quote = null;
|
|
continue;
|
|
}
|
|
if (ch === '"' || ch === "'") {
|
|
quote = ch;
|
|
continue;
|
|
}
|
|
if ((ch === '$' && line[i + 1] === '(' && line[i + 2] === '(') || (ch === '(' && line[i + 1] === '(')) {
|
|
yield null;
|
|
return;
|
|
}
|
|
if (ch === '$' && line[i + 1] === '[') {
|
|
yield null;
|
|
return;
|
|
}
|
|
if (ch === '#' && (i === 0 || /[\s;&|()]/.test(line[i - 1]))) break;
|
|
if (ch !== '<' || line[i + 1] !== '<') continue;
|
|
if (line[i + 2] === '<') {
|
|
yield null;
|
|
return;
|
|
}
|
|
const prefix = line.slice(0, i);
|
|
if (prefix.includes('((') || prefix.includes('[[')) {
|
|
yield null;
|
|
return;
|
|
}
|
|
const parsed = parseHeredocDelimiter(line, i);
|
|
if (!parsed) {
|
|
yield null;
|
|
return;
|
|
}
|
|
yield parsed.heredoc;
|
|
i = parsed.endIndex;
|
|
}
|
|
if (quote || escaped) yield null;
|
|
}
|
|
|
|
/**
|
|
* Find simple heredoc redirections on one complete shell command line.
|
|
* Anything ambiguous returns null so the caller can fail closed.
|
|
*
|
|
* @param {string} line
|
|
* @returns {{ delimiter: string, quoted: boolean, stripTabs: boolean }[] | null}
|
|
*/
|
|
function findHeredocs(line) {
|
|
const heredocs = [...iterateHeredocs(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) => {
|
|
const normalized = stripTabs ? line.replace(/^\t+/, '') : line;
|
|
if (index === lines.length - 1) return normalized;
|
|
return hasLineContinuation(normalized) ? normalized.slice(0, -1) : `${normalized}\n`;
|
|
})
|
|
.join('');
|
|
return 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.
|
|
*
|
|
* @param {string[]} body
|
|
* @returns {string[]}
|
|
*/
|
|
function extractHeredocCommandSubstitutions(body, stripTabs) {
|
|
const text = normalizeUnquotedHeredocLines(body, stripTabs);
|
|
return [...new Set(extractCommandSubstitutions(text, { literalOuterQuotes: true }))];
|
|
}
|
|
|
|
/**
|
|
* Consume one heredoc body and return its immutable parser result.
|
|
*
|
|
* @param {string[]} lines
|
|
* @param {number} startIndex
|
|
* @param {{ delimiter: string, quoted: boolean, stripTabs: boolean }} heredoc
|
|
* @returns {{ nextIndex: number, substitutions: string[] } | null}
|
|
*/
|
|
function consumeHeredocBody(lines, startIndex, heredoc) {
|
|
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, heredoc.stripTabs);
|
|
return { nextIndex: logical.nextIndex, substitutions };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @param {string[]} lines
|
|
* @param {number} startIndex
|
|
* @param {{ delimiter: string, quoted: boolean, stripTabs: boolean }[]} heredocs
|
|
* @returns {{ nextIndex: number, chunks: object | null } | null}
|
|
*/
|
|
function consumeHeredocBodies(lines, startIndex, heredocs) {
|
|
let state = { nextIndex: startIndex, chunks: null };
|
|
for (const heredoc of heredocs) {
|
|
const consumed = consumeHeredocBody(lines, state.nextIndex, heredoc);
|
|
if (!consumed) return null;
|
|
state = {
|
|
nextIndex: consumed.nextIndex,
|
|
chunks: consumed.substitutions.length === 0 ? state.chunks : { substitutions: consumed.substitutions, previous: state.chunks }
|
|
};
|
|
}
|
|
return state;
|
|
}
|
|
|
|
/** @returns {Generator<string>} */
|
|
function* iterateSubstitutionChunks(chunks) {
|
|
let ordered = null;
|
|
for (let chunk = chunks; chunk; chunk = chunk.previous) {
|
|
ordered = { substitutions: chunk.substitutions, next: ordered };
|
|
}
|
|
for (let chunk = ordered; chunk; chunk = chunk.next) {
|
|
yield* chunk.substitutions;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove heredoc payload text before classifying the surrounding shell
|
|
* command. Prose in a heredoc is data, so matching it as a command produces
|
|
* false positives. Unquoted heredocs can still execute `$()` and backtick
|
|
* substitutions; retain only those substitution bodies for classification and
|
|
* drop the remaining payload text. Quoted heredoc payloads are fully inert.
|
|
* Ambiguous shell syntax returns the original input unchanged (fail closed).
|
|
*
|
|
* @param {string} input
|
|
* @returns {string}
|
|
*/
|
|
function stripHeredocBodies(input) {
|
|
const raw = String(input || '');
|
|
const lines = raw.split(/\r?\n/);
|
|
let headerIndex = -1;
|
|
let pending = [];
|
|
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
|
|
const line = lines[lineIndex];
|
|
const heredocs = findHeredocs(line);
|
|
if (heredocs === null) return raw;
|
|
if (heredocs.length > 0 && !isProvenPassiveHeredocLine(line)) return raw;
|
|
if (heredocs.length > 0) {
|
|
pending = heredocs;
|
|
headerIndex = lineIndex;
|
|
break;
|
|
}
|
|
}
|
|
if (headerIndex < 0) return lines.join('\n');
|
|
const consumed = consumeHeredocBodies(lines, headerIndex + 1, pending);
|
|
if (!consumed) return raw;
|
|
const trailing = lines.slice(consumed.nextIndex);
|
|
if (trailing.some(line => line.trim())) return raw;
|
|
const substitutions = iterateSubstitutionChunks(consumed.chunks);
|
|
return [...lines.slice(0, headerIndex + 1), ...substitutions, ...trailing].join('\n');
|
|
}
|
|
|
|
module.exports = { stripHeredocBodies };
|