6dd622536e
Что было. classifyPsql хранил только последний -c/--command и валидировал одну строку.
Живой замер сторожем показал пять форм пропускания пишущей: слева / справа-длинная /
слитная -c'DEL' / сокращённая --comm= / в середине. Одинокая пишущая ловилась через
SQL_WRITE_RE, а любая комбинация с чтением рядом — уже нет: `psql -c 'delete from leads'
-c 'select 1'` тихо давал ok:true и открывал боевой сервер на снос строк.
Что стало. Собираю ВСЕ SQL из всех форм -c/--command:
· -c <sql>, -c<sql> (короткая, раздельно и слитно; bash склеивает -c'x' → -cx)
· --command <sql>, --command=<sql>
· --co…/--com…/--comm…/--comman… = <sql> и <sql> (сокращённые префиксы длинного имени
— psql принимает по getopt_long; треба ≥2 символов, чтобы не путать с --csv)
Любая пишущая среди собранных — отказ всей строки. Ни одна дыра не затыкается тем,
что рядом стоит чтение. Соблюдён принцип «не можешь прочитать глазами — отказывай»:
-c без парного значения → fail.
Правка минимальная и только в сторону «строже»: чужих правил не трогал, законное чтение
не задел. Проверки: tools/remote-read-mnogo-komand.test.mjs (10/10 зелёных, ловят все
пять случаев + пишущая в середине + сторож послаблений на одно и два подряд чтения);
tools/remote-read-allow.test.mjs — 19/19 целы. Замер «до» и «после» + названные вслух
оставшиеся дыры (\i, \!, \g file — вне класса моего пункта) — в tools/remote-read-zamer-do.md.
250 lines
14 KiB
JavaScript
250 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* remote-read-allow — строго read-only разрешитель удалённого доступа к боевому
|
||
* порталу для РАЗГОВОРНОГО режима стены (owner-authorized 2026-06-18).
|
||
*
|
||
* Открывает РОВНО две формы, всё остальное → ok:false (→ default-deny стены/роутера):
|
||
* 1) ssh liderra-prod '<одна read-only команда>'
|
||
* read-команды: cat/grep/egrep/fgrep/ls/head/tail/stat/wc/file/less/more/sed
|
||
* sed — только print-формы (без -i/--in-place/-f/--file и без write-команд w/W);
|
||
* psql — только SELECT-only (через -c '<SQL>'; блок write-ключевых слов и -f/-o).
|
||
* 2) gh <GET-операция>: run list|view, workflow list|view, api с методом GET.
|
||
*
|
||
* Жёсткие инварианты (консервативно, owner-решения 2026-06-18):
|
||
* - ssh-цель строго literal 'liderra-prod'; никаких ssh-опций (-L/-R/-o/...);
|
||
* - удалённая команда — РОВНО ОДНА, без пайпов/цепочек (|/;/&&/||) и редиректов;
|
||
* - sub-shell ($()/backticks/<()/heredoc) в любой части → отказ;
|
||
* - gh api: блок -X/--method ≠ GET и body-флагов (-f/-F/--field/--raw-field/--input).
|
||
*
|
||
* Чистая функция, без I/O. fail-safe: любой бросок/неоднозначность → { ok:false }.
|
||
* Остаточный риск (принят владельцем): psql SELECT-only определяется разбором SQL —
|
||
* вызов write-функции внутри SELECT (nextval/setval/pg_*) не ловится по ключевым
|
||
* словам. Поверхность сужена до явного `-c '<SELECT…>'` без множественных стейтментов.
|
||
*/
|
||
import { tokenizeBash } from './bash-tokenizer.mjs';
|
||
|
||
const SSH_HOST = 'liderra-prod';
|
||
|
||
// read-only удалённые команды (по token[0]) — файловое чтение.
|
||
const REMOTE_FILE_READ = new Set([
|
||
'cat', 'grep', 'egrep', 'fgrep', 'ls', 'head', 'tail', 'stat', 'wc', 'file', 'less', 'more', 'sed',
|
||
]);
|
||
|
||
// Редирект-/control-токены, которые tokenizeBash кладёт внутрь сегмента.
|
||
const REDIRECT_TOKENS = new Set(['>', '>>', '<', '<<', '>&', '&', '|']);
|
||
|
||
// SQL write-ключевые слова: наличие любого → НЕ SELECT-only.
|
||
const SQL_WRITE_RE = /\b(insert|update|delete|drop|alter|create|truncate|grant|revoke|copy|merge|call|do|vacuum|reindex|cluster|comment|begin|commit|rollback|savepoint|set|reset|lock|into|nextval|setval|refresh|prepare|execute|deallocate|listen|notify|unlisten|reassign|cursor|fetch|move|declare|analyze)\b/i;
|
||
|
||
// Допустимое начало SELECT-only запроса.
|
||
const SQL_READ_HEAD_RE = /^(select|with|table|values|show|explain)\b/i;
|
||
|
||
function fail() { return { ok: false }; }
|
||
function ok(kind) { return { ok: true, kind }; }
|
||
|
||
function hasRedirect(tokens) {
|
||
return tokens.some((t) => REDIRECT_TOKENS.has(t));
|
||
}
|
||
|
||
/** Единственный сегмент без sub-shell/редиректов/control-хвоста → его токены, иначе null. */
|
||
function singleCleanSegment(tok) {
|
||
if (!tok || !tok.ok || tok.hasSubshell) return null;
|
||
if (!Array.isArray(tok.segments) || tok.segments.length !== 1) return null;
|
||
const seg = tok.segments[0];
|
||
if (seg.op !== null) return null; // завершающий control-op (напр. фоновый &)
|
||
const tokens = Array.isArray(seg.tokens) ? seg.tokens : [];
|
||
if (tokens.length === 0) return null;
|
||
if (hasRedirect(tokens)) return null;
|
||
return tokens;
|
||
}
|
||
|
||
/**
|
||
* Главный разрешитель. command — полная Bash-строка инструмента.
|
||
* tokenize инъектируем для тестов.
|
||
*/
|
||
export function classifyRemoteReadCommand(command, { tokenize = tokenizeBash } = {}) {
|
||
try {
|
||
if (typeof command !== 'string' || !command.trim()) return fail();
|
||
const tokens = singleCleanSegment(tokenize(command));
|
||
if (!tokens) return fail();
|
||
const head = tokens[0];
|
||
if (head === 'ssh') return classifySsh(tokens, tokenize);
|
||
if (head === 'gh') return classifyGh(tokens);
|
||
return fail();
|
||
} catch {
|
||
return fail();
|
||
}
|
||
}
|
||
|
||
/** ssh liderra-prod '<remotecmd>' — ровно 3 токена, цель literal, опций нет. */
|
||
function classifySsh(tokens, tokenize) {
|
||
if (tokens.length !== 3) return fail(); // ssh + host + единственный quoted-remote
|
||
if (tokens[1] !== SSH_HOST) return fail(); // никаких опций/иных хостов
|
||
const remote = tokens[2];
|
||
if (typeof remote !== 'string' || !remote.trim()) return fail();
|
||
return classifyRemoteInner(remote, tokenize);
|
||
}
|
||
|
||
/** Внутренняя удалённая команда: ровно одна read-only, без пайпов/редиректов. */
|
||
function classifyRemoteInner(remote, tokenize) {
|
||
const tokens = singleCleanSegment(tokenize(remote));
|
||
if (!tokens) return fail();
|
||
const cmd = tokens[0];
|
||
if (cmd === 'psql') return classifyPsql(tokens);
|
||
if (!REMOTE_FILE_READ.has(cmd)) return fail();
|
||
if (cmd === 'sed' && !sedIsPrintOnly(tokens)) return fail();
|
||
return ok('ssh-read');
|
||
}
|
||
|
||
/**
|
||
* sed безопасен только в print-форме: без -i/--in-place (in-place запись) и без
|
||
* -f/--file (внешний скрипт может нести write-команды w/W). Доп. предосторожность:
|
||
* любой токен с буквой w/W (включая в шаблоне) → отказ, чтобы `w file` / `s///w file`
|
||
* не проскочили. Это намеренный over-block в безопасную сторону (используйте grep/cat).
|
||
*/
|
||
function sedIsPrintOnly(tokens) {
|
||
for (const t of tokens.slice(1)) {
|
||
if (t === '-i' || /^-i/.test(t) || t === '--in-place' || /^--in-place/.test(t)) return false;
|
||
if (t === '-f' || /^-f/.test(t) || t === '--file' || /^--file/.test(t)) return false;
|
||
if (/[wW]/.test(t)) return false; // w/W write-команды sed (и над-осторожно — в шаблоне)
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* psql — только явный `-c '<SELECT…>'`; блок -f/-o и write-SQL.
|
||
*
|
||
* 🔴🔴🔴 Сторож обязан смотреть КАЖДУЮ `-c`/`--command` команду, а не последнюю.
|
||
* Прежде сохранялся только последний `-c`, поэтому строка вида
|
||
* `psql -c 'delete from leads' -c 'select 1'` тихо проходила: пишущая слева забывалась,
|
||
* читающая справа шла в проверку и получала «ok». Здесь этот класс закрыт: собираем
|
||
* ВСЕ поля SQL из любых форм записи `-c` / `--command`, и любая пишущая среди них —
|
||
* отказ всей строки. Ни одна дыра не затыкается тем, что рядом стоит чтение.
|
||
*
|
||
* 🪤 Поддерживаемые формы `-c`/`--command`:
|
||
* · `-c <sql>` — раздельно (два токена);
|
||
* · `-c<sql>` — слитно (bash склеивает: `-c'DEL'` → один токен `-cDEL`);
|
||
* · `--command <sql>`;
|
||
* · `--command=<sql>`;
|
||
* · `--com…=<sql>` и `--com… <sql>` — сокращённое длинное имя. psql (getopt_long)
|
||
* принимает любой уникальный префикс `command`; уникальным префикс `--com*`
|
||
* становится с `--com` (короче — совпадает с `--csv`, ветвление).
|
||
* Мы для БЕЗОПАСНОСТИ трактуем префиксы `--co*`, `--com*`, `--comm*`, `--comma*`,
|
||
* `--comman*`, `--command` (в т.ч. неточные) как потенциальные носители SQL
|
||
* и валидируем их так же строго. Ложные срабатывания идут в безопасную сторону.
|
||
*
|
||
* 🔑 Правило «не можешь прочитать глазами — отказывай» (пункт 4 задания): любые
|
||
* `-c` без парного значения → отказ.
|
||
*/
|
||
function classifyPsql(tokens) {
|
||
const sqls = [];
|
||
for (let i = 1; i < tokens.length; i++) {
|
||
const t = tokens[i];
|
||
if (typeof t !== 'string') return fail();
|
||
|
||
// Запрет файла-скрипта и записи результата в файл (как было).
|
||
if (t === '-f' || /^-f/.test(t) || t === '--file' || /^--file/.test(t)) return fail();
|
||
if (t === '-o' || /^-o/.test(t) || t === '--output' || /^--output/.test(t)) return fail();
|
||
|
||
// Точная форма `-c` / `--command` — следующий токен есть SQL.
|
||
if (t === '-c' || t === '--command') {
|
||
const next = tokens[i + 1];
|
||
if (typeof next !== 'string') return fail(); // нет пары — отказ, не додумываем
|
||
sqls.push(next);
|
||
i++; // проглатываем взятый аргумент
|
||
continue;
|
||
}
|
||
|
||
// Слитная форма `-c<sql>` (bash склеил `-c` с прижатым к нему кавычным аргументом).
|
||
if (/^-c./.test(t)) {
|
||
sqls.push(t.slice(2));
|
||
continue;
|
||
}
|
||
|
||
// Длинное имя `--command=<sql>` и его сокращённые префиксы `--co*=<sql>` / `--co* <sql>`.
|
||
// Уникальным префиксом `--command` в psql становится `--com` (короче совпадает с --csv),
|
||
// но для безопасности трактуем как потенциальный `--command` любой префикс,
|
||
// который САМ является префиксом слова `command` длиной ≥ 2 (`--co`, `--com`, `--comm`,
|
||
// `--comma`, `--comman`, `--command`) — включая обе формы: с `=` и без.
|
||
const longEq = /^--([a-z]+)=(.*)$/s.exec(t);
|
||
if (longEq) {
|
||
const optName = longEq[1];
|
||
if ('command'.startsWith(optName) && optName.length >= 2) {
|
||
sqls.push(longEq[2]);
|
||
continue;
|
||
}
|
||
}
|
||
const longBare = /^--([a-z]+)$/.exec(t);
|
||
if (longBare) {
|
||
const optName = longBare[1];
|
||
if ('command'.startsWith(optName) && optName.length >= 2 && optName !== 'command') {
|
||
// Сокращённый префикс без `=` — следующий токен SQL.
|
||
const next = tokens[i + 1];
|
||
if (typeof next !== 'string') return fail();
|
||
sqls.push(next);
|
||
i++;
|
||
continue;
|
||
}
|
||
}
|
||
// Прочие аргументы psql (напр. имя БД, `-U user`, `-h host`) — оставляем как раньше.
|
||
}
|
||
|
||
if (sqls.length === 0) return fail(); // без явного `-c '<SELECT>'` (интерактив/файл/неявно) → отказ
|
||
|
||
// Любая пишущая среди собранных — отказ всей строки. Читающая справа не спасает.
|
||
for (const sql of sqls) {
|
||
const verdict = classifySql(sql);
|
||
if (!verdict.ok) return fail();
|
||
}
|
||
return ok('ssh-read');
|
||
}
|
||
|
||
/** SELECT-only: один стейтмент, начало select/with/…, без write-ключевых слов. */
|
||
function classifySql(sql) {
|
||
if (typeof sql !== 'string' || !sql.trim()) return fail();
|
||
const trimmed = sql.trim().replace(/;\s*$/, ''); // снять один завершающий ;
|
||
if (trimmed.includes(';')) return fail(); // множественные стейтменты
|
||
if (SQL_WRITE_RE.test(trimmed)) return fail(); // любое write-слово
|
||
const head = trimmed.replace(/^\(+\s*/, ''); // снять ведущие скобки
|
||
if (SQL_READ_HEAD_RE.test(head)) return ok('ssh-read'); // SELECT/WITH/TABLE/VALUES/SHOW/EXPLAIN
|
||
if (/^\\/.test(head)) return ok('ssh-read'); // psql meta (\dt \d \l) — read-only
|
||
return fail();
|
||
}
|
||
|
||
/** gh — только read-операции: run list|view, workflow list|view, api GET. */
|
||
function classifyGh(tokens) {
|
||
const sub = tokens[1];
|
||
const sub2 = tokens[2];
|
||
if (sub === 'run' && (sub2 === 'list' || sub2 === 'view')) return ok('gh-read');
|
||
if (sub === 'workflow' && (sub2 === 'list' || sub2 === 'view')) return ok('gh-read');
|
||
if (sub === 'api') return classifyGhApi(tokens);
|
||
return fail();
|
||
}
|
||
|
||
/** gh api — только метод GET, без тела запроса. */
|
||
function classifyGhApi(tokens) {
|
||
const rest = tokens.slice(2);
|
||
for (let i = 0; i < rest.length; i++) {
|
||
const t = rest[i];
|
||
if (t === '-X' || t === '--method') {
|
||
const m = rest[i + 1];
|
||
if (!m || String(m).toUpperCase() !== 'GET') return fail();
|
||
continue;
|
||
}
|
||
if (/^--method=/.test(t)) {
|
||
if (t.slice('--method='.length).toUpperCase() !== 'GET') return fail();
|
||
continue;
|
||
}
|
||
if (/^-X./.test(t)) { // склеенная форма -XPOST
|
||
if (t.slice(2).toUpperCase() !== 'GET') return fail();
|
||
continue;
|
||
}
|
||
// тело запроса → подразумевает POST/PATCH/PUT
|
||
if (t === '-f' || t === '-F' || t === '--field' || t === '--raw-field' || t === '--input'
|
||
|| /^(--field=|--raw-field=|--input=)/.test(t) || /^-f./.test(t) || /^-F./.test(t)) return fail();
|
||
}
|
||
const hasPath = rest.some((t) => typeof t === 'string' && !t.startsWith('-'));
|
||
if (!hasPath) return fail(); // нужен позиционный путь/endpoint
|
||
return ok('gh-read');
|
||
}
|