230 lines
7.9 KiB
TypeScript
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 };
|
|
}
|