fix(security): 예약 작업·언어팩·인스톨러의 차단목록을 허용목록으로 전환

KISA 신고포상제 접수 3건(KVE-2026-1677/1678/1679)의 공통 원인은 하나다 —
"위험한 것을 골라 막았고, 목록 밖으로 우회됐다". 세 지점 모두 판정을 뒤집는다.

예약 작업 Artisan (KVE-2026-1679)
차단 9개 / 허용 229개였던 구조를 허용목록으로 바꾸고 명령마다 허용 옵션을 선언한다.
검증 과정에서 보고서보다 짧은 경로를 추가로 발견했다 — 검증기는 preg_split 의 첫
토큰을 명령명으로 봤지만 실행부는 문자열째 넘겼고, parameters 가 비면 Laravel 이
StringInput 으로 재파싱한다. 따옴표 한 쌍이면 차단목록의 tinker 가 그대로 실행됐다
(마커 파일로 확인). 파서를 엄격 형태 하나로 고정하고 실행은 (명령명, 인자배열)로 넘겨
재해석 지점 자체를 없앴다.

언어팩 (KVE-2026-1678)
위험 함수 11개를 정규식으로 나열하던 검사를 토큰 상태기계로 교체해 "번역 배열만"
통과시킨다. 목록에 없는 함수도, 함수 이름을 아예 쓰지 않는 형태도 원리적으로 막힌다.
파일 형식·PHP 위치·심볼릭 링크도 화이트리스트로 뒤집었다. 활성 승격이 require 배선의
필수 조건이므로 설치와 활성화의 권한 경계를 네 설치 경로 전부에 같은 강도로 건다 —
경로별 면제는 곧 우회로가 되기 때문이다. 화면은 운영자의 활성화 권한을 보고
auto_activate 를 실을지 결정하므로 재설치 흐름은 그대로 동작한다.

인스톨러 (KVE-2026-1677)
실행 바이너리 자리에 인자가 들어가는 것을 형태 규칙으로 차단한다. escapeshellarg 는
"하나의 인자" 임만 보장할 뿐 그 인자가 실행 파일인지 스크립트인지는 보장하지 않는다.
판정을 의존성 0 공용 정책으로 모아 진단 API·설치 워커·저장 시점이 같은 규칙을 쓴다.
stat 은 호출하지 않는다 — 접근이 제한된 서버에서 정상 경로가 거부되던 회귀를
재발시키지 않기 위함이다.

