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

셸 스케줄 검증이 첫 토큰 basename 만 화이트리스트와 대조하고 뒤 인자를
검증하지 않아, 운영자가 bash 를 등록하면 `bash -c id` 로 임의 OS 명령이
실행됐다(KVE-2026-1653). 인터프리터는 범용 크론 용도로 등록을 허용하되,
스크립트 자리(첫 인자)에 형태 규칙을 적용해 인라인 코드·상대경로·트래버설·
artisan 우회·완전거부형(env/awk/make 등)을 저장·실행 양쪽에서 거부한다.
This commit is contained in:
HeuJung
2026-08-17 20:59:42 +09:00
parent 19424877e8
commit 2fb452aa44
12 changed files with 955 additions and 7 deletions
+1
View File
@@ -22,6 +22,7 @@
- 회원 탈퇴·회원 정보 관리 작업이 실패할 때, 응답 메시지에 데이터베이스 오류 원문 같은 내부 시스템 정보가 그대로 노출될 수 있던 문제를 수정했습니다. 이제 이런 경우 사용자에게는 일반 안내 문구만 표시하고, 원본 오류는 서버 로그에만 기록합니다. (sir.kr 커뮤니티의 Xbuilder 님께서 제보해주셨습니다.)
- 만료된 인증 토큰이 사용자 언어(로케일) 판별에서 여전히 유효한 것으로 취급되던 문제를 수정했습니다. 이제 만료된 토큰은 비로그인과 동일하게 처리합니다. (sir.kr 커뮤니티의 Xbuilder 님께서 제보해주셨습니다.)
- 통합 검색이 로그인한 회원을 비회원으로 취급하던 문제를 수정했습니다. 검색 API 가 로그인 상태를 해석하지 않아, 회원 전용 게시판의 읽기 권한이 있어도 검색 결과와 게시판 필터 목록이 비회원 기준으로만 제한되었습니다. 이제 로그인한 회원은 자신이 열람할 수 있는 게시판 범위 그대로 검색됩니다. 비회원 검색은 종전과 동일합니다.
- 셸 예약 작업의 허용 실행 파일 목록에 `bash`·`python` 같은 인터프리터를 등록했을 때, 인터프리터에 인라인 명령(`bash -c ...` 형태)을 실어 임의 명령이 실행되던 문제를 막았습니다. 이제 인터프리터 뒤에는 절대경로 스크립트 파일만 지정할 수 있으며, 인라인 코드 실행·상대경로·경로 조작이 담긴 명령은 저장·실행 양쪽에서 거부됩니다. 스크립트 파일을 주기 실행하는 정상 사용(예: `python /경로/작업.py`)은 그대로 동작합니다. 이 기능은 현재 메뉴에 노출되지 않아 실제 사용 경로가 없습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1653)
### Added
+18 -2
View File
@@ -34,8 +34,24 @@ class AllowedShellCommand implements ValidationRule
return;
}
if (! ScheduleCommandValidator::isShellCommandAllowed($value)) {
$fail(__('validation.schedule_command.shell_not_allowed'));
$verdict = ScheduleCommandValidator::inspectShellCommand($value);
if ($verdict['allowed']) {
return;
}
// 인터프리터에 인라인 코드/명령을 넘기거나 안전하지 않은 스크립트 경로를 지정한
// 경우는 전용 안내로 구분한다 — 화이트리스트 미등재와는 조치 방법이 다르다.
$interpreterReasons = [
ScheduleCommandValidator::SHELL_REASON_INTERPRETER,
ScheduleCommandValidator::SHELL_REASON_INLINE_CODE,
ScheduleCommandValidator::SHELL_REASON_SCRIPT_PATH,
];
$key = in_array($verdict['reason'], $interpreterReasons, true)
? 'validation.schedule_command.shell_interpreter_denied'
: 'validation.schedule_command.shell_not_allowed';
$fail(__($key));
}
}
+163 -5
View File
@@ -47,6 +47,24 @@ class ScheduleCommandValidator
/** 허용 개수를 넘는 위치 인자가 쓰임 */
public const ARTISAN_REASON_ARGUMENT = 'argument_denied';
/** Shell — 게이트 off 또는 화이트리스트 빔 */
public const SHELL_REASON_DISABLED = 'shell_disabled';
/** Shell — 셸 메타문자/제어문자로 토큰화 불가 */
public const SHELL_REASON_METACHARACTER = 'shell_metacharacter';
/** Shell — 첫 토큰 basename 이 화이트리스트에 없음 */
public const SHELL_REASON_NOT_ALLOWED = 'shell_not_allowlisted';
/** Shell — 완전 거부형 인터프리터 / 인자 없는 인터프리터 / 스크립트 자리 artisan */
public const SHELL_REASON_INTERPRETER = 'shell_interpreter';
/** Shell — 인터프리터 뒤 스크립트 자리가 하이픈(인라인 코드 플래그) */
public const SHELL_REASON_INLINE_CODE = 'shell_inline_code';
/** Shell — 스크립트 경로 형태 위반(상대경로 / `..` / `#`) */
public const SHELL_REASON_SCRIPT_PATH = 'shell_script_path';
/** 셸 해석을 유발하는 메타문자 — 하나라도 있으면 인자 배열 실행이 불가하므로 거부 */
private const SHELL_METACHARACTERS = [
'|', '&', ';', '$', '`', '>', '<', '(', ')', '{', '}',
@@ -56,16 +74,35 @@ class ScheduleCommandValidator
/**
* Shell 타입 command 를 실행해도 되는지 판정합니다.
*
* 게이트가 켜져 있고, 화이트리스트가 비어있지 않으며, 메타문자가 없고,
* 첫 토큰의 basename 이 화이트리스트와 완전 일치할 때만 true.
* 판정은 전적으로 `inspectShellCommand()` 에 위임한다 — 사유가 필요 없는 호출부
* (실행부의 마지막 방어선 등)를 위한 bool 형태이며, 두 경로의 결과가 갈릴 여지가 없다.
*
* @param string $command 스케줄에 저장된 shell command 문자열
* @return bool 실행을 허용하면 true
*/
public static function isShellCommandAllowed(string $command): bool
{
return self::inspectShellCommand($command)['allowed'];
}
/**
* Shell command 를 실행 가능한지 판정하고 거부 사유를 함께 돌려줍니다.
*
* 판정 순서:
* 1. 게이트 off 또는 화이트리스트 빔 → SHELL_REASON_DISABLED
* 2. 메타문자/제어문자로 토큰화 불가 → SHELL_REASON_METACHARACTER
* 3. 첫 토큰 basename 이 화이트리스트에 없음 → SHELL_REASON_NOT_ALLOWED
* 4. 첫 토큰이 완전 거부형(reject_binaries) → SHELL_REASON_INTERPRETER (화이트리스트보다 우선)
* 5. 첫 토큰이 스크립트 실행형 인터프리터 → 스크립트 자리 형태 규칙 적용
* 6. 그 외(일반 스크립트, 예 backup.sh) → 통과 (기존 계약 보존)
*
* @param string $command 스케줄에 저장된 shell command 문자열
* @return array{allowed: bool, reason: string|null}
*/
public static function inspectShellCommand(string $command): array
{
if (! (bool) config('schedule_security.shell.enabled', false)) {
return false;
return self::shellVerdict(false, self::SHELL_REASON_DISABLED);
}
$allowed = array_values(array_filter(array_map(
@@ -74,16 +111,137 @@ class ScheduleCommandValidator
)));
if ($allowed === []) {
return false;
return self::shellVerdict(false, self::SHELL_REASON_DISABLED);
}
$tokens = self::tokenize($command);
if ($tokens === null || $tokens === []) {
return self::shellVerdict(false, self::SHELL_REASON_METACHARACTER);
}
$binary = basename($tokens[0]);
if (! in_array($binary, $allowed, true)) {
return self::shellVerdict(false, self::SHELL_REASON_NOT_ALLOWED);
}
$normalized = strtolower($binary);
// 완전 거부형 — 화이트리스트에 등재돼 있어도 실행하지 않는다.
if (in_array($normalized, self::shellList('reject_binaries'), true)) {
return self::shellVerdict(false, self::SHELL_REASON_INTERPRETER);
}
// 스크립트 실행형 인터프리터 — 첫 인자(스크립트 자리)에 형태 규칙을 적용한다.
if (in_array($normalized, self::shellList('script_interpreters'), true)) {
return self::inspectInterpreterScript($tokens);
}
// 일반 스크립트(인터프리터 아님) — 기존 계약 보존.
return self::shellVerdict(true, null);
}
/**
* 인터프리터 뒤 스크립트 자리(첫 인자)가 허용 형태인지 판정합니다.
*
* 인스톨러 선례 `public/install/includes/binary-path-policy.php` 의
* `installer_binary_path_shape_ok`("하이픈 선두 토큰 거부 + `..`/`#` 차단 + 절대경로 요구")
* 를 스케줄 Shell 스크립트 자리에 이식한 것이다.
*
* @param array<int, string> $tokens 토큰 배열 (첫 원소는 인터프리터)
* @return array{allowed: bool, reason: string|null}
*/
private static function inspectInterpreterScript(array $tokens): array
{
// 인자 0개 — 실행할 스크립트가 없다(인터프리터 REPL/셸 기동).
if (! isset($tokens[1]) || $tokens[1] === '') {
return self::shellVerdict(false, self::SHELL_REASON_INTERPRETER);
}
$script = $tokens[1];
// 하이픈 선두 = 인라인 코드/옵션 플래그(`-c`/`-e`/`-r`/`-m`/`-d`/`-x` 등) — 일괄 차단.
if ($script[0] === '-') {
return self::shellVerdict(false, self::SHELL_REASON_INLINE_CODE);
}
// 스크립트 자리 basename 이 artisan 이면 거부 — 코어 Artisan 축은 Artisan 타입으로.
$deniedNames = array_map(
static fn ($name): string => strtolower(trim((string) $name)),
self::shellList('denied_script_names'),
);
if (in_array(strtolower(basename(str_replace('\\', '/', $script))), $deniedNames, true)) {
return self::shellVerdict(false, self::SHELL_REASON_INTERPRETER);
}
// `#` 이후를 주석으로 흘려 확장자를 위장하는 트릭 차단.
if (str_contains($script, '#')) {
return self::shellVerdict(false, self::SHELL_REASON_SCRIPT_PATH);
}
// `..` 세그먼트 차단 (CWD 상대 이동/트래버설).
if (preg_match('#(^|[/\\\\])\.\.([/\\\\]|$)#', $script) === 1) {
return self::shellVerdict(false, self::SHELL_REASON_SCRIPT_PATH);
}
// 스크립트는 절대경로여야 한다 (CWD 에 의존한 실행 차단).
if (! self::isAbsolutePath($script)) {
return self::shellVerdict(false, self::SHELL_REASON_SCRIPT_PATH);
}
// 스크립트 뒤 인자는 스크립트 소유이므로 검사하지 않는다.
return self::shellVerdict(true, null);
}
/**
* 절대경로인지 판정합니다 (POSIX / Windows 드라이브 / UNC).
*
* @param string $token 경로 토큰
* @return bool 절대경로면 true
*/
private static function isAbsolutePath(string $token): bool
{
if ($token === '') {
return false;
}
return in_array(basename($tokens[0]), $allowed, true);
if ($token[0] === '/') {
return true;
}
if (preg_match('#^[A-Za-z]:[\\\\/]#', $token) === 1) {
return true;
}
return str_starts_with($token, '\\\\');
}
/**
* shell 정책 배열 설정을 소문자 문자열 목록으로 정규화합니다.
*
* @param string $key `schedule_security.shell` 하위 키
* @return array<int, string>
*/
private static function shellList(string $key): array
{
return array_values(array_filter(array_map(
static fn ($value): string => strtolower(trim((string) $value)),
(array) config('schedule_security.shell.'.$key, []),
)));
}
/**
* Shell 판정 결과 배열을 만듭니다.
*
* @param bool $allowed 허용 여부
* @param string|null $reason 거부 사유 코드
* @return array{allowed: bool, reason: string|null}
*/
private static function shellVerdict(bool $allowed, ?string $reason = null): array
{
return ['allowed' => $allowed, 'reason' => $reason];
}
/**
+28
View File
@@ -9,10 +9,38 @@ return [
'enabled' => env('SCHEDULE_SHELL_ENABLED', false),
// 허용 실행 파일 basename 목록. 빈 배열이면 모든 shell 명령을 거부한다.
// Shell 타입은 범용 크론탭 대체이므로 운영자가 python/node/php/bash 등
// 인터프리터를 등록해 기존 스케줄러 스크립트를 주기 실행할 수 있다.
'allowed_binaries' => array_values(array_filter(array_map(
'trim',
explode(',', (string) env('SCHEDULE_SHELL_ALLOWED_BINARIES', '')),
))),
// 아래 세 목록은 코드 소유 리터럴이다(env 아님) — 스케줄 권한만 위임받은 관리자가
// 정책을 넓혀 인라인 코드 실행을 복구하지 못하게 한다.
// 스크립트 실행형 인터프리터 — 첫 인자를 "스크립트 파일 경로" 로 강제한다.
// 위험한 것은 인터프리터 자체가 아니라 인터프리터에게 인라인 코드/명령을 넘기는 것이므로
// (하이픈 선두=인라인 코드 플래그 거부, 절대경로 요구, `..`/`#` 금지, basename≠artisan)
// 를 적용해 스크립트 파일 실행만 허용한다.
'script_interpreters' => [
'bash', 'sh', 'dash', 'zsh', 'ksh', 'csh', 'tcsh', 'ash', 'fish',
'php', 'php-cgi', 'python', 'python2', 'python3',
'perl', 'ruby', 'node', 'nodejs', 'lua', 'luajit', 'tclsh',
'rscript', 'pwsh', 'powershell',
],
// 완전 거부형 — 첫 인자가 코드/애플릿/감싼 명령이라 "스크립트 파일" 모델이 성립하지 않는다.
// allowed_binaries 에 등재돼 있어도 실행하지 않는다.
'reject_binaries' => [
'env', 'xargs', 'sudo', 'doas', 'make', 'awk', 'gawk', 'mawk', 'nawk', 'sed', 'find',
'busybox', 'nohup', 'timeout', 'watch', 'nice', 'ionice', 'setsid', 'stdbuf',
'script', 'expect', 'socat', 'nc', 'ncat', 'tee', 'flock', 'chroot', 'unshare',
],
// 인터프리터 뒤 스크립트 자리에 올 수 없는 basename — 코어 Artisan 축 우회 방지.
// `php artisan ...` 는 Artisan 타입으로 유도한다.
'denied_script_names' => ['artisan'],
],
'artisan' => [
@@ -22,6 +22,7 @@
- 레이아웃 저장 시 위험 표현식·외부 리소스 URL 거부 안내(`layout.dangerous_expression`, `layout.external_resource_url`)의 일본어 번역을 추가했습니다.
- 고아 첨부 정리 설정의 검증 메시지와 항목명 일본어 번역을 추가했습니다 (`validation.settings.orphan_*`) — 환경설정 > 업로드의 자동 정리·보존기간을 잘못 저장할 때의 안내가 일본어 로케일에서 항목 이름과 함께 표시됩니다.
- 데이터베이스 백업 미제공 안내 문구의 일본어 번역을 추가했습니다 (`settings.database_backup_unavailable`).
- 셸 예약 작업 허용목록에 인터프리터를 등록했을 때 인라인 명령 실행·안전하지 않은 스크립트 경로를 거부하는 안내 문구의 일본어 번역을 추가했습니다 (`validation.schedule_command.shell_interpreter_denied`).
### Changed
@@ -481,6 +481,7 @@ return [
],
'schedule_command' => [
'shell_not_allowed' => '許可されていないシェルコマンドです。サーバーに登録された実行ファイルのみ使用でき、パイプ(|)·セミコロン(;)などの特殊文字は使用できません。',
'shell_interpreter_denied' => 'インタープリタでインラインコマンド/コードを実行したり、安全でないパスを指定したりすることはできません。インタープリタの後には絶対パスのスクリプトファイルのみを指定してください。',
'artisan_denied' => 'この Artisan コマンドはセキュリティ上の理由からスケジュール実行できません。',
'artisan_not_allowlisted' => 'スケジュール実行が許可された Artisan コマンドではありません。キャッシュ削除·キュー処理などのメンテナンスコマンドのみ登録できます。',
'artisan_malformed' => 'Artisan コマンドの形式が正しくありません。引用符·バックスラッシュ·短縮オプション(-v)は使用できず、"コマンド名 --オプション[=値]" の形式のみ登録できます。',
+1
View File
@@ -515,6 +515,7 @@ return [
// 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.',
'shell_interpreter_denied' => 'You cannot run inline commands/code through an interpreter or point it at an unsafe path. After an interpreter, specify only an absolute-path script file.',
'artisan_denied' => 'This Artisan command cannot be run as a schedule for security reasons.',
'artisan_not_allowlisted' => 'This Artisan command is not allowed to run on a schedule. Only maintenance commands such as cache clearing and queue processing can be registered.',
'artisan_malformed' => 'The Artisan command format is invalid. Quotes, backslashes, and short options (-v) are not allowed; use "command --option[=value]" only.',
+1
View File
@@ -514,6 +514,7 @@ return [
// 서버에서 실행되는 스케줄 command(쉘·Artisan) 검증 메시지
'schedule_command' => [
'shell_not_allowed' => '허용되지 않은 쉘 명령입니다. 서버에 등록된 실행 파일만 사용할 수 있으며, 파이프(|)·세미콜론(;) 등 특수문자는 쓸 수 없습니다.',
'shell_interpreter_denied' => '인터프리터로 인라인 명령/코드를 실행하거나 안전하지 않은 경로를 지정할 수 없습니다. 인터프리터 뒤에는 절대경로 스크립트 파일만 지정하세요.',
'artisan_denied' => '이 Artisan 명령은 보안상 스케줄로 실행할 수 없습니다.',
'artisan_not_allowlisted' => '예약 실행이 허용된 Artisan 명령이 아닙니다. 캐시 정리·큐 처리 등 유지보수 명령만 등록할 수 있습니다.',
'artisan_malformed' => 'Artisan 명령 형식이 올바르지 않습니다. 따옴표·역슬래시·단축 옵션(-v)은 쓸 수 없고 "명령명 --옵션[=값]" 형태만 등록할 수 있습니다.',
@@ -516,10 +516,224 @@ class ScheduleCommandGuardTest extends TestCase
$response->assertJsonValidationErrors('command');
}
// ========================================================================
// Shell 인터프리터 정책 (KVE-2026-1653)
// ========================================================================
/**
* 게이트를 켜고 `bash` 등 인터프리터를 화이트리스트에 등록해도, 인터프리터에
* 인라인 명령/코드를 넘기는 저장 요청은 422 로 거부된다.
*
* KVE-2026-1653 본체: `bash` 를 화이트리스트에 등록하면 `bash -c id` 가 통과해
* 임의 OS 명령이 실행됐다. 이제 첫 인자가 하이픈(코드 플래그)이면 저장 시점에 거부된다.
*
* @param string $command 인라인 코드/명령 command
*
* @scenario command_class=inline_code, enforcement_point=store
*
* @effects inline_code_flags_rejected
*/
#[DataProvider('interpreterInlineCodeProvider')]
public function test_store_rejects_interpreter_inline_code_even_when_allowlisted(string $command): void
{
$this->enableInterpreterShell();
$response = $this->authRequest()->postJson(route('api.admin.schedules.store'), [
'name' => '인라인 코드 시도',
'type' => ScheduleType::Shell->value,
'command' => $command,
'expression' => '* * * * *',
'frequency' => 'everyMinute',
]);
$response->assertStatus(422, "인라인 코드가 저장됨: {$command}");
$response->assertJsonValidationErrors('command');
}
/**
* 인터프리터 + 절대경로 스크립트 실행은 게이트를 켜고 등록하면 201 로 허용된다
* (범용 크론 기능 회귀 가드).
*
* @param string $command 스크립트 파일 실행 command
*
* @scenario command_class=script_ok, enforcement_point=store
*
* @effects interpreter_script_files_still_run
*/
#[DataProvider('interpreterScriptProvider')]
public function test_store_allows_interpreter_running_absolute_script(string $command): void
{
$this->enableInterpreterShell();
$response = $this->authRequest()->postJson(route('api.admin.schedules.store'), [
'name' => '정상 스크립트 스케줄',
'type' => ScheduleType::Shell->value,
'command' => $command,
'expression' => '* * * * *',
'frequency' => 'everyMinute',
]);
$response->assertStatus(201, "정상 스크립트 실행이 거부됨: {$command}");
}
/**
* `php artisan ...` 를 shell 로 실행하려는 저장 요청은 422 로 거부된다 (Artisan 축 우회 방지).
*
* @param string $command artisan 우회 command
*
* @scenario command_class=artisan_bypass, enforcement_point=store
*
* @effects artisan_via_shell_rejected
*/
#[DataProvider('artisanBypassShellProvider')]
public function test_store_rejects_artisan_via_shell(string $command): void
{
$this->enableInterpreterShell();
$response = $this->authRequest()->postJson(route('api.admin.schedules.store'), [
'name' => 'artisan 우회 시도',
'type' => ScheduleType::Shell->value,
'command' => $command,
'expression' => '* * * * *',
'frequency' => 'everyMinute',
]);
$response->assertStatus(422, "artisan 우회가 저장됨: {$command}");
$response->assertJsonValidationErrors('command');
}
/**
* 완전 거부형 실행기(env/make/xargs/sudo/busybox)는 화이트리스트에 등재돼도 422 로 거부된다.
*
* @param string $command 완전 거부형 command
*
* @scenario command_class=reject_binary, enforcement_point=store
*
* @effects reject_binaries_blocked_even_when_allowlisted
*/
#[DataProvider('rejectBinaryShellProvider')]
public function test_store_rejects_reject_binaries_even_when_allowlisted(string $command): void
{
$this->enableInterpreterShell();
$response = $this->authRequest()->postJson(route('api.admin.schedules.store'), [
'name' => '완전 거부형 시도',
'type' => ScheduleType::Shell->value,
'command' => $command,
'expression' => '* * * * *',
'frequency' => 'everyMinute',
]);
$response->assertStatus(422, "완전 거부형이 저장됨: {$command}");
$response->assertJsonValidationErrors('command');
}
/**
* 기존 shell 스케줄의 command 만 인라인 코드로 바꾸는 update 요청도 거부된다.
*
* @scenario command_class=inline_code, enforcement_point=update
*
* @effects update_falls_back_to_the_stored_type_when_the_request_omits_it
*/
public function test_update_rejects_interpreter_inline_code_without_type_in_request(): void
{
$this->enableInterpreterShell();
$schedule = Schedule::create([
'name' => '기존 스크립트 스케줄',
'type' => ScheduleType::Shell,
'command' => 'bash /app/deploy.sh',
'expression' => '* * * * *',
'is_active' => true,
]);
$response = $this->authRequest()->putJson(
route('api.admin.schedules.update', ['schedule' => $schedule->id]),
['command' => 'bash -c id']
);
$response->assertStatus(422);
$response->assertJsonValidationErrors('command');
}
/**
* 게이트를 켜고 `bash` 를 등록한 상태에서 DB 에 직접 심어진 `bash -c id` 는
* 실행 시점에 차단된다 (마지막 방어선 — 저장 검증 우회 값 방어).
*
* @scenario command_class=inline_code, enforcement_point=run
*
* @effects run_time_last_line_of_defense_blocks_inline_code
*/
public function test_run_blocks_interpreter_inline_code_injected_directly(): void
{
$this->enableInterpreterShell();
Process::fake();
$schedule = Schedule::create([
'name' => 'DB 직접 주입 — 인라인 코드',
'type' => ScheduleType::Shell,
'command' => 'bash -c id',
'expression' => '* * * * *',
'is_active' => true,
]);
$this->assertScheduleRunIsBlocked($schedule);
Process::assertNothingRan();
}
/**
* 슈퍼 관리자도 인터프리터 인라인 코드는 저장할 수 없다 (권한 분기 무관 동일 차단).
*
* @scenario command_class=inline_code, enforcement_point=store, caller_role=super_admin
*
* @effects inline_code_flags_rejected
*/
public function test_store_rejects_interpreter_inline_code_for_super_admin(): void
{
$this->enableInterpreterShell();
// 검증 계층까지 도달하도록 스케줄 권한을 갖춘 계정에 is_super 를 부여한다 —
// 슈퍼 관리자도 인라인 코드는 저장 단계에서 막힘을 증명하기 위함(권한 게이트가 아니라 검증).
$superAdmin = $this->createScheduleOperator();
$superAdmin->forceFill(['is_super' => true])->save();
$superToken = $superAdmin->createToken('super-token')->plainTextToken;
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$superToken,
'Accept' => 'application/json',
])->postJson(route('api.admin.schedules.store'), [
'name' => '슈퍼 관리자 인라인 코드',
'type' => ScheduleType::Shell->value,
'command' => 'bash -c id',
'expression' => '* * * * *',
'frequency' => 'everyMinute',
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors('command');
}
// ========================================================================
// Helpers
// ========================================================================
/**
* shell 게이트를 켜고 인터프리터·완전거부형을 화이트리스트에 등록합니다.
*
* 인터프리터를 **등재한 상태**로 검증해 "등재돼도 인라인코드/artisan/완전거부형은
* 차단됨" 을 증명한다 (범용 크론 용도로 인터프리터 등록은 정상 운영이다).
*/
private function enableInterpreterShell(): void
{
config([
'schedule_security.shell.enabled' => true,
'schedule_security.shell.allowed_binaries' => [
'bash', 'sh', 'python', 'python3', 'php', 'node', 'perl', 'ruby',
'env', 'make', 'xargs', 'sudo', 'busybox',
],
]);
}
/**
* 파서 분기 페이로드가 실행되었을 때 생성되는 마커 파일 경로.
*
@@ -736,4 +950,66 @@ class ScheduleCommandGuardTest extends TestCase
'SEO 레이아웃 지정' => ['seo:warmup --layout=home'],
];
}
/**
* 인터프리터에 인라인 코드/명령을 넘기는 command 목록 (전부 차단).
*
* @return array<string, array{string}>
*/
public static function interpreterInlineCodeProvider(): array
{
return [
'bash -c id (본체)' => ['bash -c id'],
'sh -c id' => ['sh -c id'],
'python -c 코드' => ['python -c import_os'],
'php -r 코드' => ['php -r phpinfo'],
'node -e 코드' => ['node -e process'],
'perl -e 코드' => ['perl -e system'],
'bash -x 스크립트 앞 옵션' => ['bash -x /app/x.sh'],
];
}
/**
* 인터프리터 + 절대경로 스크립트 command 목록 (전부 허용).
*
* @return array<string, array{string}>
*/
public static function interpreterScriptProvider(): array
{
return [
'python 스크립트' => ['python /app/job.py'],
'php 스크립트' => ['php /app/legacy.php'],
'node 스크립트' => ['node /app/worker.js'],
'스크립트 + 인자' => ['python /app/job.py --daily'],
];
}
/**
* shell 로 artisan 을 실행하려는 command 목록 (전부 차단).
*
* @return array<string, array{string}>
*/
public static function artisanBypassShellProvider(): array
{
return [
'php artisan' => ['php artisan'],
'php 절대경로 artisan' => ['php /var/www/html/artisan'],
];
}
/**
* 완전 거부형 실행기 command 목록 (전부 차단, 메타문자 없음).
*
* @return array<string, array{string}>
*/
public static function rejectBinaryShellProvider(): array
{
return [
'env bash' => ['env bash /app/x.sh'],
'make -f' => ['make -f /tmp/Makefile'],
'xargs' => ['xargs id'],
'sudo' => ['sudo id'],
'busybox' => ['busybox sh'],
];
}
}
@@ -2,6 +2,7 @@
namespace Tests\Unit\Services;
use App\Enums\ScheduleResultStatus;
use App\Enums\ScheduleType;
use App\Extension\HookManager;
use App\Models\Schedule;
@@ -9,6 +10,8 @@ use App\Models\ScheduleHistory;
use App\Services\ScheduleService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Process;
use Illuminate\Validation\ValidationException;
use Tests\TestCase;
/**
@@ -87,6 +90,45 @@ class ScheduleServiceTest extends TestCase
$this->assertSame(1, $result->total());
}
// ========================================================================
// runSchedule() - 실행 직전 방어선 (KVE-2026-1653)
// ========================================================================
/**
* 게이트를 켜고 `bash` 를 등록한 상태라도, DB 에 직접 심어진 `bash -c id` 는
* 실행 직전 방어선에서 차단된다 (저장 검증을 우회해 들어온 값 방어).
*
* @scenario command_class=inline_code, enforcement_point=run
*
* @effects run_time_last_line_of_defense_blocks_inline_code
*/
public function test_run_schedule_blocks_interpreter_inline_code(): void
{
config([
'schedule_security.shell.enabled' => true,
'schedule_security.shell.allowed_binaries' => ['bash'],
]);
Process::fake();
$schedule = Schedule::create([
'name' => 'DB 직접 주입 — 인라인 코드',
'type' => ScheduleType::Shell,
'command' => 'bash -c id',
'expression' => '* * * * *',
'is_active' => true,
]);
try {
$this->scheduleService->runSchedule($schedule);
$this->fail('인라인 코드 스케줄이 실행됨');
} catch (ValidationException) {
// 실행 직전 방어선이 차단 — 아래에서 미실행·실패 기록을 확인한다
}
Process::assertNothingRan();
$this->assertSame(ScheduleResultStatus::Failed, $schedule->fresh()->last_result);
}
// ========================================================================
// delete() - 관계 레코드 명시적 삭제 검증
// ========================================================================
@@ -139,6 +139,220 @@ class ScheduleCommandValidatorTest extends TestCase
$this->assertNull(ScheduleCommandValidator::tokenizeShellCommand(' '));
}
// ========================================================================
// Shell — 인터프리터 정책 (KVE-2026-1653)
// ========================================================================
/**
* 인터프리터에 인라인 코드/명령을 넘기는 형태는 화이트리스트에 등재돼 있어도 차단한다.
*
* KVE-2026-1653 본체: 운영자가 `bash` 를 화이트리스트에 등록하면 `bash -c id` 가
* 통과해 임의 OS 명령이 실행됐다. 스크립트 자리(첫 인자)가 하이픈이면 인라인 코드 플래그다.
*
* @param string $command 인라인 코드/명령을 넘기는 command
*
* @scenario command_class=inline_code, enforcement_point=validator_unit
*
* @effects inline_code_flags_rejected
*/
#[Test]
#[DataProvider('inlineCodeShellCommandProvider')]
public function it_blocks_inline_code_through_interpreters_even_when_allowlisted(string $command): void
{
$this->enableShell(self::interpreterBinaries());
$verdict = ScheduleCommandValidator::inspectShellCommand($command);
$this->assertFalse($verdict['allowed'], "인라인 코드가 통과함: {$command}");
$this->assertSame(ScheduleCommandValidator::SHELL_REASON_INLINE_CODE, $verdict['reason']);
}
/**
* 인터프리터 + 절대경로 스크립트 파일은 통과한다 (범용 크론 기능 회귀 가드).
*
* @param string $command 스크립트 파일을 실행하는 command
*
* @scenario command_class=script_ok, enforcement_point=validator_unit
*
* @effects interpreter_script_files_still_run
*/
#[Test]
#[DataProvider('interpreterScriptShellCommandProvider')]
public function it_allows_interpreters_running_absolute_path_scripts(string $command): void
{
$this->enableShell(self::interpreterBinaries());
$verdict = ScheduleCommandValidator::inspectShellCommand($command);
$this->assertTrue($verdict['allowed'], "정상 스크립트 실행이 차단됨: {$command}");
$this->assertNull($verdict['reason']);
}
/**
* 인터프리터 뒤 스크립트 자리가 artisan 이면 차단한다 (Artisan 축 우회 방지).
*
* @param string $command artisan 을 스크립트로 넘기는 command
*
* @scenario command_class=artisan_bypass, enforcement_point=validator_unit
*
* @effects artisan_via_shell_rejected
*/
#[Test]
#[DataProvider('artisanBypassShellCommandProvider')]
public function it_blocks_artisan_as_the_interpreter_script(string $command): void
{
$this->enableShell(self::interpreterBinaries());
$verdict = ScheduleCommandValidator::inspectShellCommand($command);
$this->assertFalse($verdict['allowed'], "artisan 우회가 통과함: {$command}");
$this->assertSame(ScheduleCommandValidator::SHELL_REASON_INTERPRETER, $verdict['reason']);
}
/**
* 완전 거부형 실행기는 화이트리스트에 등재돼 있어도 차단한다.
*
* env/awk/make/xargs/sudo/busybox 등은 첫 인자가 코드·감싼 명령이라 "스크립트 파일"
* 모델이 성립하지 않는다.
*
* @param string $command 완전 거부형 command
*
* @scenario command_class=reject_binary, enforcement_point=validator_unit
*
* @effects reject_binaries_blocked_even_when_allowlisted
*/
#[Test]
#[DataProvider('rejectBinaryShellCommandProvider')]
public function it_blocks_reject_binaries_even_when_allowlisted(string $command): void
{
$this->enableShell(self::interpreterBinaries());
$verdict = ScheduleCommandValidator::inspectShellCommand($command);
$this->assertFalse($verdict['allowed'], "완전 거부형이 통과함: {$command}");
$this->assertSame(ScheduleCommandValidator::SHELL_REASON_INTERPRETER, $verdict['reason']);
}
/**
* 인터프리터 뒤 스크립트 경로 형태 위반(상대경로 / `..` / `#`)은 차단한다.
*
* @param string $command 경로 형태 위반 command
*
* @scenario command_class=script_path, enforcement_point=validator_unit
*
* @effects script_path_traversal_rejected
*/
#[Test]
#[DataProvider('scriptPathShellCommandProvider')]
public function it_blocks_unsafe_interpreter_script_paths(string $command): void
{
$this->enableShell(self::interpreterBinaries());
$verdict = ScheduleCommandValidator::inspectShellCommand($command);
$this->assertFalse($verdict['allowed'], "안전하지 않은 경로가 통과함: {$command}");
$this->assertSame(ScheduleCommandValidator::SHELL_REASON_SCRIPT_PATH, $verdict['reason']);
}
/**
* 스크립트 없이 인터프리터만 등록하면(REPL/셸 기동) 차단한다.
*
* @param string $command 인자 없는 인터프리터 command
*
* @scenario command_class=no_script, enforcement_point=validator_unit
*
* @effects interpreter_without_script_rejected
*/
#[Test]
#[DataProvider('noScriptShellCommandProvider')]
public function it_blocks_interpreters_without_a_script(string $command): void
{
$this->enableShell(self::interpreterBinaries());
$verdict = ScheduleCommandValidator::inspectShellCommand($command);
$this->assertFalse($verdict['allowed'], "인자 없는 인터프리터가 통과함: {$command}");
$this->assertSame(ScheduleCommandValidator::SHELL_REASON_INTERPRETER, $verdict['reason']);
}
/**
* 인터프리터가 아닌 일반 스크립트는 종전처럼 통과한다 (기존 계약 보존 회귀 가드).
*
* @param string $command 일반 스크립트 command
*
* @scenario command_class=legit_plain_script, enforcement_point=validator_unit
*
* @effects plain_scripts_still_run_without_path_restrictions
*/
#[Test]
#[DataProvider('plainScriptShellCommandProvider')]
public function it_still_allows_plain_non_interpreter_scripts(string $command): void
{
$this->enableShell(['backup.sh']);
$verdict = ScheduleCommandValidator::inspectShellCommand($command);
$this->assertTrue($verdict['allowed'], "일반 스크립트가 차단됨: {$command}");
$this->assertNull($verdict['reason']);
}
/**
* 게이트가 꺼져 있으면 disabled 사유를 돌려준다.
*
* @scenario command_class=inline_code, enforcement_point=validator_unit
*
* @effects rejection_reason_is_reported_per_category
*/
#[Test]
public function it_reports_disabled_when_the_shell_gate_is_off(): void
{
config([
'schedule_security.shell.enabled' => false,
'schedule_security.shell.allowed_binaries' => ['bash'],
]);
$verdict = ScheduleCommandValidator::inspectShellCommand('bash /app/x.sh');
$this->assertFalse($verdict['allowed']);
$this->assertSame(ScheduleCommandValidator::SHELL_REASON_DISABLED, $verdict['reason']);
}
/**
* 화이트리스트 밖 명령은 not_allowlisted 사유를 돌려준다.
*
* @scenario command_class=inline_code, enforcement_point=validator_unit
*
* @effects rejection_reason_is_reported_per_category
*/
#[Test]
public function it_reports_not_allowlisted_for_unregistered_binaries(): void
{
$this->enableShell(['backup.sh']);
$verdict = ScheduleCommandValidator::inspectShellCommand('bash -c id');
$this->assertFalse($verdict['allowed']);
$this->assertSame(ScheduleCommandValidator::SHELL_REASON_NOT_ALLOWED, $verdict['reason']);
}
/**
* 메타문자가 섞인 명령은 metacharacter 사유를 돌려준다.
*
* @scenario command_class=reject_binary, enforcement_point=validator_unit
*
* @effects rejection_reason_is_reported_per_category
*/
#[Test]
public function it_reports_metacharacter_for_commands_with_shell_metacharacters(): void
{
$this->enableShell(self::interpreterBinaries());
$verdict = ScheduleCommandValidator::inspectShellCommand('awk \'BEGIN{system("id")}\'');
$this->assertFalse($verdict['allowed']);
$this->assertSame(ScheduleCommandValidator::SHELL_REASON_METACHARACTER, $verdict['reason']);
}
// ========================================================================
// Artisan — 차단목록
// ========================================================================
@@ -677,4 +891,137 @@ class ScheduleCommandValidatorTest extends TestCase
'옵션 구분자 단독' => ['cache:clear --', ScheduleCommandValidator::ARTISAN_REASON_MALFORMED],
];
}
// ========================================================================
// Data Providers — Shell 인터프리터 정책 (KVE-2026-1653)
// ========================================================================
/**
* 인터프리터 정책 테스트에서 화이트리스트에 등재하는 실행 파일 목록.
*
* 인터프리터·완전거부형을 **등재한 상태**로 판정해 "등재돼도 차단됨" 을 증명한다.
*
* @return array<int, string>
*/
private static function interpreterBinaries(): array
{
return [
'bash', 'sh', 'python', 'python3', 'php', 'node', 'perl', 'ruby',
'env', 'awk', 'make', 'xargs', 'sudo', 'busybox', 'sed',
'backup.sh',
];
}
/**
* 인터프리터에 인라인 코드/명령을 넘기는 command 목록 (전부 차단).
*
* @return array<string, array{string}>
*/
public static function inlineCodeShellCommandProvider(): array
{
return [
'bash -c id (본체)' => ['bash -c id'],
'sh -c id' => ['sh -c id'],
'python -c 코드' => ['python -c import_os'],
'python3 -c 코드' => ['python3 -c import_os'],
'php -r 코드' => ['php -r phpinfo'],
'node -e 코드' => ['node -e process'],
'perl -e 코드' => ['perl -e system'],
'ruby -e 코드' => ['ruby -e exec'],
'bash -x 스크립트 앞 옵션' => ['bash -x /app/x.sh'],
'python -m 모듈' => ['python -m http'],
];
}
/**
* 인터프리터 + 절대경로 스크립트 (전부 허용 — 범용 크론 회귀 가드).
*
* @return array<string, array{string}>
*/
public static function interpreterScriptShellCommandProvider(): array
{
return [
'python 스크립트' => ['python /app/job.py'],
'python3 스크립트' => ['python3 /app/job.py'],
'node 스크립트' => ['node /app/worker.js'],
'php 스크립트' => ['php /app/legacy.php'],
'bash 스크립트' => ['bash /app/deploy.sh'],
'ruby 스크립트' => ['ruby /app/task.rb'],
'perl 스크립트' => ['perl /app/task.pl'],
'스크립트 + 인자' => ['python /app/job.py --daily --verbose'],
];
}
/**
* 인터프리터 뒤 스크립트 자리가 artisan 인 command 목록 (전부 차단).
*
* @return array<string, array{string}>
*/
public static function artisanBypassShellCommandProvider(): array
{
return [
'php artisan tinker' => ['php artisan'],
'php 절대경로 artisan' => ['php /var/www/html/artisan'],
];
}
/**
* 완전 거부형 실행기 command 목록 (전부 차단, 메타문자 없음).
*
* @return array<string, array{string}>
*/
public static function rejectBinaryShellCommandProvider(): array
{
return [
'env bash' => ['env bash /app/x.sh'],
'make -f' => ['make -f /tmp/Makefile'],
'xargs' => ['xargs id'],
'sudo' => ['sudo id'],
'busybox' => ['busybox sh'],
'sed -e' => ['sed -e s/a/b/ /tmp/input.txt'],
];
}
/**
* 인터프리터 뒤 스크립트 경로 형태 위반 command 목록 (전부 차단).
*
* @return array<string, array{string}>
*/
public static function scriptPathShellCommandProvider(): array
{
return [
'상대경로 (python)' => ['python job.py'],
'상대경로 (php ./)' => ['php ./x.php'],
'트래버설' => ['php /app/../../tmp/x.php'],
'해시 위장' => ['php /app/x.php#foo'],
];
}
/**
* 스크립트 없이 인터프리터만 있는 command 목록 (전부 차단).
*
* @return array<string, array{string}>
*/
public static function noScriptShellCommandProvider(): array
{
return [
'bash 단독' => ['bash'],
'python 단독' => ['python'],
];
}
/**
* 인터프리터가 아닌 일반 스크립트 command 목록 (전부 허용 — 기존 계약 보존).
*
* @return array<string, array{string}>
*/
public static function plainScriptShellCommandProvider(): array
{
return [
'실행 파일 단독' => ['backup.sh'],
'인자 포함' => ['backup.sh --full /var/data'],
'절대경로 인자' => ['/usr/local/bin/backup.sh --full'],
'하이픈 인자 (일반 스크립트는 제한 없음)' => ['backup.sh -C /var/data'],
];
}
}
@@ -0,0 +1,76 @@
feature: 예약 작업 Shell 인터프리터 인라인 코드 우회 차단
description: |
Shell 타입 스케줄은 범용 크론탭 대체다 — 그누보드 밖의 python/node/php/bash 스크립트를
주기 실행할 수 있어야 한다. 그래서 인터프리터를 통째로 막지 않고 화이트리스트에 등록할 수
있게 둔다. 위험한 것은 인터프리터 자체가 아니라 인터프리터에게 인라인 코드/명령 문자열을
넘기는 것이다.
종전 `isShellCommandAllowed()` 는 첫 토큰 basename 만 화이트리스트와 대조하고 뒤 인자를
검증하지 않았다. 운영자가 `bash` 를 등록하면 `bash -c id` 가 통과해 임의 OS 명령이 실행됐다
(KVE-2026-1653). 이제 첫 토큰이 스크립트 실행형 인터프리터면 첫 인자(스크립트 자리)에
형태 규칙을 적용한다 — 하이픈 선두(인라인 코드 플래그) 거부, 절대경로 요구, `..`/`#` 금지,
basename≠artisan. 완전 거부형(env/awk/make/xargs/sudo/busybox 등)은 등재돼 있어도 거부한다.
인터프리터가 아닌 일반 스크립트(backup.sh 등)는 기존 계약대로 통과한다.
저장 시점(store/update Rule)과 실행 시점(runSchedule 마지막 방어선)이 같은 판정 함수
`inspectShellCommand()` 를 공유한다 — 한쪽만 고치면 DB 직접 수정이 우회로가 된다.
axes:
command_class: [inline_code, script_ok, artisan_bypass, reject_binary, script_path, no_script, legit_plain_script]
enforcement_point: [validator_unit, store, update, run]
cross_product: |
command_class × enforcement_point 이 핵심이다. 저장 시점과 실행 시점이 같은 판정 함수를
공유해야 하고(한쪽만 고치면 DB 직접 수정이 우회로가 된다), 명령 부류마다 거부 사유가 달라
운영자에게 보이는 메시지가 갈린다.
전 부류는 validator_unit 에서 전수 검증한다. store/update/run 은 같은 판정 함수를 호출할
뿐 별도 분기가 없으므로, HTTP·실행 계층에서는 대표 부류만 검증한다:
- store: inline_code(차단)·script_ok(허용)·artisan_bypass(차단)·reject_binary(차단)
- update: 저장된 타입 폴백 배선이 고유 축이므로 대표 1건(inline_code)
- run: 마지막 방어선의 대표 1건(inline_code) — Feature + Service 두 계층에서
caller_role(위임관리자 / 슈퍼관리자)은 별도 axis 로 두지 않는다. 판정은 권한과 무관한
단일 함수라 결과가 갈리지 않기 때문이다 — 슈퍼관리자도 인라인 코드를 저장할 수 없음을
대표 1건으로 고정한다(effects inline_code_flags_rejected).
exclusions:
- { command_class: script_path, enforcement_point: store, reason: "경로 형태 판정은 validator 단일 지점 — HTTP 계층은 같은 Rule 을 호출할 뿐 별도 분기 없음" }
- { command_class: no_script, enforcement_point: store, reason: "동일 — 판정 분기 없음" }
- { command_class: legit_plain_script, enforcement_point: store, reason: "동일 — 정상 스크립트 허용은 script_ok(store)가 대표" }
- { command_class: script_ok, enforcement_point: update, reason: "update 고유 축은 '요청에 type 없을 때 저장 타입 폴백' 배선 — 판정은 store 와 같은 Rule, 대표 1건(inline_code)으로 검증" }
- { command_class: artisan_bypass, enforcement_point: update, reason: "동일 — 대표 1건으로 검증" }
- { command_class: reject_binary, enforcement_point: update, reason: "동일 — 대표 1건으로 검증" }
- { command_class: script_path, enforcement_point: update, reason: "동일 — 대표 1건으로 검증" }
- { command_class: no_script, enforcement_point: update, reason: "동일 — 대표 1건으로 검증" }
- { command_class: legit_plain_script, enforcement_point: update, reason: "동일 — 대표 1건으로 검증" }
- { command_class: script_ok, enforcement_point: run, reason: "실행 시점 방어선의 대표는 inline_code — 허용 스크립트 실행 회귀는 store(script_ok)가 대표" }
- { command_class: artisan_bypass, enforcement_point: run, reason: "저장 시점에 거부되어 DB 에 남지 않는다. 실행 시점 대표는 inline_code" }
- { command_class: reject_binary, enforcement_point: run, reason: "동일 — 실행 시점 대표는 inline_code" }
- { command_class: script_path, enforcement_point: run, reason: "동일 — 실행 시점 대표는 inline_code" }
- { command_class: no_script, enforcement_point: run, reason: "동일 — 실행 시점 대표는 inline_code" }
- { command_class: legit_plain_script, enforcement_point: run, reason: "정상 스크립트 실행 자체는 기존 shell 실행 테스트가 이미 고정 — 본 기능의 신규 축이 아님" }
effects:
# 판정 — 인터프리터 정책
- inline_code_flags_rejected
- interpreter_script_files_still_run
- artisan_via_shell_rejected
- reject_binaries_blocked_even_when_allowlisted
- script_path_traversal_rejected
- interpreter_without_script_rejected
- plain_scripts_still_run_without_path_restrictions
- rejection_reason_is_reported_per_category
# 엔드투엔드 — 저장·실행 두 지점
- update_falls_back_to_the_stored_type_when_the_request_omits_it
- run_time_last_line_of_defense_blocks_inline_code
# E2E axis 없음: 판정은 전부 서버측이고 레이아웃 JSON·컴포넌트 TSX·엔진 TS 변경이 없다.
# 예약 작업 관리 화면은 config/core.php 에서 is_active=false 로 비노출 상태라 브라우저 경로
# 자체가 존재하지 않는다 (frontend-change-requires-e2e 룰의 대상이 아님).
test_files:
- tests/Unit/Support/ScheduleCommandValidatorTest.php
- tests/Feature/Security/ScheduleCommandGuardTest.php
- tests/Unit/Services/ScheduleServiceTest.php