Files
mokocli/security/advisory_scan.php
T
Jonathan Miller 033e948c79
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.1) (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.2) (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 2: Unit Tests (8.3) (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 3: Self-Health Check (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 4: Governance (pull_request) Blocked by required conditions
Platform: mokoplatform CI / Gate 5: Template Integrity (pull_request) Blocked by required conditions
Platform: mokoplatform CI / CI Summary (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Failing after 2s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Universal: Security Audit / Dependency Audit (pull_request) Successful in 7s
Universal: Auto Version Bump / Version Bump (push) Successful in 14s
Platform: mokoplatform CI / Gate 1: Code Quality (pull_request) Failing after 1m8s
feat: security advisory aggregator, manifest API rewrite, namespace rename (#150, #283)
- Add `security:advisories` command — cross-repo CVE scanner via composer audit
  with checkpoint resumability, severity filtering, and auto-issue creation
- Rewrite `manifest:read` to use Gitea manifest API as primary source with
  auto-detection fallback from source tree (no more manifest.xml dependency)
- Rename MokoStandards namespace → MokoCli across all files
- Rename MokoEnterprise namespace → MokoCli across all files
- Rename MokoStandardsParser class → ManifestParser
- Fix composer.json autoload paths: src/ → source/
2026-06-20 20:24:58 -05:00

564 lines
22 KiB
PHP

#!/usr/bin/env php
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* 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<array{
* advisoryId: string,
* packageName: string,
* cve: string,
* title: string,
* severity: string,
* affectedVersions: string,
* link: string
* }>
*/
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());