검증: 백엔드 392건 + 프론트 7건 green(skip 1, symlink 미지원 환경). PoC 마커로 red 를
먼저 확보하고, 수정 후 stash 역회귀로 다시 red 가 되는 것까지 확인했다.
This commit is contained in:
HeuJung
2026-08-04 17:48:05 +09:00
parent 30867e0e0b
commit 4b6adf4788
45 changed files with 4382 additions and 301 deletions
@@ -3,6 +3,8 @@
namespace Tests\Unit\Support;
use App\Support\ScheduleCommandValidator;
use Illuminate\Support\Facades\Artisan;
use Modules\G7Testing\Console\FakeExtensionScheduleCommand;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
@@ -145,6 +147,10 @@ class ScheduleCommandValidatorTest extends TestCase
* 차단목록에 오른 artisan 명령은 거부한다 (코드 실행형·파괴적 명령).
*
* @param string $command 차단되어야 하는 command
*
* @scenario command_class=denylisted, enforcement_point=validator_unit
*
* @effects denylist_blocks_destructive_and_code_executing_commands
*/
#[Test]
#[DataProvider('deniedArtisanCommandProvider')]
@@ -160,6 +166,10 @@ class ScheduleCommandValidatorTest extends TestCase
* 차단목록에 없는 artisan 명령은 허용한다 (기본 허용).
*
* @param string $command 허용되어야 하는 command
*
* @scenario command_class=core_allowlisted, enforcement_point=validator_unit
*
* @effects allowlisted_maintenance_commands_still_run
*/
#[Test]
#[DataProvider('allowedArtisanCommandProvider')]
@@ -177,10 +187,331 @@ class ScheduleCommandValidatorTest extends TestCase
#[Test]
public function it_extracts_the_artisan_command_name_from_the_first_token(): void
{
$this->assertSame('cache:clear', ScheduleCommandValidator::extractArtisanCommandName(' cache:clear --force '));
$this->assertSame('cache:clear', ScheduleCommandValidator::extractArtisanCommandName(' cache:clear --tags=a '));
$this->assertNull(ScheduleCommandValidator::extractArtisanCommandName(' '));
}
// ========================================================================
// Artisan — 파서 분기 (KVE-2026-1679 추가 발견분)
// ========================================================================
/**
* 검증기가 본 이름과 Symfony 가 해석하는 이름이 갈리는 입력은 거부한다.
*
* 검증기는 `preg_split` 첫 토큰을 명령명으로 봤지만 실행부는 문자열 전체를
* StringInput 으로 재파싱했다. 따옴표·백슬래시·선행 옵션 한 쌍이면 차단목록에 있는
* `tinker` 가 그대로 실행됐다. 판정이 거부라는 것뿐 아니라, 명령명 추출이
* **다른 이름을 돌려주지 않는 것**까지 단언한다 — 이름이 돌아오면 그 이름으로 실행하는
* 호출부가 언제든 다시 생길 수 있다.
*
* @param string $command 파서 분기를 유발하는 command
* @param string $symfonyName Symfony 가 실제로 해석하는 명령명
*
* @scenario command_class=parser_divergent, enforcement_point=validator_unit
*
* @effects parser_divergent_input_is_rejected, extracted_name_equals_executed_name
*/
#[Test]
#[DataProvider('parserDivergenceArtisanCommandProvider')]
public function it_blocks_artisan_commands_whose_parsed_name_diverges(string $command, string $symfonyName): void
{
$this->assertFalse(
ScheduleCommandValidator::isArtisanCommandAllowed($command),
"파서 분기 입력이 통과함: {$command}"
);
$this->assertNull(
ScheduleCommandValidator::extractArtisanCommandName($command),
"정규 형태가 아닌 입력에서 명령명이 반환됨 (Symfony 는 '{$symfonyName}' 으로 해석): {$command}"
);
$this->assertNull(
ScheduleCommandValidator::resolveArtisanCommand($command),
"파서 분기 입력에 실행 계획이 만들어짐: {$command}"
);
}
/**
* 파서 분기 입력의 거부 사유는 형식 오류로 보고된다.
*
* @scenario command_class=malformed, enforcement_point=validator_unit
*
* @effects rejection_reason_is_reported_per_category
*/
#[Test]
public function it_reports_malformed_as_the_reason_for_parser_divergent_input(): void
{
$verdict = ScheduleCommandValidator::inspectArtisanCommand('"tinker" --execute=1');
$this->assertFalse($verdict['allowed']);
$this->assertSame(ScheduleCommandValidator::ARTISAN_REASON_MALFORMED, $verdict['reason']);
$this->assertNull($verdict['name']);
}
// ========================================================================
// Artisan — 허용목록 / 옵션 / 인자
// ========================================================================
/**
* 허용목록에 없는 명령은 무해하더라도 거부한다 (허용목록 전환의 증명).
*
* @param string $command 허용목록 밖 command
*
* @scenario command_class=core_not_allowlisted, enforcement_point=validator_unit
*
* @effects commands_outside_the_allowlist_are_rejected
*/
#[Test]
#[DataProvider('notAllowlistedArtisanCommandProvider')]
public function it_blocks_artisan_commands_outside_the_allowlist(string $command): void
{
$verdict = ScheduleCommandValidator::inspectArtisanCommand($command);
$this->assertFalse($verdict['allowed'], "허용목록 밖 명령이 통과함: {$command}");
$this->assertSame(ScheduleCommandValidator::ARTISAN_REASON_NOT_ALLOWED, $verdict['reason']);
}
/**
* 허용된 명령이라도 선언되지 않은 옵션·위치 인자·단축 옵션은 거부한다.
*
* @param string $command 차단되어야 하는 command
* @param string $reason 기대 거부 사유 코드
*
* @scenario command_class=core_allowlisted, enforcement_point=validator_unit
*
* @effects undeclared_options_and_positional_arguments_are_rejected
*/
#[Test]
#[DataProvider('blockedOptionArtisanCommandProvider')]
public function it_blocks_allowlisted_commands_with_denied_usage(string $command, string $reason): void
{
$verdict = ScheduleCommandValidator::inspectArtisanCommand($command);
$this->assertFalse($verdict['allowed'], "거부되어야 하는 사용법이 통과함: {$command}");
$this->assertSame($reason, $verdict['reason'], "거부 사유가 다름: {$command}");
}
/**
* 허용된 명령은 (명령명, 인자배열) 실행 계획으로 변환된다.
*
* 이 배열을 `Artisan::call()` 에 넘겨야 StringInput 재파싱 경로가 사라진다.
*
* @scenario command_class=core_allowlisted, enforcement_point=validator_unit
*
* @effects allowed_command_resolves_to_name_and_parameter_array
*/
#[Test]
public function it_returns_the_execution_plan_for_allowed_commands(): void
{
$this->assertSame(
['name' => 'queue:work', 'parameters' => ['--stop-when-empty' => true, '--max-time' => '300']],
ScheduleCommandValidator::resolveArtisanCommand('queue:work --stop-when-empty --max-time=300')
);
$this->assertSame(
['name' => 'cache:clear', 'parameters' => []],
ScheduleCommandValidator::resolveArtisanCommand('cache:clear')
);
}
/**
* 차단목록은 허용목록보다 먼저 평가된다 (최종 거부권).
*
* 허용목록에 올라 있어도 차단목록에 있으면 거부되어야 한다 — 확장이 코어 명령 이름을
* 가로채 등록하는 경우를 같은 규칙으로 막기 위함이다.
*
* @scenario command_class=denylisted, enforcement_point=validator_unit
*
* @effects denylist_is_evaluated_before_the_allowlist
*/
#[Test]
public function it_evaluates_the_denylist_before_the_allowlist(): void
{
config(['schedule_security.artisan.denylist' => ['cache:clear']]);
$verdict = ScheduleCommandValidator::inspectArtisanCommand('cache:clear');
$this->assertFalse($verdict['allowed']);
$this->assertSame(ScheduleCommandValidator::ARTISAN_REASON_DENIED, $verdict['reason']);
}
/**
* `make:` 접두사는 41개 명령을 개별 나열 없이 일괄 차단한다.
*
* @scenario command_class=code_generating, enforcement_point=validator_unit
*
* @effects make_prefix_blocks_all_code_generating_commands
*/
#[Test]
public function it_blocks_every_command_matching_a_denylisted_prefix(): void
{
foreach (['make:migration', 'make:command', 'make:controller'] as $command) {
$verdict = ScheduleCommandValidator::inspectArtisanCommand($command);
$this->assertFalse($verdict['allowed'], "접두사 차단이 적용되지 않음: {$command}");
$this->assertSame(ScheduleCommandValidator::ARTISAN_REASON_DENIED, $verdict['reason']);
}
}
// ========================================================================
// Artisan — 확장 소유 명령 계층
// ========================================================================
/**
* 게이트가 켜져 있으면 설치된 확장이 소유한 명령을 자동으로 허용한다.
*
* 이름 패턴이 아니라 등록된 명령 인스턴스의 클래스 네임스페이스로 판정하므로,
* `{확장}:{작업}` 형태를 따르지 않는 확장 명령도 함께 허용된다.
*
* @scenario command_class=extension_owned, enforcement_point=validator_unit
*
* @effects extension_owned_commands_are_allowed_by_namespace
*/
#[Test]
public function it_allows_commands_owned_by_installed_extensions(): void
{
$command = $this->registerExtensionCommand();
$this->assertTrue(
ScheduleCommandValidator::isArtisanCommandAllowed($command),
"확장 소유 명령이 차단됨: {$command}"
);
}
/**
* 게이트를 끄면 확장 소유 명령도 거부된다 (설정으로 좁힐 수 있어야 한다).
*
* @scenario command_class=extension_owned, enforcement_point=validator_unit
*
* @effects extension_gate_can_be_turned_off
*/
#[Test]
public function it_blocks_extension_commands_when_the_gate_is_disabled(): void
{
$command = $this->registerExtensionCommand();
config(['schedule_security.artisan.allow_extension_commands' => false]);
$verdict = ScheduleCommandValidator::inspectArtisanCommand($command);
$this->assertFalse($verdict['allowed'], "게이트를 껐는데 확장 명령이 통과함: {$command}");
$this->assertSame(ScheduleCommandValidator::ARTISAN_REASON_NOT_ALLOWED, $verdict['reason']);
}
/**
* 확장 소유 명령이라도 자기 정의에 없는 옵션은 거부한다.
*
* @scenario command_class=extension_owned, enforcement_point=validator_unit
*
* @effects extension_commands_only_accept_their_own_options
*/
#[Test]
public function it_blocks_undefined_options_on_extension_commands(): void
{
$command = $this->registerExtensionCommand();
$verdict = ScheduleCommandValidator::inspectArtisanCommand($command.' --g7-not-a-real-option');
$this->assertFalse($verdict['allowed']);
$this->assertSame(ScheduleCommandValidator::ARTISAN_REASON_OPTION, $verdict['reason']);
}
/**
* 확장 네임스페이스를 비워도 코어 허용목록은 그대로 동작한다 (계층 독립성).
*
* @scenario command_class=core_allowlisted, enforcement_point=validator_unit
*
* @effects extension_gate_can_be_turned_off
*/
#[Test]
public function it_keeps_core_allowlist_working_without_extension_namespaces(): void
{
config(['schedule_security.artisan.extension_namespaces' => []]);
$this->assertTrue(ScheduleCommandValidator::isArtisanCommandAllowed('cache:clear'));
}
// ========================================================================
// Artisan — config 불변식
// ========================================================================
/**
* 허용목록과 차단목록은 겹치지 않아야 한다.
*
* 겹치면 거부권이 허용목록 항목을 조용히 무력화해, 설정만 보고는 무엇이 실행 가능한지
* 알 수 없게 된다.
*
* @scenario command_class=core_allowlisted, enforcement_point=validator_unit
*
* @effects allowlist_and_denylist_stay_disjoint
*/
#[Test]
public function it_keeps_the_allowlist_and_denylist_disjoint(): void
{
$allowlist = array_map('strtolower', array_keys((array) config('schedule_security.artisan.allowlist')));
$denylist = array_map('strtolower', (array) config('schedule_security.artisan.denylist'));
$this->assertSame([], array_values(array_intersect($allowlist, $denylist)));
foreach ((array) config('schedule_security.artisan.denylist_prefixes') as $prefix) {
foreach ($allowlist as $name) {
$this->assertStringStartsNotWith(
strtolower((string) $prefix),
$name,
"차단 접두사가 허용목록 항목을 무력화함: {$name}"
);
}
}
}
/**
* 허용목록의 명령과 옵션은 실제로 등록된 시그니처에 존재해야 한다.
*
* 오타나 시그니처 변경으로 허용 옵션이 실체와 어긋나면, 정상 운영 스케줄이
* 저장 시점에 거부되거나 반대로 존재하지 않는 옵션이 허용된 것처럼 보인다.
*
* @scenario command_class=core_allowlisted, enforcement_point=validator_unit
*
* @effects allowlist_matches_real_command_signatures
*/
#[Test]
public function it_keeps_the_allowlist_in_sync_with_real_command_signatures(): void
{
$registered = Artisan::all();
foreach ((array) config('schedule_security.artisan.allowlist') as $name => $spec) {
$this->assertArrayHasKey($name, $registered, "허용목록에 없는 명령이 등재됨: {$name}");
$definition = $registered[$name]->getDefinition();
foreach ((array) ($spec['options'] ?? []) as $option) {
$this->assertTrue(
$definition->hasOption($option),
"허용 옵션이 실제 시그니처에 없음: {$name} --{$option}"
);
}
}
}
/**
* 확장 소유 명령 계층 검증에 쓸 명령을 레지스트리에 등록하고 그 이름을 돌려줍니다.
*
* 설치된 확장 중 하나를 골라 쓰면 확장 구성에 따라 테스트가 조용히 skip 되어
* 계층 자체가 검증되지 않는다. `Modules\` 네임스페이스의 픽스처를 실제 레지스트리에
* 등록해 판정 결과를 고정한다 — 판정은 이름이 아니라 등록된 인스턴스의
* 클래스 네임스페이스로 이뤄지므로 이 등록이 실제 확장과 동일한 경로를 탄다.
*/
private function registerExtensionCommand(): string
{
require_once __DIR__.'/../../Fixtures/Extension/FakeExtensionScheduleCommand.php';
$command = new FakeExtensionScheduleCommand;
Artisan::registerCommand($command);
return $command->getName();
}
// ========================================================================
// Data Providers
// ========================================================================
@@ -259,6 +590,19 @@ class ScheduleCommandValidatorTest extends TestCase
'env:decrypt' => ['env:decrypt'],
'schedule:run' => ['schedule:run'],
'빈 명령' => [''],
// 보고서 2단계 체인 — 코드 생성 후 실행
'make:migration — 접두사 차단' => ['make:migration evil --create=x --path=/tmp/g7 --realpath'],
'migrate — 임의 경로 실행' => ['migrate --path=/tmp/g7 --realpath --force'],
'db:seed — 임의 클래스 실행' => ['db:seed --class=Evil'],
'test' => ['test'],
'serve' => ['serve'],
'core:update' => ['core:update'],
'extension:composer-install' => ['extension:composer-install'],
'playwright:issue-token' => ['playwright:issue-token'],
'invoke-serialized-closure' => ['invoke-serialized-closure'],
'stub:publish' => ['stub:publish'],
'vendor:publish' => ['vendor:publish'],
'migrate:refresh — 기존 목록 누락분' => ['migrate:refresh'],
];
}
@@ -273,6 +617,64 @@ class ScheduleCommandValidatorTest extends TestCase
'cache:clear' => ['cache:clear'],
'인자 포함' => ['queue:work --stop-when-empty'],
'inspire' => ['inspire'],
'optimize:clear' => ['optimize:clear'],
'값 있는 옵션' => ['queue:prune-batches --hours=48'],
'옵션 2개' => ['queue:work --stop-when-empty --max-time=300'],
'seo:warmup --layout' => ['seo:warmup --layout=home'],
];
}
/**
* 무해하지만 허용목록에 없어 거부되어야 하는 artisan command 목록.
*
* @return array<string, array{string}>
*/
public static function notAllowlistedArtisanCommandProvider(): array
{
return [
'route:list — 정보 노출' => ['route:list'],
'about — 환경 정보 노출' => ['about'],
'queue:failed' => ['queue:failed'],
'storage:link' => ['storage:link'],
'schedule:list' => ['schedule:list'],
];
}
/**
* 검증기와 Symfony 의 해석이 갈리는 command 목록.
*
* @return array<string, array{string, string}>
*/
public static function parserDivergenceArtisanCommandProvider(): array
{
return [
'큰따옴표 위장' => ['"tinker" --execute=1', 'tinker'],
'작은따옴표 위장' => ["'tinker' --execute=1", 'tinker'],
'백슬래시 위장' => ['tin\\ker --execute=1', 'tinker'],
'선행 옵션으로 명령명 밀어내기' => ['--env=local tinker', 'tinker'],
'선행 옵션 + 마이그레이션 체인' => ['--no-interaction migrate --path=/tmp/x --realpath', 'migrate'],
'따옴표로 허용목록 위장' => ['"cache:clear" --execute=1', 'cache:clear'],
];
}
/**
* 허용목록에 있으나 사용법이 거부되어야 하는 command 목록.
*
* @return array<string, array{string, string}>
*/
public static function blockedOptionArtisanCommandProvider(): array
{
return [
'미허용 옵션 (cache:clear --tags)' => ['cache:clear --tags=a', ScheduleCommandValidator::ARTISAN_REASON_OPTION],
'워커 상주 (--daemon)' => ['queue:work --daemon', ScheduleCommandValidator::ARTISAN_REASON_OPTION],
'안전장치 무력화 (--force)' => ['queue:work --force', ScheduleCommandValidator::ARTISAN_REASON_OPTION],
'정의에 없는 옵션' => ['optimize:clear --whatever', ScheduleCommandValidator::ARTISAN_REASON_OPTION],
'경로 지정 옵션 (model:prune --path)' => ['model:prune --path=/tmp/g7', ScheduleCommandValidator::ARTISAN_REASON_OPTION],
'클래스 지정 옵션 (model:prune --model)' => ['model:prune --model=App\\\\Models\\\\User', ScheduleCommandValidator::ARTISAN_REASON_MALFORMED],
'단축 옵션' => ['cache:clear -v', ScheduleCommandValidator::ARTISAN_REASON_MALFORMED],
'위치 인자' => ['cache:clear redis', ScheduleCommandValidator::ARTISAN_REASON_ARGUMENT],
'중복 옵션' => ['queue:work --tries=1 --tries=9', ScheduleCommandValidator::ARTISAN_REASON_MALFORMED],
'옵션 구분자 단독' => ['cache:clear --', ScheduleCommandValidator::ARTISAN_REASON_MALFORMED],
];
}
}