#!/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 ''; блок write-ключевых слов и -f/-o). * 2) gh : 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 ''` без множественных стейтментов. */ 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 '' — ровно 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 ''`; блок -f/-o и write-SQL. */ function classifyPsql(tokens) { let sql = null; for (let i = 1; i < tokens.length; i++) { const t = tokens[i]; // запрет файла-скрипта и записи результата в файл 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(); if (t === '-c' || t === '--command') { sql = tokens[i + 1] ?? null; continue; } if (/^--command=/.test(t)) { sql = t.slice('--command='.length); continue; } } if (sql == null) return fail(); // без явного -c '