Files
portal/tools/remote-read-allow.mjs
T
Дмитрий 6dd622536e ночь: сторож боевого сервера теперь смотрит на КАЖДУЮ команду psql, а не последнюю
Что было. 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.
2026-08-05 18:12:54 +03:00

250 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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');
}