#!/usr/bin/env php * * This file is part of a Moko Consulting project. * * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION * DEFGROUP: MokoPlatform.Security * INGROUP: MokoPlatform.Scripts * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * PATH: /security/advisory_scan.php * BRIEF: Cross-repo security advisory aggregator — scans org repos for known CVEs */ declare(strict_types=1); require_once __DIR__ . '/../vendor/autoload.php'; use MokoCli\{ ApiClient, AuditLogger, CheckpointManager, CircuitBreakerOpen, CliFramework, Config, GitPlatformAdapter, MetricsCollector, PlatformAdapterFactory, RateLimitExceeded }; /** * Cross-Repo Security Advisory Aggregator * * Scans all repositories in an organization for known CVEs via `composer audit`, * aggregates results into a single report, and optionally auto-creates issues * for critical vulnerabilities. * * @see https://git.mokoconsulting.tech/MokoConsulting/mokocli/issues/150 */ class AdvisoryScan extends CliFramework { public const VERSION = '01.00.00'; /** Minimum severity level for auto-creating issues. */ private const SEVERITY_LEVELS = ['critical', 'high', 'medium', 'low']; private ApiClient $api; private GitPlatformAdapter $adapter; private AuditLogger $logger; private CheckpointManager $checkpoints; /** Collected advisories keyed by repo name. */ private array $advisories = []; /** Summary counters. */ private int $reposScanned = 0; private int $reposWithIssues = 0; private int $totalAdvisories = 0; private int $issuesCreated = 0; protected function configure(): void { $this->setDescription('Cross-repo security advisory aggregator'); $this->addArgument('--org', 'Organization to scan', 'MokoConsulting'); $this->addArgument('--repos', 'Comma-separated list of specific repos', ''); $this->addArgument('--exclude', 'Comma-separated list of repos to exclude', ''); $this->addArgument('--skip-archived', 'Skip archived repositories', true); $this->addArgument('--severity', 'Minimum severity for issue creation (critical|high|medium|low)', 'high'); $this->addArgument('--create-issues', 'Auto-create issues for vulnerabilities', false); $this->addArgument('--resume', 'Resume from checkpoint', false); $this->addArgument('--export', 'Export report to file (json|csv)', ''); } protected function run(): int { $this->log("Security Advisory Scanner v" . self::VERSION, 'INFO'); if (!$this->initComponents()) { return self::EXIT_FAILURE; } $org = $this->getArgument('--org', 'MokoConsulting'); $minSeverity = strtolower($this->getArgument('--severity', 'high')); $createIssues = $this->getArgument('--create-issues', false); $exportFormat = $this->getArgument('--export', ''); if (!in_array($minSeverity, self::SEVERITY_LEVELS, true)) { $this->log("Invalid severity: {$minSeverity}. Use: " . implode(', ', self::SEVERITY_LEVELS), 'ERROR'); return self::EXIT_USAGE; } // ── Gather repos ───────────────────────────────────────────────── $repos = $this->gatherRepos($org); if ($repos === null) { return self::EXIT_FAILURE; } $total = count($repos); $this->log("Found {$total} repositories to scan", 'INFO'); // ── Resume support ─────────────────────────────────────────────── $completed = []; if ($this->getArgument('--resume', false)) { $checkpoint = $this->checkpoints->load('advisory_scan'); if ($checkpoint) { $completed = $checkpoint['completed'] ?? []; $this->advisories = $checkpoint['advisories'] ?? []; $this->log("Resuming — skipping " . count($completed) . " already-scanned repos", 'INFO'); } } // ── Scan each repo ─────────────────────────────────────────────── $this->section('Scanning repositories'); foreach ($repos as $i => $repo) { $repoName = $repo['name']; $this->progress($i + 1, $total, $repoName); if (in_array($repoName, $completed, true)) { continue; } try { $this->scanRepo($org, $repoName); $completed[] = $repoName; // Checkpoint after each repo $this->checkpoints->save('advisory_scan', [ 'completed' => $completed, 'advisories' => $this->advisories, ]); } catch (RateLimitExceeded $e) { $this->log("Rate limit hit — checkpoint saved, resume later with --resume", 'WARNING'); break; } catch (CircuitBreakerOpen $e) { $this->log("API circuit breaker open — checkpoint saved", 'WARNING'); break; } catch (\Exception $e) { $this->log("Failed to scan {$repoName}: {$e->getMessage()}", 'ERROR'); } } $this->progress($total, $total, '', true); // ── Aggregate & report ─────────────────────────────────────────── $this->section('Advisory Report'); $this->printReport($minSeverity); // ── Auto-create issues ─────────────────────────────────────────── if ($createIssues) { $this->section('Creating tracking issues'); $this->createTrackingIssues($org, $minSeverity); } // ── Export ─────────────────────────────────────────────────────── if ($exportFormat !== '') { $this->exportReport($exportFormat); } // ── JSON output ────────────────────────────────────────────────── if ($this->getArgument('--json', false)) { $this->jsonOutput('ok', $this->buildReportData($minSeverity)); return self::EXIT_SUCCESS; } // ── Summary ────────────────────────────────────────────────────── $this->printSummary( $this->reposScanned - $this->reposWithIssues, $this->reposWithIssues, $this->elapsed() ); $this->log("Total advisories: {$this->totalAdvisories} across {$this->reposWithIssues} repos", 'INFO'); if ($this->issuesCreated > 0) { $this->log("Issues created: {$this->issuesCreated}", 'INFO'); } // Clean checkpoint on full completion if (count($completed) === $total) { $this->checkpoints->clear('advisory_scan'); } return $this->reposWithIssues > 0 ? self::EXIT_FAILURE : self::EXIT_SUCCESS; } // ── Component initialisation ───────────────────────────────────────── private function initComponents(): bool { try { $this->config = new Config(); $this->api = new ApiClient($this->config); $this->adapter = PlatformAdapterFactory::create($this->api, $this->config); $this->logger = new AuditLogger(); $this->checkpoints = new CheckpointManager(); return true; } catch (\Exception $e) { $this->log("Failed to initialise: {$e->getMessage()}", 'ERROR'); return false; } } // ── Repo gathering ─────────────────────────────────────────────────── private function gatherRepos(string $org): ?array { $specificRepos = array_filter(explode(',', $this->getArgument('--repos', ''))); $excludeRepos = array_filter(explode(',', $this->getArgument('--exclude', ''))); $skipArchived = $this->getArgument('--skip-archived', true); try { $repos = $this->adapter->listOrgRepos($org, $skipArchived); } catch (\Exception $e) { $this->log("Failed to list repos: {$e->getMessage()}", 'ERROR'); return null; } // Filter to specific repos if requested if (!empty($specificRepos)) { $repos = array_filter($repos, fn($r) => in_array($r['name'], $specificRepos, true)); } // Exclude repos if (!empty($excludeRepos)) { $repos = array_filter($repos, fn($r) => !in_array($r['name'], $excludeRepos, true)); } return array_values($repos); } // ── Per-repo scanning ──────────────────────────────────────────────── private function scanRepo(string $org, string $repoName): void { $this->reposScanned++; // Check if repo has a composer.lock file try { $lockData = $this->adapter->getFileContents($org, $repoName, 'composer.lock'); } catch (\Exception $e) { // No composer.lock — skip silently (not a Composer project) return; } // Download composer.json too (required by composer audit) try { $jsonData = $this->adapter->getFileContents($org, $repoName, 'composer.json'); } catch (\Exception $e) { return; } // Write to temp directory and run composer audit $tmpDir = sys_get_temp_dir() . '/moko_advisory_' . $repoName . '_' . getmypid(); @mkdir($tmpDir, 0700, true); try { file_put_contents($tmpDir . '/composer.lock', base64_decode($lockData['content'] ?? '')); file_put_contents($tmpDir . '/composer.json', base64_decode($jsonData['content'] ?? '')); $result = $this->runComposerAudit($tmpDir); if (!empty($result)) { $this->advisories[$repoName] = $result; $this->reposWithIssues++; $this->totalAdvisories += count($result); } } finally { // Cleanup temp files @unlink($tmpDir . '/composer.lock'); @unlink($tmpDir . '/composer.json'); @rmdir($tmpDir); } } /** * Run `composer audit --format=json` and parse the output. * * @return array */ private function runComposerAudit(string $dir): array { $cmd = sprintf( 'composer audit --format=json --working-dir=%s --no-interaction 2>/dev/null', escapeshellarg($dir) ); $output = shell_exec($cmd); if ($output === null || $output === '') { return []; } $data = json_decode($output, true); if (!is_array($data)) { return []; } $advisories = []; // composer audit JSON: { "advisories": { "vendor/package": [ { ... }, ... ] } } foreach ($data['advisories'] ?? [] as $packageName => $packageAdvisories) { foreach ($packageAdvisories as $advisory) { $advisories[] = [ 'advisoryId' => $advisory['advisoryId'] ?? '', 'packageName' => $packageName, 'cve' => $advisory['cve'] ?? '', 'title' => $advisory['title'] ?? 'Unknown', 'severity' => strtolower($advisory['severity'] ?? 'unknown'), 'affectedVersions' => $advisory['affectedVersions'] ?? '', 'link' => $advisory['link'] ?? '', ]; } } return $advisories; } // ── Reporting ──────────────────────────────────────────────────────── private function printReport(string $minSeverity): void { if (empty($this->advisories)) { $this->log("No advisories found — all repos are clean", 'INFO'); return; } // Aggregate by severity $bySeverity = array_fill_keys(self::SEVERITY_LEVELS, []); foreach ($this->advisories as $repoName => $repoAdvisories) { foreach ($repoAdvisories as $adv) { $sev = $adv['severity'] ?: 'unknown'; $bySeverity[$sev][] = array_merge($adv, ['repo' => $repoName]); } } foreach (self::SEVERITY_LEVELS as $severity) { $items = $bySeverity[$severity] ?? []; if (empty($items)) { continue; } $this->log(strtoupper($severity) . ': ' . count($items) . ' advisory(ies)', 'WARNING'); foreach ($items as $adv) { $cveLabel = $adv['cve'] ?: $adv['advisoryId']; $meetsThreshold = $this->severityMeetsThreshold($adv['severity'], $minSeverity); $this->status( !$meetsThreshold, "{$adv['repo']}: {$adv['packageName']}", "{$cveLabel} — {$adv['title']}" ); } } // Show unknown-severity advisories if any $unknowns = $bySeverity['unknown'] ?? []; if (!empty($unknowns)) { $this->log('UNKNOWN SEVERITY: ' . count($unknowns) . ' advisory(ies)', 'WARNING'); foreach ($unknowns as $adv) { $cveLabel = $adv['cve'] ?: $adv['advisoryId']; $this->status(false, "{$adv['repo']}: {$adv['packageName']}", "{$cveLabel} — {$adv['title']}"); } } } // ── Issue creation ─────────────────────────────────────────────────── private function createTrackingIssues(string $org, string $minSeverity): void { $dryRun = $this->getArgument('--dry-run', false); foreach ($this->advisories as $repoName => $repoAdvisories) { // Filter to advisories meeting the severity threshold $actionable = array_filter( $repoAdvisories, fn($a) => $this->severityMeetsThreshold($a['severity'], $minSeverity) ); if (empty($actionable)) { continue; } $title = 'Security: ' . count($actionable) . ' CVE(s) found by composer audit'; $body = $this->buildIssueBody($repoName, $actionable); if ($dryRun) { $this->log("[dry-run] Would create issue in {$repoName}: {$title}", 'INFO'); continue; } // Check for existing open issue to avoid duplicates try { $existing = $this->adapter->listIssues($org, $repoName, [ 'state' => 'open', 'type' => 'issues', ]); $duplicate = false; foreach ($existing as $issue) { if (str_starts_with($issue['title'] ?? '', 'Security:') && str_contains($issue['title'] ?? '', 'composer audit')) { // Update existing issue instead $this->adapter->addIssueComment($org, $repoName, (int) $issue['number'], $body); $this->log("Updated existing issue #{$issue['number']} in {$repoName}", 'INFO'); $this->issuesCreated++; $duplicate = true; break; } } if (!$duplicate) { $this->adapter->createIssue($org, $repoName, $title, $body, [ 'labels' => ['security'], ]); $this->log("Created issue in {$repoName}: {$title}", 'INFO'); $this->issuesCreated++; } } catch (\Exception $e) { $this->log("Failed to create issue in {$repoName}: {$e->getMessage()}", 'ERROR'); } } } private function buildIssueBody(string $repoName, array $advisories): string { $date = date('Y-m-d H:i:s'); $lines = [ "## Security Advisory Report", "", "**Repository**: `{$repoName}`", "**Scanned**: {$date}", "**Tool**: `composer audit`", "", "### Vulnerabilities", "", "| Severity | Package | CVE | Description |", "|----------|---------|-----|-------------|", ]; foreach ($advisories as $adv) { $cve = $adv['cve'] ?: $adv['advisoryId']; $link = $adv['link'] ? "[{$cve}]({$adv['link']})" : $cve; $lines[] = "| {$adv['severity']} | `{$adv['packageName']}` | {$link} | {$adv['title']} |"; } $lines[] = ""; $lines[] = "### Remediation"; $lines[] = ""; $lines[] = "Run `composer update` to update affected packages, or pin specific versions:"; $lines[] = ""; $lines[] = "```bash"; $lines[] = "composer audit # verify current state"; $lines[] = "composer update # update all dependencies"; $lines[] = "```"; $lines[] = ""; $lines[] = "---"; $lines[] = "*Auto-generated by `moko security:advisories`*"; return implode("\n", $lines); } // ── Export ──────────────────────────────────────────────────────────── private function exportReport(string $format): void { $data = $this->buildReportData($this->getArgument('--severity', 'high')); $filename = 'advisory-report-' . date('Y-m-d-His'); switch (strtolower($format)) { case 'json': $path = $filename . '.json'; file_put_contents($path, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); $this->log("Report exported to {$path}", 'INFO'); break; case 'csv': $path = $filename . '.csv'; $fp = fopen($path, 'w'); fputcsv($fp, ['Repository', 'Package', 'CVE', 'Severity', 'Title', 'Affected Versions', 'Link']); foreach ($data['advisories'] as $adv) { fputcsv($fp, [ $adv['repo'], $adv['packageName'], $adv['cve'], $adv['severity'], $adv['title'], $adv['affectedVersions'], $adv['link'], ]); } fclose($fp); $this->log("Report exported to {$path}", 'INFO'); break; default: $this->log("Unsupported export format: {$format}. Use 'json' or 'csv'.", 'ERROR'); } } // ── Helpers ─────────────────────────────────────────────────────────── private function buildReportData(string $minSeverity): array { $flat = []; foreach ($this->advisories as $repoName => $repoAdvisories) { foreach ($repoAdvisories as $adv) { $flat[] = array_merge($adv, ['repo' => $repoName]); } } // Sort: critical first, then high, etc. usort($flat, function ($a, $b) { $order = array_flip(self::SEVERITY_LEVELS); $sa = $order[$a['severity']] ?? 99; $sb = $order[$b['severity']] ?? 99; return $sa <=> $sb; }); return [ 'summary' => [ 'scannedAt' => date('c'), 'reposScanned' => $this->reposScanned, 'reposAffected' => $this->reposWithIssues, 'totalAdvisories' => $this->totalAdvisories, 'minSeverity' => $minSeverity, 'issuesCreated' => $this->issuesCreated, ], 'advisories' => $flat, ]; } /** * Check if a severity level meets or exceeds a threshold. */ private function severityMeetsThreshold(string $severity, string $threshold): bool { $order = array_flip(self::SEVERITY_LEVELS); $sevIdx = $order[$severity] ?? 99; $thrIdx = $order[$threshold] ?? 0; return $sevIdx <= $thrIdx; } } $script = new AdvisoryScan('advisory_scan', 'Cross-repo security advisory aggregator'); exit($script->execute());