52d500db5d
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
177 lines
9.3 KiB
JavaScript
177 lines
9.3 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. */
|
||
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');
|
||
}
|