maestro/src/ssh/deny-list.ts
2026-06-03 05:08:00 +00:00

230 lines
7.9 KiB
TypeScript

/**
* SSH command policy: built-in deny patterns + per-connection custom regex.
*
* Layers (evaluated in order against the candidate command line):
* 1. BUILT-IN deny — destructive operations we never want from automation
* 2. CUSTOM deny — per-connection admin/operator additions
* 3. CUSTOM allow — when non-empty, command must match at least one
* (otherwise denied as `not_in_allowlist`)
*
* Custom regex sources are validated at save time with three caps:
* - max 16 patterns per connection
* - max 256 chars per pattern
* - structural ReDoS check (nested quantifier rejected; no external dep)
*
* Invalid custom regex → save error (caller surfaces to UI); never silently
* dropped, so the operator is never confused about what's enforced.
*
* Plan: docs/superpowers/plans/2026-05-12-ssh-tool-integration.md (Phase 2).
*/
export interface DenyPattern {
/** Stable key for logs / audit / UI. */
name: string;
regex: RegExp;
description: string;
}
/**
* Destructive shell patterns we never permit. These are intentionally narrow
* — false-positives are worse than false-negatives at this layer (the LLM
* will give up early and the user will have to debug). Operators are
* expected to add their own deny patterns for site-specific concerns.
*
* All patterns are case-INSENSITIVE and tested against the raw command line.
*/
export const BUILTIN_DENY_PATTERNS: readonly DenyPattern[] = [
{
name: 'rm_rf_system_dir',
regex: /\brm\s+(?:-[A-Za-z]*[rf][A-Za-z]*\s+)+(?:--no-preserve-root\s+)?\/(?:etc|usr|var|boot|bin|sbin|lib|lib64|opt|root|sys|proc)(?:\s|\/|$|;|\||&)/i,
description: 'rm -rf on a system directory',
},
{
name: 'rm_rf_root',
regex: /\brm\s+(?:-[A-Za-z]*[rf][A-Za-z]*\s+)+(?:--no-preserve-root\s+)?\/(?![A-Za-z0-9_.-])/i,
description: 'rm -rf / or rm -rf /*',
},
{
name: 'dd_to_block_device',
regex: /\bdd\b[^\n]*\bof=\/dev\/(?:sd[a-z]|hd[a-z]|nvme\d|vd[a-z]|xvd[a-z]|mmcblk\d|loop\d)/i,
description: 'dd writing to a block device',
},
{
name: 'mkfs',
regex: /\bmkfs(?:\.[A-Za-z0-9]+)?\b/i,
description: 'mkfs (format filesystem)',
},
{
name: 'fork_bomb',
regex: /:\(\)\s*\{[^}]*:\s*\|[^}]*:[^}]*\}\s*;?\s*:/,
description: 'classic :(){ :|:& };: fork bomb',
},
{
name: 'shutdown_or_reboot',
regex: /\b(?:shutdown|reboot|poweroff|halt|init\s+0|init\s+6)\b/i,
description: 'system power state change',
},
{
name: 'kill_init',
regex: /\bkill\s+(?:-(?:9|KILL|SIGKILL|TERM|SIGTERM)\s+)?1\b/i,
description: 'kill PID 1 (init)',
},
{
name: 'pipe_curl_to_shell',
regex: /\b(?:curl|wget|fetch)\b[^|\n]*\|\s*(?:sudo\s+)?(?:bash|sh|zsh|ksh|dash|fish)\b/i,
description: 'curl/wget piped to shell',
},
{
name: 'reverse_shell_tcp',
regex: /(?:bash|sh|zsh|ksh)\s+-i\s+>&?\s*\/dev\/tcp\//i,
description: 'bash -i >& /dev/tcp/ reverse shell',
},
{
name: 'nc_exec_shell',
regex: /\bn(?:c|cat)\b[^\n]*-e\s+\/(?:bin\/)?(?:bash|sh|zsh)/i,
description: 'netcat -e /bin/sh reverse shell',
},
{
name: 'overwrite_etc_passwd',
regex: /(?:^|[\s;|&])>{1,2}\s*\/etc\/(?:passwd|shadow|sudoers|gshadow)\b/i,
description: 'redirect over /etc/passwd-class files',
},
{
name: 'chmod_777_root',
regex: /\bchmod\s+-R\s+0?777\s+\/(?:\s|$)/i,
description: 'chmod -R 777 /',
},
{
name: 'history_clear',
regex: /\bhistory\s+-c\b|(?:^|[\s;|&])>{1,2}\s*~?\/?\.?(?:bash_|zsh_)?history\b|\b(?:unset|export)\s+HISTFILE\b/i,
description: 'clear or disable shell history',
},
];
export interface ValidateCustomResult {
ok: boolean;
/** Compiled regexes when ok=true (1:1 with input order). */
compiled?: RegExp[];
/** Per-index errors when ok=false. */
errors?: Array<{ index: number; reason: ValidateRejection }>;
}
export type ValidateRejection =
| 'too_many'
| 'too_long'
| 'empty'
| 'invalid_regex'
| 'nested_quantifier'
| 'forbidden_construct';
export const MAX_CUSTOM_PATTERNS = 16;
export const MAX_PATTERN_LENGTH = 256;
/**
* Heuristic ReDoS check: rejects patterns with a quantifier applied to a
* group whose own contents include a quantifier ("star height > 1"). This
* catches the most common catastrophic-backtracking shape, like `(\w+)+`
* or `(.*)*`.
*
* Imperfect — it does not detect overlapping alternation like `(a|aa)+`.
* Operators with deep regex needs should run their own tests.
*/
function hasNestedQuantifier(source: string): boolean {
// Match: '(' + optional non-capture marker + body + ')' + quantifier,
// where body contains a quantifier (+ * ? or { ).
// Limit to non-nested groups (no inner parens) so we don't blow up on
// arbitrarily complex source.
const re = /\((?:\?[:=!<])?[^()]*[+*?][^()]*\)[+*?{]/;
if (re.test(source)) return true;
// Also flag (?:.*)+ and (?:.+)+ where . isn't an additional quantifier
// catch — already covered by the above. Good enough.
return false;
}
function hasForbiddenConstruct(source: string): boolean {
// Refuse Unicode-property escapes (\p{...}) and named groups — they're
// legitimate, but rare enough that operators are unlikely to need them
// here. Forbidding them keeps the surface area minimal. Comment them in
// if a future use case appears.
if (/\\p\{/.test(source)) return true;
if (/\(\?P?</.test(source)) return true;
return false;
}
export function validateCustomPatterns(sources: string[]): ValidateCustomResult {
if (!Array.isArray(sources)) {
return { ok: false, errors: [{ index: 0, reason: 'invalid_regex' }] };
}
if (sources.length > MAX_CUSTOM_PATTERNS) {
return { ok: false, errors: [{ index: MAX_CUSTOM_PATTERNS, reason: 'too_many' }] };
}
const errors: Array<{ index: number; reason: ValidateRejection }> = [];
const compiled: RegExp[] = [];
for (let i = 0; i < sources.length; i++) {
const src = sources[i];
if (typeof src !== 'string' || src.length === 0) {
errors.push({ index: i, reason: 'empty' });
continue;
}
if (src.length > MAX_PATTERN_LENGTH) {
errors.push({ index: i, reason: 'too_long' });
continue;
}
if (hasForbiddenConstruct(src)) {
errors.push({ index: i, reason: 'forbidden_construct' });
continue;
}
if (hasNestedQuantifier(src)) {
errors.push({ index: i, reason: 'nested_quantifier' });
continue;
}
try {
compiled.push(new RegExp(src, 'i'));
} catch {
errors.push({ index: i, reason: 'invalid_regex' });
}
}
if (errors.length > 0) return { ok: false, errors };
return { ok: true, compiled };
}
export interface CheckCommandArgs {
command: string;
customDenyPatterns?: RegExp[];
/** When non-empty, command must match at least one (allowlist mode). */
customAllowPatterns?: RegExp[];
}
export type CheckCommandReason = 'builtin_deny' | 'custom_deny' | 'not_in_allowlist' | 'empty';
export interface CheckCommandResult {
allowed: boolean;
reason?: CheckCommandReason;
/** Name of the built-in pattern (or 'custom') that matched. */
matched?: string;
}
export function checkCommand(args: CheckCommandArgs): CheckCommandResult {
const cmd = args.command;
if (typeof cmd !== 'string' || cmd.trim().length === 0) {
return { allowed: false, reason: 'empty' };
}
for (const p of BUILTIN_DENY_PATTERNS) {
if (p.regex.test(cmd)) {
return { allowed: false, reason: 'builtin_deny', matched: p.name };
}
}
if (args.customDenyPatterns) {
for (let i = 0; i < args.customDenyPatterns.length; i++) {
if (args.customDenyPatterns[i].test(cmd)) {
return { allowed: false, reason: 'custom_deny', matched: `custom_deny[${i}]` };
}
}
}
if (args.customAllowPatterns && args.customAllowPatterns.length > 0) {
const hit = args.customAllowPatterns.some((r) => r.test(cmd));
if (!hit) return { allowed: false, reason: 'not_in_allowlist' };
}
return { allowed: true };
}