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|null $composerAudit * @param array|null $composerOutdated * @param array|null $npmAudit * @param array|null $npmOutdated * @return array */ 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|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|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 $command * @return array|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|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|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|null $data * @return array|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 $data * @return array */ 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); } }