Files
portal/tools/remote-read-allow.mjs
T
2026-06-18 08:04:50 +03:00

177 lines
9.3 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. */
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');
}