fix(security): 스케줄 셸 인터프리터 인라인 코드 우회 차단

셸 스케줄 검증이 첫 토큰 basename 만 화이트리스트와 대조하고 뒤 인자를
검증하지 않아, 운영자가 bash 를 등록하면 `bash -c id` 로 임의 OS 명령이
실행됐다(KVE-2026-1653). 인터프리터는 범용 크론 용도로 등록을 허용하되,
스크립트 자리(첫 인자)에 형태 규칙을 적용해 인라인 코드·상대경로·트래버설·
artisan 우회·완전거부형(env/awk/make 등)을 저장·실행 양쪽에서 거부한다.
This commit is contained in:
HeuJung committed 2026-08-17 20:59:42 +09:00
1 parent 19424877e8
commit 2fb452aa44
12 files changed
+955 -7

No files matched your search

@@ -139,6 +139,220 @@ class ScheduleCommandValidatorTest extends TestCase
$this->assertNull(ScheduleCommandValidator::tokenizeShellCommand(' '));
}
// ========================================================================
// Shell — 인터프리터 정책 (KVE-2026-1653)
// ========================================================================
/**
* 인터프리터에 인라인 코드/명령을 넘기는 형태는 화이트리스트에 등재돼 있어도 차단한다.
*
* KVE-2026-1653 본체: 운영자가 `bash` 를 화이트리스트에 등록하면 `bash -c id` 가
* 통과해 임의 OS 명령이 실행됐다. 스크립트 자리(첫 인자)가 하이픈이면 인라인 코드 플래그다.
*
* @param string $command 인라인 코드/명령을 넘기는 command
*
* @scenario command_class=inline_code, enforcement_point=validator_unit
*
* @effects inline_code_flags_rejected
*/
#[Test]
#[DataProvider('inlineCodeShellCommandProvider')]
public function it_blocks_inline_code_through_interpreters_even_when_allowlisted(string $command): void
{
$this->enableShell(self::interpreterBinaries());
$verdict = ScheduleCommandValidator::inspectShellCommand($command);
$this->assertFalse($verdict['allowed'], "인라인 코드가 통과함: {$command}");
$this->assertSame(ScheduleCommandValidator::SHELL_REASON_INLINE_CODE, $verdict['reason']);
}
/**
* 인터프리터 + 절대경로 스크립트 파일은 통과한다 (범용 크론 기능 회귀 가드).
*
* @param string $command 스크립트 파일을 실행하는 command
*
* @scenario command_class=script_ok, enforcement_point=validator_unit
*
* @effects interpreter_script_files_still_run
*/
#[Test]
#[DataProvider('interpreterScriptShellCommandProvider')]
public function it_allows_interpreters_running_absolute_path_scripts(string $command): void
{
$this->enableShell(self::interpreterBinaries());
$verdict = ScheduleCommandValidator::inspectShellCommand($command);
$this->assertTrue($verdict['allowed'], "정상 스크립트 실행이 차단됨: {$command}");
$this->assertNull($verdict['reason']);
}
/**
* 인터프리터 뒤 스크립트 자리가 artisan 이면 차단한다 (Artisan 축 우회 방지).
*
* @param string $command artisan 을 스크립트로 넘기는 command
*
* @scenario command_class=artisan_bypass, enforcement_point=validator_unit
*
* @effects artisan_via_shell_rejected
*/
#[Test]
#[DataProvider('artisanBypassShellCommandProvider')]
public function it_blocks_artisan_as_the_interpreter_script(string $command): void
{
$this->enableShell(self::interpreterBinaries());
$verdict = ScheduleCommandValidator::inspectShellCommand($command);
$this->assertFalse($verdict['allowed'], "artisan 우회가 통과함: {$command}");
$this->assertSame(ScheduleCommandValidator::SHELL_REASON_INTERPRETER, $verdict['reason']);
}
/**
* 완전 거부형 실행기는 화이트리스트에 등재돼 있어도 차단한다.
*
* env/awk/make/xargs/sudo/busybox 등은 첫 인자가 코드·감싼 명령이라 "스크립트 파일"
* 모델이 성립하지 않는다.
*
* @param string $command 완전 거부형 command
*
* @scenario command_class=reject_binary, enforcement_point=validator_unit
*
* @effects reject_binaries_blocked_even_when_allowlisted
*/
#[Test]
#[DataProvider('rejectBinaryShellCommandProvider')]
public function it_blocks_reject_binaries_even_when_allowlisted(string $command): void
{
$this->enableShell(self::interpreterBinaries());
$verdict = ScheduleCommandValidator::inspectShellCommand($command);
$this->assertFalse($verdict['allowed'], "완전 거부형이 통과함: {$command}");
$this->assertSame(ScheduleCommandValidator::SHELL_REASON_INTERPRETER, $verdict['reason']);
}
/**
* 인터프리터 뒤 스크립트 경로 형태 위반(상대경로 / `..` / `#`)은 차단한다.
*
* @param string $command 경로 형태 위반 command
*
* @scenario command_class=script_path, enforcement_point=validator_unit
*
* @effects script_path_traversal_rejected
*/
#[Test]
#[DataProvider('scriptPathShellCommandProvider')]
public function it_blocks_unsafe_interpreter_script_paths(string $command): void
{
$this->enableShell(self::interpreterBinaries());
$verdict = ScheduleCommandValidator::inspectShellCommand($command);
$this->assertFalse($verdict['allowed'], "안전하지 않은 경로가 통과함: {$command}");
$this->assertSame(ScheduleCommandValidator::SHELL_REASON_SCRIPT_PATH, $verdict['reason']);
}
/**
* 스크립트 없이 인터프리터만 등록하면(REPL/셸 기동) 차단한다.
*
* @param string $command 인자 없는 인터프리터 command
*
* @scenario command_class=no_script, enforcement_point=validator_unit
*
* @effects interpreter_without_script_rejected
*/
#[Test]
#[DataProvider('noScriptShellCommandProvider')]
public function it_blocks_interpreters_without_a_script(string $command): void
{
$this->enableShell(self::interpreterBinaries());
$verdict = ScheduleCommandValidator::inspectShellCommand($command);
$this->assertFalse($verdict['allowed'], "인자 없는 인터프리터가 통과함: {$command}");
$this->assertSame(ScheduleCommandValidator::SHELL_REASON_INTERPRETER, $verdict['reason']);
}
/**
* 인터프리터가 아닌 일반 스크립트는 종전처럼 통과한다 (기존 계약 보존 회귀 가드).
*
* @param string $command 일반 스크립트 command
*
* @scenario command_class=legit_plain_script, enforcement_point=validator_unit
*
* @effects plain_scripts_still_run_without_path_restrictions
*/
#[Test]
#[DataProvider('plainScriptShellCommandProvider')]
public function it_still_allows_plain_non_interpreter_scripts(string $command): void
{
$this->enableShell(['backup.sh']);
$verdict = ScheduleCommandValidator::inspectShellCommand($command);
$this->assertTrue($verdict['allowed'], "일반 스크립트가 차단됨: {$command}");
$this->assertNull($verdict['reason']);
}
/**
* 게이트가 꺼져 있으면 disabled 사유를 돌려준다.
*
* @scenario command_class=inline_code, enforcement_point=validator_unit
*
* @effects rejection_reason_is_reported_per_category
*/
#[Test]
public function it_reports_disabled_when_the_shell_gate_is_off(): void
{
config([
'schedule_security.shell.enabled' => false,
'schedule_security.shell.allowed_binaries' => ['bash'],
]);
$verdict = ScheduleCommandValidator::inspectShellCommand('bash /app/x.sh');
$this->assertFalse($verdict['allowed']);
$this->assertSame(ScheduleCommandValidator::SHELL_REASON_DISABLED, $verdict['reason']);
}
/**
* 화이트리스트 밖 명령은 not_allowlisted 사유를 돌려준다.
*
* @scenario command_class=inline_code, enforcement_point=validator_unit
*
* @effects rejection_reason_is_reported_per_category
*/
#[Test]
public function it_reports_not_allowlisted_for_unregistered_binaries(): void
{
$this->enableShell(['backup.sh']);
$verdict = ScheduleCommandValidator::inspectShellCommand('bash -c id');
$this->assertFalse($verdict['allowed']);
$this->assertSame(ScheduleCommandValidator::SHELL_REASON_NOT_ALLOWED, $verdict['reason']);
}
/**
* 메타문자가 섞인 명령은 metacharacter 사유를 돌려준다.
*
* @scenario command_class=reject_binary, enforcement_point=validator_unit
*
* @effects rejection_reason_is_reported_per_category
*/
#[Test]
public function it_reports_metacharacter_for_commands_with_shell_metacharacters(): void
{
$this->enableShell(self::interpreterBinaries());
$verdict = ScheduleCommandValidator::inspectShellCommand('awk \'BEGIN{system("id")}\'');
$this->assertFalse($verdict['allowed']);
$this->assertSame(ScheduleCommandValidator::SHELL_REASON_METACHARACTER, $verdict['reason']);
}
// ========================================================================
// Artisan — 차단목록
// ========================================================================
@@ -677,4 +891,137 @@ class ScheduleCommandValidatorTest extends TestCase
'옵션 구분자 단독' => ['cache:clear --', ScheduleCommandValidator::ARTISAN_REASON_MALFORMED],
];
}
// ========================================================================
// Data Providers — Shell 인터프리터 정책 (KVE-2026-1653)
// ========================================================================
/**
* 인터프리터 정책 테스트에서 화이트리스트에 등재하는 실행 파일 목록.
*
* 인터프리터·완전거부형을 **등재한 상태**로 판정해 "등재돼도 차단됨" 을 증명한다.
*
* @return array<int, string>
*/
private static function interpreterBinaries(): array
{
return [
'bash', 'sh', 'python', 'python3', 'php', 'node', 'perl', 'ruby',
'env', 'awk', 'make', 'xargs', 'sudo', 'busybox', 'sed',
'backup.sh',
];
}
/**
* 인터프리터에 인라인 코드/명령을 넘기는 command 목록 (전부 차단).
*
* @return array<string, array{string}>
*/
public static function inlineCodeShellCommandProvider(): array
{
return [
'bash -c id (본체)' => ['bash -c id'],
'sh -c id' => ['sh -c id'],
'python -c 코드' => ['python -c import_os'],
'python3 -c 코드' => ['python3 -c import_os'],
'php -r 코드' => ['php -r phpinfo'],
'node -e 코드' => ['node -e process'],
'perl -e 코드' => ['perl -e system'],
'ruby -e 코드' => ['ruby -e exec'],
'bash -x 스크립트 앞 옵션' => ['bash -x /app/x.sh'],
'python -m 모듈' => ['python -m http'],
];
}
/**
* 인터프리터 + 절대경로 스크립트 (전부 허용 — 범용 크론 회귀 가드).
*
* @return array<string, array{string}>
*/
public static function interpreterScriptShellCommandProvider(): array
{
return [
'python 스크립트' => ['python /app/job.py'],
'python3 스크립트' => ['python3 /app/job.py'],
'node 스크립트' => ['node /app/worker.js'],
'php 스크립트' => ['php /app/legacy.php'],
'bash 스크립트' => ['bash /app/deploy.sh'],
'ruby 스크립트' => ['ruby /app/task.rb'],
'perl 스크립트' => ['perl /app/task.pl'],
'스크립트 + 인자' => ['python /app/job.py --daily --verbose'],
];
}
/**
* 인터프리터 뒤 스크립트 자리가 artisan 인 command 목록 (전부 차단).
*
* @return array<string, array{string}>
*/
public static function artisanBypassShellCommandProvider(): array
{
return [
'php artisan tinker' => ['php artisan'],
'php 절대경로 artisan' => ['php /var/www/html/artisan'],
];
}
/**
* 완전 거부형 실행기 command 목록 (전부 차단, 메타문자 없음).
*
* @return array<string, array{string}>
*/
public static function rejectBinaryShellCommandProvider(): array
{
return [
'env bash' => ['env bash /app/x.sh'],
'make -f' => ['make -f /tmp/Makefile'],
'xargs' => ['xargs id'],
'sudo' => ['sudo id'],
'busybox' => ['busybox sh'],
'sed -e' => ['sed -e s/a/b/ /tmp/input.txt'],
];
}
/**
* 인터프리터 뒤 스크립트 경로 형태 위반 command 목록 (전부 차단).
*
* @return array<string, array{string}>
*/
public static function scriptPathShellCommandProvider(): array
{
return [
'상대경로 (python)' => ['python job.py'],
'상대경로 (php ./)' => ['php ./x.php'],
'트래버설' => ['php /app/../../tmp/x.php'],
'해시 위장' => ['php /app/x.php#foo'],
];
}
/**
* 스크립트 없이 인터프리터만 있는 command 목록 (전부 차단).
*
* @return array<string, array{string}>
*/
public static function noScriptShellCommandProvider(): array
{
return [
'bash 단독' => ['bash'],
'python 단독' => ['python'],
];
}
/**
* 인터프리터가 아닌 일반 스크립트 command 목록 (전부 허용 — 기존 계약 보존).
*
* @return array<string, array{string}>
*/
public static function plainScriptShellCommandProvider(): array
{
return [
'실행 파일 단독' => ['backup.sh'],
'인자 포함' => ['backup.sh --full /var/data'],
'절대경로 인자' => ['/usr/local/bin/backup.sh --full'],
'하이픈 인자 (일반 스크립트는 제한 없음)' => ['backup.sh -C /var/data'],
];
}
}