KISA 신고포상제 접수 3건(KVE-2026-1677/1678/1679)의 공통 원인은 하나다 — "위험한 것을 골라 막았고, 목록 밖으로 우회됐다". 세 지점 모두 판정을 뒤집는다. 예약 작업 Artisan (KVE-2026-1679) 차단 9개 / 허용 229개였던 구조를 허용목록으로 바꾸고 명령마다 허용 옵션을 선언한다. 검증 과정에서 보고서보다 짧은 경로를 추가로 발견했다 — 검증기는 preg_split 의 첫 토큰을 명령명으로 봤지만 실행부는 문자열째 넘겼고, parameters 가 비면 Laravel 이 StringInput 으로 재파싱한다. 따옴표 한 쌍이면 차단목록의 tinker 가 그대로 실행됐다 (마커 파일로 확인). 파서를 엄격 형태 하나로 고정하고 실행은 (명령명, 인자배열)로 넘겨 재해석 지점 자체를 없앴다. 언어팩 (KVE-2026-1678) 위험 함수 11개를 정규식으로 나열하던 검사를 토큰 상태기계로 교체해 "번역 배열만" 통과시킨다. 목록에 없는 함수도, 함수 이름을 아예 쓰지 않는 형태도 원리적으로 막힌다. 파일 형식·PHP 위치·심볼릭 링크도 화이트리스트로 뒤집었다. 활성 승격이 require 배선의 필수 조건이므로 설치와 활성화의 권한 경계를 네 설치 경로 전부에 같은 강도로 건다 — 경로별 면제는 곧 우회로가 되기 때문이다. 화면은 운영자의 활성화 권한을 보고 auto_activate 를 실을지 결정하므로 재설치 흐름은 그대로 동작한다. 인스톨러 (KVE-2026-1677) 실행 바이너리 자리에 인자가 들어가는 것을 형태 규칙으로 차단한다. escapeshellarg 는 "하나의 인자" 임만 보장할 뿐 그 인자가 실행 파일인지 스크립트인지는 보장하지 않는다. 판정을 의존성 0 공용 정책으로 모아 진단 API·설치 워커·저장 시점이 같은 규칙을 쓴다. stat 은 호출하지 않는다 — 접근이 제한된 서버에서 정상 경로가 거부되던 회귀를 재발시키지 않기 위함이다. 검증: 백엔드 392건 + 프론트 7건 green(skip 1, symlink 미지원 환경). PoC 마커로 red 를 먼저 확보하고, 수정 후 stash 역회귀로 다시 red 가 되는 것까지 확인했다.
511 lines
18 KiB
PHP
511 lines
18 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Contracts\Repositories\ScheduleHistoryRepositoryInterface;
|
|
use App\Contracts\Repositories\ScheduleRepositoryInterface;
|
|
use App\Enums\ScheduleResultStatus;
|
|
use App\Enums\ScheduleTriggerType;
|
|
use App\Enums\ScheduleType;
|
|
use App\Exceptions\ScheduleExecutionException;
|
|
use App\Extension\HookManager;
|
|
use App\Models\Schedule;
|
|
use App\Models\ScheduleHistory;
|
|
use App\Support\OutboundUrlValidator;
|
|
use App\Support\ScheduleCommandValidator;
|
|
use Exception;
|
|
use Illuminate\Database\Eloquent\Collection;
|
|
use Illuminate\Pagination\LengthAwarePaginator;
|
|
use Illuminate\Support\Facades\Artisan;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Process;
|
|
use Illuminate\Validation\ValidationException;
|
|
use Symfony\Component\Console\Output\BufferedOutput;
|
|
|
|
class ScheduleService
|
|
{
|
|
public function __construct(
|
|
private ScheduleRepositoryInterface $scheduleRepository,
|
|
private ScheduleHistoryRepositoryInterface $historyRepository
|
|
) {}
|
|
|
|
/**
|
|
* 필터링된 스케줄 목록을 페이지네이션으로 조회합니다.
|
|
*
|
|
* @param array $filters 필터 조건 배열
|
|
* @return LengthAwarePaginator 페이지네이션된 스케줄 목록
|
|
*/
|
|
public function getPaginatedSchedules(array $filters = []): LengthAwarePaginator
|
|
{
|
|
return $this->scheduleRepository->getPaginatedSchedules($filters);
|
|
}
|
|
|
|
/**
|
|
* 새로운 스케줄을 생성합니다.
|
|
*
|
|
* @param array $data 스케줄 생성 데이터
|
|
* @return Schedule 생성된 스케줄 모델
|
|
*
|
|
* @throws ValidationException 생성 실패 시
|
|
*/
|
|
public function create(array $data): Schedule
|
|
{
|
|
try {
|
|
// before_create 훅 실행
|
|
HookManager::doAction('core.schedule.before_create', $data);
|
|
$data = HookManager::applyFilters('core.schedule.filter_create_data', $data);
|
|
|
|
$schedule = $this->scheduleRepository->create($data);
|
|
|
|
// after_create 훅 실행
|
|
HookManager::doAction('core.schedule.after_create', $schedule);
|
|
|
|
return $schedule->fresh('creator');
|
|
} catch (Exception $e) {
|
|
if ($e instanceof ValidationException) {
|
|
throw $e;
|
|
}
|
|
|
|
throw ValidationException::withMessages([
|
|
'general' => [__('schedule.create_failed', ['error' => $e->getMessage()])],
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 기존 스케줄 정보를 수정합니다.
|
|
*
|
|
* @param Schedule $schedule 수정할 스케줄 모델
|
|
* @param array $data 수정할 데이터
|
|
* @return Schedule 수정된 스케줄 모델
|
|
*
|
|
* @throws ValidationException 수정 실패 시
|
|
*/
|
|
public function update(Schedule $schedule, array $data): Schedule
|
|
{
|
|
try {
|
|
// before_update 훅 실행
|
|
HookManager::doAction('core.schedule.before_update', $schedule, $data);
|
|
|
|
// 스냅샷 캡처 (ChangeDetector용)
|
|
$snapshot = $schedule->toArray();
|
|
|
|
$data = HookManager::applyFilters('core.schedule.filter_update_data', $data, $schedule);
|
|
|
|
$this->scheduleRepository->update($schedule, $data);
|
|
|
|
// after_update 훅 실행 (스냅샷 전달)
|
|
HookManager::doAction('core.schedule.after_update', $schedule, $snapshot);
|
|
|
|
return $schedule->fresh('creator');
|
|
} catch (Exception $e) {
|
|
if ($e instanceof ValidationException) {
|
|
throw $e;
|
|
}
|
|
|
|
throw ValidationException::withMessages([
|
|
'general' => [__('schedule.update_failed', ['error' => $e->getMessage()])],
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 스케줄을 삭제합니다.
|
|
*
|
|
* @param Schedule $schedule 삭제할 스케줄 모델
|
|
* @return bool 삭제 성공 여부
|
|
*
|
|
* @throws ValidationException 삭제 실패 시
|
|
*/
|
|
public function delete(Schedule $schedule): bool
|
|
{
|
|
try {
|
|
// before_delete 훅 실행
|
|
HookManager::doAction('core.schedule.before_delete', $schedule);
|
|
|
|
// 실행 이력 삭제 (명시적 삭제 - CASCADE 의존 금지)
|
|
$schedule->histories()->delete();
|
|
|
|
$result = $this->scheduleRepository->delete($schedule);
|
|
|
|
// after_delete 훅 실행
|
|
HookManager::doAction('core.schedule.after_delete', $schedule->id);
|
|
|
|
return $result;
|
|
} catch (Exception $e) {
|
|
if ($e instanceof ValidationException) {
|
|
throw $e;
|
|
}
|
|
|
|
throw ValidationException::withMessages([
|
|
'general' => [__('schedule.delete_failed', ['error' => $e->getMessage()])],
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* ID로 스케줄 상세 정보를 조회합니다.
|
|
*
|
|
* @param int $id 스케줄 ID
|
|
* @return Schedule|null 스케줄 모델 또는 null
|
|
*/
|
|
public function getScheduleById(int $id): ?Schedule
|
|
{
|
|
return $this->scheduleRepository->findById($id);
|
|
}
|
|
|
|
/**
|
|
* 스케줄을 즉시 실행합니다.
|
|
*
|
|
* @param Schedule $schedule 실행할 스케줄
|
|
* @param int|null $triggeredBy 수동 실행자 ID
|
|
* @return ScheduleHistory 실행 이력
|
|
*
|
|
* @throws ValidationException 실행 실패 시
|
|
*/
|
|
public function runSchedule(Schedule $schedule, ?int $triggeredBy = null): ScheduleHistory
|
|
{
|
|
// before_run 훅 실행
|
|
HookManager::doAction('core.schedule.before_run', $schedule);
|
|
|
|
// 실행 이력 생성
|
|
$history = $this->historyRepository->create([
|
|
'schedule_id' => $schedule->id,
|
|
'started_at' => now(),
|
|
'status' => ScheduleResultStatus::Running,
|
|
'trigger_type' => $triggeredBy ? ScheduleTriggerType::Manual : ScheduleTriggerType::Scheduled,
|
|
'triggered_by' => $triggeredBy,
|
|
]);
|
|
|
|
// 스케줄 상태 업데이트
|
|
$this->scheduleRepository->update($schedule, [
|
|
'last_result' => ScheduleResultStatus::Running,
|
|
'last_run_at' => now(),
|
|
]);
|
|
|
|
try {
|
|
$startTime = microtime(true);
|
|
$memoryStart = memory_get_usage();
|
|
|
|
// 타입별 실행
|
|
$result = match ($schedule->type) {
|
|
ScheduleType::Artisan => $this->executeArtisanCommand($schedule),
|
|
ScheduleType::Shell => $this->executeShellCommand($schedule),
|
|
ScheduleType::Url => $this->executeUrlCall($schedule),
|
|
};
|
|
|
|
$endTime = microtime(true);
|
|
$memoryEnd = memory_get_usage();
|
|
|
|
// 이력 업데이트 (성공)
|
|
$this->historyRepository->update($history, [
|
|
'ended_at' => now(),
|
|
'duration' => (int) ($endTime - $startTime),
|
|
'status' => ScheduleResultStatus::Success,
|
|
'exit_code' => 0,
|
|
'memory_usage' => $memoryEnd - $memoryStart,
|
|
'output' => $result['output'] ?? null,
|
|
]);
|
|
|
|
// 스케줄 상태 업데이트 (다음 실행 시각은 모델이 계산, 영속화는 Repository 경유)
|
|
$schedule->last_result = ScheduleResultStatus::Success;
|
|
$schedule->calculateNextRunAt();
|
|
$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);
|
|
|
|
return $history->fresh();
|
|
|
|
} catch (Exception $e) {
|
|
// 이력 업데이트 (실패)
|
|
$this->historyRepository->update($history, [
|
|
'ended_at' => now(),
|
|
'status' => ScheduleResultStatus::Failed,
|
|
'exit_code' => $e->getCode() ?: 1,
|
|
'error_output' => $e->getMessage(),
|
|
]);
|
|
|
|
// 스케줄 상태 업데이트 (다음 실행 시각은 모델이 계산, 영속화는 Repository 경유)
|
|
$schedule->last_result = ScheduleResultStatus::Failed;
|
|
$schedule->calculateNextRunAt();
|
|
$this->scheduleRepository->update($schedule, [
|
|
'last_result' => ScheduleResultStatus::Failed,
|
|
'next_run_at' => $schedule->next_run_at,
|
|
]);
|
|
|
|
// 거부 사유는 error 레벨 한 줄만 보는 알림 파이프라인에서도 보여야 한다 —
|
|
// 별도 warning 로그에만 남기면 그 경로에서는 "왜 막혔는지" 가 유실된다.
|
|
Log::error('Schedule execution failed', [
|
|
'schedule_id' => $schedule->id,
|
|
'type' => $schedule->type->value,
|
|
'command' => $schedule->command,
|
|
'reason' => $e instanceof ScheduleExecutionException ? $e->reasonCode : null,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
throw ValidationException::withMessages([
|
|
'general' => [__('schedule.run_failed', ['error' => $e->getMessage()])],
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Artisan 커맨드를 실행합니다.
|
|
*
|
|
* @param Schedule $schedule 스케줄
|
|
* @return array 실행 결과
|
|
*
|
|
* @throws ScheduleExecutionException 차단목록에 걸린 명령일 때
|
|
*/
|
|
private function executeArtisanCommand(Schedule $schedule): array
|
|
{
|
|
// 저장 시점 검증 도입 이전 데이터나 DB 직접 수정으로 들어온 값을 방어한다
|
|
// (isUrlCallAllowed 와 동일 사유 — 실행 직전이 마지막 방어선).
|
|
$verdict = ScheduleCommandValidator::inspectArtisanCommand($schedule->command);
|
|
|
|
if (! $verdict['allowed']) {
|
|
// 업그레이드 점검 스텝을 두지 않으므로, 거부 사유는 이 로그가 유일한 운영 진단 통로다.
|
|
Log::warning('Schedule artisan command rejected', [
|
|
'schedule_id' => $schedule->id,
|
|
'command' => $schedule->command,
|
|
'reason' => $verdict['reason'],
|
|
]);
|
|
|
|
throw ScheduleExecutionException::artisanNotAllowed($verdict['reason']);
|
|
}
|
|
|
|
// 문자열이 아닌 (명령명, 인자배열) 로 넘겨 StringInput 재파싱을 경유하지 않는다 —
|
|
// parameters 가 비어 있으면 Laravel 이 문자열 전체를 다시 파싱해
|
|
// 검증한 이름과 다른 명령이 실행될 수 있다.
|
|
Artisan::call($verdict['name'], $verdict['parameters'], new BufferedOutput);
|
|
|
|
return [
|
|
'output' => Artisan::output(),
|
|
'exit_code' => 0,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 쉘 명령을 실행합니다.
|
|
*
|
|
* @param Schedule $schedule 스케줄
|
|
* @return array 실행 결과
|
|
*
|
|
* @throws ScheduleExecutionException 차단목록에 걸렸거나 실행이 실패했을 때
|
|
*/
|
|
private function executeShellCommand(Schedule $schedule): array
|
|
{
|
|
// 저장 시점 검증 도입 이전 데이터나 DB 직접 수정으로 들어온 값을 방어한다
|
|
// (isUrlCallAllowed 와 동일 사유 — 실행 직전이 마지막 방어선).
|
|
$arguments = ScheduleCommandValidator::tokenizeShellCommand($schedule->command);
|
|
|
|
if ($arguments === null || ! ScheduleCommandValidator::isShellCommandAllowed($schedule->command)) {
|
|
throw ScheduleExecutionException::shellNotAllowed();
|
|
}
|
|
|
|
$timeout = $schedule->timeout ?? 60;
|
|
|
|
// 문자열이 아닌 인자 배열로 넘겨 `/bin/sh -c` 를 경유하지 않는다 —
|
|
// 파이프·`;`·`$()` 같은 셸 메타문자가 명령으로 해석될 여지를 없앤다.
|
|
$result = Process::timeout($timeout)->run($arguments);
|
|
|
|
if ($result->failed()) {
|
|
throw ScheduleExecutionException::shellCommandFailed($result->errorOutput(), $result->exitCode());
|
|
}
|
|
|
|
return [
|
|
'output' => $result->output(),
|
|
'exit_code' => $result->exitCode(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* URL 호출을 실행합니다.
|
|
*
|
|
* @param Schedule $schedule 스케줄
|
|
* @return array 실행 결과
|
|
*
|
|
* @throws ScheduleExecutionException 내부망 URL 이거나 호출이 실패했을 때
|
|
*/
|
|
private function executeUrlCall(Schedule $schedule): array
|
|
{
|
|
// 저장된 URL 이 그대로 서버의 outbound 목적지가 되므로, 실행 직전에도 내부망 주소를 차단한다
|
|
// (저장 시점 검증 도입 이전 데이터나 DB 직접 수정으로 들어온 값 방어).
|
|
if (! $this->isUrlCallAllowed($schedule->command)) {
|
|
throw ScheduleExecutionException::urlNotPublic();
|
|
}
|
|
|
|
$timeout = $schedule->timeout ?? 30;
|
|
|
|
$response = Http::timeout($timeout)->get($schedule->command);
|
|
|
|
if ($response->failed()) {
|
|
throw ScheduleExecutionException::httpRequestFailed($response->status());
|
|
}
|
|
|
|
return [
|
|
'output' => 'HTTP Status: '.$response->status()."\n".$response->body(),
|
|
'exit_code' => 0,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 스케줄의 URL 호출이 허용되는 목적지인지 판정합니다.
|
|
*
|
|
* 사설 IP·localhost 등 내부 네트워크 주소는 기본 차단하되, 사내 엔드포인트를 주기 호출하는
|
|
* 정당한 운영을 위해 `security.allow_internal_outbound_urls` 로 허용할 수 있습니다.
|
|
* 허용하더라도 userinfo 위장·비 HTTP scheme 은 계속 거부합니다.
|
|
*
|
|
* @param string $url 스케줄에 저장된 호출 URL
|
|
* @return bool 호출을 허용하면 true
|
|
*/
|
|
private function isUrlCallAllowed(string $url): bool
|
|
{
|
|
$options = ['schemes' => ['http', 'https']];
|
|
|
|
if ((bool) g7_core_settings('security.allow_internal_outbound_urls', false)) {
|
|
return OutboundUrlValidator::isStructurallySafeUrl($url, $options);
|
|
}
|
|
|
|
return OutboundUrlValidator::isPublicHttpUrl($url, $options);
|
|
}
|
|
|
|
/**
|
|
* 스케줄을 복제합니다.
|
|
*
|
|
* @param Schedule $schedule 복제할 스케줄
|
|
* @return Schedule 복제된 스케줄
|
|
*/
|
|
public function duplicate(Schedule $schedule): Schedule
|
|
{
|
|
return $this->scheduleRepository->duplicate($schedule);
|
|
}
|
|
|
|
/**
|
|
* 여러 스케줄의 상태를 일괄 업데이트합니다.
|
|
*
|
|
* @param array $ids 스케줄 ID 배열
|
|
* @param bool $isActive 활성화 여부
|
|
* @return array 업데이트 결과
|
|
*/
|
|
public function bulkUpdateStatus(array $ids, bool $isActive): array
|
|
{
|
|
// before_bulk_update 훅 실행
|
|
HookManager::doAction('core.schedule.before_bulk_update', $ids, $isActive);
|
|
|
|
$updatedCount = DB::transaction(function () use ($ids, $isActive) {
|
|
return $this->scheduleRepository->bulkUpdateStatus($ids, $isActive);
|
|
});
|
|
|
|
// after_bulk_update 훅 실행
|
|
HookManager::doAction('core.schedule.after_bulk_update', $ids, $isActive, $updatedCount);
|
|
|
|
return [
|
|
'updated_count' => $updatedCount,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 여러 스케줄을 일괄 삭제합니다.
|
|
*
|
|
* @param array $ids 스케줄 ID 배열
|
|
* @return array 삭제 결과
|
|
*/
|
|
public function bulkDelete(array $ids): array
|
|
{
|
|
// before_bulk_delete 훅 실행
|
|
HookManager::doAction('core.schedule.before_bulk_delete', $ids);
|
|
|
|
$deletedCount = DB::transaction(function () use ($ids) {
|
|
return $this->scheduleRepository->bulkDelete($ids);
|
|
});
|
|
|
|
// after_bulk_delete 훅 실행
|
|
HookManager::doAction('core.schedule.after_bulk_delete', $ids, $deletedCount);
|
|
|
|
return [
|
|
'deleted_count' => $deletedCount,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 스케줄 관련 통계 정보를 조회합니다.
|
|
*
|
|
* @return array 스케줄 통계 데이터
|
|
*/
|
|
public function getStatistics(): array
|
|
{
|
|
return $this->scheduleRepository->getStatistics();
|
|
}
|
|
|
|
/**
|
|
* 특정 스케줄의 실행 이력을 페이지네이션으로 조회합니다.
|
|
*
|
|
* @param int $scheduleId 스케줄 ID
|
|
* @param array $filters 필터 조건 배열
|
|
* @return LengthAwarePaginator 페이지네이션된 이력 목록
|
|
*/
|
|
public function getHistory(int $scheduleId, array $filters = []): LengthAwarePaginator
|
|
{
|
|
return $this->historyRepository->getPaginatedByScheduleId($scheduleId, $filters);
|
|
}
|
|
|
|
/**
|
|
* 실행 이력을 삭제합니다.
|
|
*
|
|
* @param int $historyId 이력 ID
|
|
* @return bool 삭제 성공 여부
|
|
*/
|
|
public function deleteHistory(int $historyId): bool
|
|
{
|
|
$history = $this->historyRepository->findById($historyId);
|
|
|
|
if (! $history) {
|
|
return false;
|
|
}
|
|
|
|
return $this->historyRepository->delete($history);
|
|
}
|
|
|
|
/**
|
|
* 여러 실행 이력을 일괄 삭제합니다.
|
|
*
|
|
* @param array $ids 이력 ID 배열
|
|
* @return array 삭제 결과
|
|
*/
|
|
public function bulkDeleteHistory(array $ids): array
|
|
{
|
|
$deletedCount = $this->historyRepository->bulkDelete($ids);
|
|
|
|
return [
|
|
'deleted_count' => $deletedCount,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 활성화된 스케줄들을 조회합니다.
|
|
*
|
|
* @return Collection 활성화된 스케줄 컬렉션
|
|
*/
|
|
public function getActiveSchedules(): Collection
|
|
{
|
|
return $this->scheduleRepository->getActiveSchedules();
|
|
}
|
|
|
|
/**
|
|
* 실행 대기 중인 스케줄들을 조회합니다.
|
|
*
|
|
* @return Collection 실행 대기 중인 스케줄 컬렉션
|
|
*/
|
|
public function getDueSchedules(): Collection
|
|
{
|
|
return $this->scheduleRepository->getDueSchedules();
|
|
}
|
|
}
|