365 lines
12 KiB
PHP
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);
|
|
}
|
|
}
|