Files
portal/tools/remote-read-allow.mjs
T

177 lines
9.3 KiB
JavaScript
Raw Normal View History

#!/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. */
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 '<SELECT>' (интерактив/файл/неявно) → отказ
return classifySql(sql);
}
/** 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');
}