Files
SkinbaseNova/app/Services/SecurityReport/SecurityReportScanner.php
T

365 lines
12 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\SecurityReport;
use App\Models\SecurityReport;
use Illuminate\Support\Facades\Log;
use Symfony\Component\Process\Process;
use Throwable;
final class SecurityReportScanner
{
public function scan(string $triggeredBy = 'artisan', ?int $userId = null): SecurityReport
{
$report = SecurityReport::query()->create([
'status' => 'running',
'started_at' => now(),
'triggered_by' => $triggeredBy,
'user_id' => $userId,
]);
try {
$composerAudit = null;
$composerOutdated = null;
$npmAudit = null;
$npmOutdated = null;
if ((bool) config('security-report.scan.composer', true)) {
$composerAudit = $this->runJsonCommand((array) config('security-report.commands.composer_audit', []));
$composerOutdated = $this->runJsonCommand((array) config('security-report.commands.composer_outdated', []));
}
if ((bool) config('security-report.scan.npm', true)) {
$npmAudit = $this->runJsonCommand((array) config('security-report.commands.npm_audit', []));
$npmOutdated = $this->runJsonCommand((array) config('security-report.commands.npm_outdated', []));
}
$normalized = $this->summarizePayloads($composerAudit, $composerOutdated, $npmAudit, $npmOutdated);
$status = ($normalized['total_critical'] > 0 || $normalized['total_high'] > 0 || $normalized['total_medium'] > 0 || $normalized['total_low'] > 0)
? 'completed_with_findings'
: 'completed';
$report->update(array_merge($normalized, [
'status' => $status,
'finished_at' => now(),
'composer_audit' => $this->shouldStoreRaw() ? $this->limitRaw($composerAudit) : null,
'composer_outdated' => $this->shouldStoreRaw() ? $this->limitRaw($composerOutdated) : null,
'npm_audit' => $this->shouldStoreRaw() ? $this->limitRaw($npmAudit) : null,
'npm_outdated' => $this->shouldStoreRaw() ? $this->limitRaw($npmOutdated) : null,
]));
return $report->fresh();
} catch (Throwable $exception) {
Log::error('Security report scan failed', [
'message' => $exception->getMessage(),
]);
$report->update([
'status' => 'failed',
'finished_at' => now(),
'error_message' => $this->sanitizeText($exception->getMessage()),
]);
return $report->fresh();
}
}
/**
* @param array<string, mixed>|null $composerAudit
* @param array<string, mixed>|null $composerOutdated
* @param array<string, mixed>|null $npmAudit
* @param array<string, mixed>|null $npmOutdated
* @return array<string, mixed>
*/
public function summarizePayloads(?array $composerAudit, ?array $composerOutdated, ?array $npmAudit, ?array $npmOutdated): array
{
$composerCounts = $this->summarizeComposerAudit($composerAudit);
$npmCounts = $this->summarizeNpmAudit($npmAudit);
$composerOutdatedCount = $this->countComposerOutdated($composerOutdated);
$npmOutdatedCount = $this->countNpmOutdated($npmOutdated);
$totalCritical = $composerCounts['critical'] + $npmCounts['critical'];
$totalHigh = $composerCounts['high'] + $npmCounts['high'];
$totalMedium = $composerCounts['medium'] + $npmCounts['moderate'];
$totalLow = $composerCounts['low'] + $npmCounts['low'];
$totalUnknown = $composerCounts['unknown'] + $npmCounts['unknown'] + $npmCounts['info'];
return [
'composer_critical' => $composerCounts['critical'],
'composer_high' => $composerCounts['high'],
'composer_medium' => $composerCounts['medium'],
'composer_low' => $composerCounts['low'],
'composer_unknown' => $composerCounts['unknown'],
'npm_critical' => $npmCounts['critical'],
'npm_high' => $npmCounts['high'],
'npm_moderate' => $npmCounts['moderate'],
'npm_low' => $npmCounts['low'],
'npm_info' => $npmCounts['info'],
'npm_unknown' => $npmCounts['unknown'],
'total_critical' => $totalCritical,
'total_high' => $totalHigh,
'total_medium' => $totalMedium,
'total_low' => $totalLow,
'total_unknown' => $totalUnknown,
'composer_outdated_count' => $composerOutdatedCount,
'npm_outdated_count' => $npmOutdatedCount,
'summary' => [
'total' => [
'critical' => $totalCritical,
'high' => $totalHigh,
'medium' => $totalMedium,
'low' => $totalLow,
'unknown' => $totalUnknown,
],
'composer' => $composerCounts,
'npm' => $npmCounts,
'outdated' => [
'composer' => $composerOutdatedCount,
'npm' => $npmOutdatedCount,
],
],
];
}
/**
* @param array<string, mixed>|null $audit
* @return array{critical:int,high:int,medium:int,low:int,unknown:int}
*/
public function summarizeComposerAudit(?array $audit): array
{
$counts = [
'critical' => 0,
'high' => 0,
'medium' => 0,
'low' => 0,
'unknown' => 0,
];
if (! is_array($audit)) {
return $counts;
}
$advisories = $audit['advisories'] ?? [];
foreach ($advisories as $packageAdvisories) {
if (! is_array($packageAdvisories)) {
continue;
}
foreach ($packageAdvisories as $advisory) {
if (! is_array($advisory)) {
continue;
}
$severity = strtolower((string) ($advisory['severity'] ?? 'unknown'));
if (array_key_exists($severity, $counts)) {
$counts[$severity]++;
} else {
$counts['unknown']++;
}
}
}
return $counts;
}
/**
* @param array<string, mixed>|null $audit
* @return array{critical:int,high:int,moderate:int,low:int,info:int,unknown:int}
*/
public function summarizeNpmAudit(?array $audit): array
{
$counts = [
'critical' => 0,
'high' => 0,
'moderate' => 0,
'low' => 0,
'info' => 0,
'unknown' => 0,
];
if (! is_array($audit)) {
return $counts;
}
if (isset($audit['metadata']['vulnerabilities']) && is_array($audit['metadata']['vulnerabilities'])) {
$vulnerabilities = $audit['metadata']['vulnerabilities'];
$counts['critical'] = (int) ($vulnerabilities['critical'] ?? 0);
$counts['high'] = (int) ($vulnerabilities['high'] ?? 0);
$counts['moderate'] = (int) ($vulnerabilities['moderate'] ?? 0);
$counts['low'] = (int) ($vulnerabilities['low'] ?? 0);
$counts['info'] = (int) ($vulnerabilities['info'] ?? 0);
return $counts;
}
$vulnerabilities = $audit['vulnerabilities'] ?? [];
foreach ($vulnerabilities as $vulnerability) {
if (! is_array($vulnerability)) {
continue;
}
$severity = strtolower((string) ($vulnerability['severity'] ?? 'unknown'));
if (array_key_exists($severity, $counts)) {
$counts[$severity]++;
} else {
$counts['unknown']++;
}
}
return $counts;
}
/**
* @param array<int, string> $command
* @return array<string, mixed>|null
*/
private function runJsonCommand(array $command): ?array
{
if ($command === []) {
return null;
}
$process = new Process(
$command,
base_path(),
null,
null,
(float) config('security-report.timeout_seconds', 180),
);
$process->run();
$output = trim($process->getOutput());
$errorOutput = trim($process->getErrorOutput());
if ($output === '') {
return [
'_status' => $errorOutput !== '' ? 'no_json_output' : 'empty_output',
'_exit_code' => $process->getExitCode(),
'_error' => $errorOutput !== '' ? $this->sanitizeText(mb_substr($errorOutput, 0, 5000)) : null,
];
}
$json = json_decode($output, true);
if (json_last_error() !== JSON_ERROR_NONE || ! is_array($json)) {
return [
'_status' => 'invalid_json',
'_exit_code' => $process->getExitCode(),
'_json_error' => json_last_error_msg(),
'_output_preview' => $this->sanitizeText(mb_substr($output, 0, 5000)),
'_error_preview' => $this->sanitizeText(mb_substr($errorOutput, 0, 5000)),
];
}
$json['_exit_code'] = $process->getExitCode();
return $this->sanitizeArray($json);
}
/**
* @param array<string, mixed>|null $outdated
*/
private function countComposerOutdated(?array $outdated): int
{
if (! is_array($outdated)) {
return 0;
}
return isset($outdated['installed']) && is_array($outdated['installed'])
? count($outdated['installed'])
: 0;
}
/**
* @param array<string, mixed>|null $outdated
*/
private function countNpmOutdated(?array $outdated): int
{
if (! is_array($outdated)) {
return 0;
}
return count(array_filter(
$outdated,
static fn (mixed $value, mixed $key): bool => is_array($value) && ! str_starts_with((string) $key, '_'),
ARRAY_FILTER_USE_BOTH,
));
}
private function shouldStoreRaw(): bool
{
return (bool) config('security-report.store_raw', true);
}
/**
* @param array<string, mixed>|null $data
* @return array<string, mixed>|null
*/
private function limitRaw(?array $data): ?array
{
if ($data === null) {
return null;
}
$sanitized = $this->sanitizeArray($data);
$maxBytes = max(64, (int) config('security-report.max_raw_kb', 512)) * 1024;
$json = json_encode($sanitized, JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE);
if (! is_string($json)) {
return [
'_status' => 'raw_encode_failed',
];
}
if (strlen($json) <= $maxBytes) {
return $sanitized;
}
return [
'_status' => 'truncated',
'_max_kb' => (int) config('security-report.max_raw_kb', 512),
'_preview' => mb_substr($json, 0, $maxBytes),
];
}
/**
* @param array<mixed> $data
* @return array<mixed>
*/
private function sanitizeArray(array $data): array
{
$sanitized = [];
foreach ($data as $key => $value) {
if (is_array($value)) {
$sanitized[$key] = $this->sanitizeArray($value);
continue;
}
if (is_string($value)) {
$sanitized[$key] = $this->sanitizeText($value);
continue;
}
$sanitized[$key] = $value;
}
return $sanitized;
}
private function sanitizeText(string $value): string
{
$normalized = str_replace(["\r\n", "\r"], "\n", $value);
$normalized = str_replace(base_path(), '[project-root]', $normalized);
$normalized = preg_replace('/[A-Z]:\\\\[^\s"\']+/', '[path]', $normalized) ?? $normalized;
return trim($normalized);
}
}