Files
Gnuboard7/app/Console/Commands/SecurityAuditDependenciesCommand.php
T
HeuJung 4d60b6cefb fix(core,templates): 의존성 보안 버전 정비 및 감사 사각 해소
제보(https://github.com/gnuboard/g7/issues/126)는 "패키지가 낡았다" 로 읽히지만
본질은 감사 신호가 거짓이었다는 데 있다. 실행에 쓰이는 axios·laravel-echo·pusher-js
가 devDependencies 에 있어 `npm audit --omit=dev` 가 이들을 통째로 건너뛰었고,
동봉 자산은 어떤 잠금파일에도 없어 감사 도구가 원리상 볼 수 없었다. 버전만 올리면
같은 사각이 남으므로 분류를 바로잡고 전수 점검 통로와 회귀 차단을 함께 둔다.

사용자 템플릿 정화기를 3.4.14 로, 코드 편집기를 monaco 0.56.0 으로 올렸다. 0.56.0 은
`editor.main` 의 `m` export 제거와 JSON 언어 서비스 이전이라는 파괴 변경을 얹었으므로
로더를 1.7.0 으로 올리고 진입점을 버전 관용적으로 해석한다.

상류가 못 고치는 잔여(monaco 내장 정화기 3.4.8)는 overrides 로 가리지 않고 하한과
사유를 기록해 그 아래로 내려가는 것만 막는다. 가리면 감사만 조용해지고 브라우저가
받는 자산은 그대로여서, 이 이슈가 고치려던 거짓 신호를 재생산한다.
2026-08-29 00:08:48 +09:00

483 lines
17 KiB
PHP

<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Symfony\Component\Process\Process;
/**
* 의존성 취약점 전수 점검 커맨드 (#126)
*
* 이 저장소는 잠금파일이 60개 가까이 흩어져 있다 — 코어 하나와 확장 수십 개가 각자
* `package-lock.json` / `composer.lock` 을 갖는다. 루트만 감사하면 확장 전부가 사각이
* 되는데, 그 사각은 오류도 경고도 남기지 않는다: 취약한 라이브러리가 정상 동작하는 것이
* 유일한 증상이다.
*
* 동봉(vendored) 자산은 어떤 잠금파일에도 없어 `npm audit` 이 **원리상** 볼 수 없다.
* 그 축은 판정하지 않고 목록으로 노출한다 — 사람이 봐야 하는 대상이기 때문이다.
*
* 종료 코드: 취약점이 하나라도 있으면 비-0. 비-0 은 "커맨드 실행 실패" 가 아니라
* **조치 대상 발견 신호**다.
*/
class SecurityAuditDependenciesCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'security:audit-dependencies
{--json : 결과를 JSON 으로 출력합니다}
{--npm-only : npm 잠금파일만 점검합니다}
{--composer-only : composer 잠금파일만 점검합니다}';
/**
* The console command description.
*/
protected $description = '저장소의 모든 잠금파일(npm·composer) 운영 의존성 취약점을 점검합니다 (취약점 발견 시 비-0 종료)';
/** 잠금파일 탐색에서 제외할 경로 조각 */
private const EXCLUDED_SEGMENTS = ['node_modules', 'vendor', '_pending', 'storage'];
/** 개별 감사 프로세스 제한 시간(초) */
private const PROCESS_TIMEOUT = 300;
/**
* Execute the console command.
*
* @return int 취약점이 없으면 0, 있으면 1
*/
public function handle(): int
{
$npmOnly = (bool) $this->option('npm-only');
$composerOnly = (bool) $this->option('composer-only');
$results = [];
if (! $composerOnly) {
foreach ($this->lockFiles('package-lock.json') as $lock) {
$results[] = $this->auditNpm($lock);
}
}
if (! $npmOnly) {
foreach ($this->lockFiles('composer.lock') as $lock) {
$results[] = $this->auditComposer($lock);
}
}
$vendored = $this->vendoredAssets();
if ($this->option('json')) {
$this->line((string) json_encode([
'results' => $results,
'vendored_assets' => $vendored,
'totals' => $this->totals($results),
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
return $this->totals($results)['vulnerable'] > 0 ? self::FAILURE : self::SUCCESS;
}
$this->renderTable($results);
$this->renderVendored($vendored);
$totals = $this->totals($results);
$this->line('');
if ($totals['unmeasurable'] > 0) {
$this->warn(sprintf(
'점검 불가 %d건 — 감사 도구가 실행되지 않았습니다. "취약점 없음" 과 다릅니다.',
$totals['unmeasurable']
));
}
if ($totals['vulnerable'] === 0) {
$this->info(sprintf(
'운영 의존성 취약점 0건 (감사 %d개 / 대상 없음 %d개 / 발견 %d개 잠금파일).',
$totals['audited'],
$totals['empty'],
$totals['checked']
));
return $totals['unmeasurable'] > 0 ? self::FAILURE : self::SUCCESS;
}
$this->error(sprintf(
'%d개 잠금파일에서 운영 의존성 취약점이 발견되었습니다 (총 %d건).',
$totals['vulnerable'],
$totals['advisories']
));
return self::FAILURE;
}
/**
* 저장소에서 잠금파일을 전수 탐색합니다.
*
* @param string $fileName 잠금파일 이름
* @return array<int, string> 잠금파일 절대 경로 목록
*/
private function lockFiles(string $fileName): array
{
$root = base_path();
$found = [];
// 제외 디렉토리는 **내려가기 전에** 잘라낸다. 파일 단계에서 거르면 `node_modules`
// 수만 개를 전부 훑고 나서 버리게 되어 한 번 실행에 수 분이 걸린다.
$directories = new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS);
$pruned = new \RecursiveCallbackFilterIterator(
$directories,
function (\SplFileInfo $entry): bool {
if (! $entry->isDir()) {
return true;
}
return ! in_array($entry->getFilename(), self::EXCLUDED_SEGMENTS, true);
}
);
foreach (new \RecursiveIteratorIterator($pruned) as $entry) {
if (! $entry->isFile() || $entry->getFilename() !== $fileName) {
continue;
}
$found[] = $entry->getPathname();
}
sort($found);
return $found;
}
/**
* npm 잠금파일 하나를 감사합니다.
*
* @param string $lockPath 잠금파일 절대 경로
* @return array{target: string, kind: string, status: string, advisories: int, severities: array<string,int>, detail: string}
*/
private function auditNpm(string $lockPath): array
{
if ($this->isEmptyLock($lockPath, ['packages', 'dependencies'])) {
return $this->emptyLock($lockPath, 'npm');
}
$dir = dirname($lockPath);
$process = $this->runProcess(['npm', 'audit', '--omit=dev', '--json'], $dir);
$decoded = json_decode($process['output'], true);
if (! is_array($decoded) || ! isset($decoded['metadata']['vulnerabilities'])) {
return $this->unmeasurable($lockPath, 'npm', trim($process['error']) ?: 'npm audit 출력을 해석할 수 없습니다.');
}
$severities = array_filter(
array_map('intval', $decoded['metadata']['vulnerabilities']),
static fn (int $count, string $key): bool => $count > 0 && $key !== 'total',
ARRAY_FILTER_USE_BOTH
);
$total = (int) ($decoded['metadata']['vulnerabilities']['total'] ?? 0);
return [
'target' => $this->relative($lockPath),
'kind' => 'npm',
'status' => $total > 0 ? 'vulnerable' : 'clean',
'advisories' => $total,
'severities' => $severities,
'detail' => $total > 0 ? implode(', ', array_keys($decoded['vulnerabilities'] ?? [])) : '',
];
}
/**
* composer 잠금파일 하나를 감사합니다.
*
* @param string $lockPath 잠금파일 절대 경로
* @return array{target: string, kind: string, status: string, advisories: int, severities: array<string,int>, detail: string}
*/
private function auditComposer(string $lockPath): array
{
if ($this->isEmptyLock($lockPath, ['packages', 'packages-dev'])) {
return $this->emptyLock($lockPath, 'composer');
}
$dir = dirname($lockPath);
$process = $this->runProcess(['composer', 'audit', '--locked', '--no-dev', '--format=json'], $dir);
$decoded = json_decode($this->firstJsonObject($process['output']), true);
if (! is_array($decoded) || ! array_key_exists('advisories', $decoded)) {
return $this->unmeasurable($lockPath, 'composer', trim($process['error']) ?: 'composer audit 출력을 해석할 수 없습니다.');
}
$severities = [];
$total = 0;
foreach ($decoded['advisories'] as $advisories) {
foreach ((array) $advisories as $advisory) {
$total++;
$severity = (string) ($advisory['severity'] ?? 'unknown');
$severities[$severity] = ($severities[$severity] ?? 0) + 1;
}
}
return [
'target' => $this->relative($lockPath),
'kind' => 'composer',
'status' => $total > 0 ? 'vulnerable' : 'clean',
'advisories' => $total,
'severities' => $severities,
'detail' => $total > 0 ? implode(', ', array_keys($decoded['advisories'])) : '',
];
}
/**
* 잠금파일이 의존성을 하나도 담고 있지 않은지 판정합니다.
*
* 확장 대부분은 composer 의존성이 없어 빈 잠금을 갖는다. 그 상태에서 감사 도구는
* "설치본이 없다" 는 오류를 내는데, 그것을 점검 불가로 세면 조치할 것이 없는 대상이
* 24건씩 경고로 쌓여 진짜 점검 불가를 가린다.
*
* @param string $lockPath 잠금파일 절대 경로
* @param array<int, string> $keys 의존성이 담기는 최상위 키
* @return bool
*/
private function isEmptyLock(string $lockPath, array $keys): bool
{
$decoded = json_decode((string) @file_get_contents($lockPath), true);
if (! is_array($decoded)) {
return false;
}
foreach ($keys as $key) {
$entries = $decoded[$key] ?? [];
if (! is_array($entries)) {
continue;
}
// npm 잠금의 `packages` 는 루트 자신을 빈 문자열 키로 항상 담는다 — 그것만 있으면
// 빈 것이다. composer 잠금의 `packages` 는 리스트라 키가 정수이므로 건드리지 않는다.
$meaningful = array_filter(
array_keys($entries),
static fn ($name): bool => $name !== ''
);
if ($meaningful !== []) {
return false;
}
}
return true;
}
/**
* 의존성이 없는 잠금파일을 기록합니다.
*
* @param string $lockPath 잠금파일 절대 경로
* @param string $kind npm|composer
* @return array{target: string, kind: string, status: string, advisories: int, severities: array<string,int>, detail: string}
*/
private function emptyLock(string $lockPath, string $kind): array
{
return [
'target' => $this->relative($lockPath),
'kind' => $kind,
'status' => 'empty',
'advisories' => 0,
'severities' => [],
'detail' => '의존성 없음',
];
}
/**
* 감사 도구를 실행할 수 없었던 대상을 기록합니다.
*
* "점검 불가" 는 "취약점 없음" 과 다르다 — 뭉뚱그리면 운영자가 정상으로 읽는다.
*
* @param string $lockPath 잠금파일 절대 경로
* @param string $kind npm|composer
* @param string $reason 사유
* @return array{target: string, kind: string, status: string, advisories: int, severities: array<string,int>, detail: string}
*/
private function unmeasurable(string $lockPath, string $kind, string $reason): array
{
return [
'target' => $this->relative($lockPath),
'kind' => $kind,
'status' => 'unmeasurable',
'advisories' => 0,
'severities' => [],
'detail' => mb_substr($reason, 0, 200),
];
}
/**
* 외부 명령을 실행합니다 ( 과 충돌하지 않도록 이름을 분리).
*
* @param array<int, string> $command 명령과 인자
* @param string $cwd 작업 디렉토리
* @return array{output: string, error: string, exit: int}
*/
private function runProcess(array $command, string $cwd): array
{
$process = new Process($command, $cwd, null, null, self::PROCESS_TIMEOUT);
try {
$process->run();
} catch (\Throwable $e) {
return ['output' => '', 'error' => $e->getMessage(), 'exit' => -1];
}
return [
'output' => $process->getOutput(),
'error' => $process->getErrorOutput(),
'exit' => (int) $process->getExitCode(),
];
}
/**
* 앞쪽 잡음을 걷어내고 첫 JSON 객체만 남깁니다.
*
* composer 는 오토로드 경고를 표준출력에 함께 흘릴 수 있다.
*
* @param string $output 명령 출력
* @return string JSON 문자열 (없으면 빈 문자열)
*/
private function firstJsonObject(string $output): string
{
$start = strpos($output, '{');
return $start === false ? '' : substr($output, $start);
}
/**
* 동봉 제3자 자산의 버전 디렉토리를 나열합니다.
*
* 감사 도구가 원리상 볼 수 없는 축이므로 판정하지 않고 노출만 한다.
*
* @return array<int, array{extension: string, library: string, versions: array<int, string>}>
*/
private function vendoredAssets(): array
{
$rows = [];
foreach (['templates', 'modules', 'plugins'] as $kind) {
foreach ([$kind.'/*/dist/vendor', $kind.'/_bundled/*/dist/vendor'] as $pattern) {
foreach ((array) glob(base_path($pattern), GLOB_ONLYDIR) as $vendorRoot) {
foreach ((array) glob($vendorRoot.'/*', GLOB_ONLYDIR) as $libDir) {
$versions = array_map(
'basename',
(array) glob($libDir.'/*', GLOB_ONLYDIR)
);
$rows[] = [
'extension' => $this->relative(dirname($vendorRoot, 2)),
'library' => basename($libDir),
'versions' => array_values($versions),
];
}
}
}
}
return $rows;
}
/**
* 결과 집계를 계산합니다.
*
* @param array<int, array<string, mixed>> $results 감사 결과
* @return array{checked: int, vulnerable: int, unmeasurable: int, advisories: int}
*/
private function totals(array $results): array
{
return [
'checked' => count($results),
'audited' => count(array_filter($results, static fn (array $r): bool => in_array($r['status'], ['clean', 'vulnerable'], true))),
'empty' => count(array_filter($results, static fn (array $r): bool => $r['status'] === 'empty')),
'vulnerable' => count(array_filter($results, static fn (array $r): bool => $r['status'] === 'vulnerable')),
'unmeasurable' => count(array_filter($results, static fn (array $r): bool => $r['status'] === 'unmeasurable')),
'advisories' => array_sum(array_column($results, 'advisories')),
];
}
/**
* 결과 표를 출력합니다.
*
* @param array<int, array<string, mixed>> $results 감사 결과
* @return void
*/
private function renderTable(array $results): void
{
$this->line('');
$this->line('<options=bold>의존성 취약점 점검 (운영 의존성 기준)</>');
$this->line('');
$rows = [];
foreach ($results as $result) {
$severities = [];
foreach ($result['severities'] as $severity => $count) {
$severities[] = $severity.' '.$count;
}
$rows[] = [
$result['kind'],
$result['target'],
match ($result['status']) {
'clean' => '이상 없음',
'empty' => '대상 없음',
'vulnerable' => '취약',
default => '점검 불가',
},
$result['advisories'] > 0 ? (string) $result['advisories'] : '-',
$severities === [] ? ($result['detail'] !== '' ? mb_substr($result['detail'], 0, 60) : '-') : implode(', ', $severities),
];
}
$this->table(['종류', '대상', '상태', '건수', '내역'], $rows);
}
/**
* 동봉 자산 목록을 출력합니다.
*
* @param array<int, array<string, mixed>> $vendored 동봉 자산 목록
* @return void
*/
private function renderVendored(array $vendored): void
{
$this->line('');
$this->line('<options=bold>동봉 제3자 자산 (감사 도구가 볼 수 없는 축 — 사람이 확인)</>');
$this->line('');
if ($vendored === []) {
$this->line(' (없음)');
return;
}
$this->table(
['확장', '라이브러리', '버전 디렉토리'],
array_map(
static fn (array $row): array => [
$row['extension'],
$row['library'],
$row['versions'] === [] ? '(버전 디렉토리 없음)' : implode(', ', $row['versions']),
],
$vendored
)
);
}
/**
* 저장소 상대 경로로 바꿉니다.
*
* @param string $path 절대 경로
* @return string
*/
private function relative(string $path): string
{
$base = base_path().DIRECTORY_SEPARATOR;
return str_replace('\\', '/', str_starts_with($path, $base) ? substr($path, strlen($base)) : $path);
}
}