Public Access
ab9f2d5674
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
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Site Health (push) Has been skipped
Universal: Auto Version Bump / Version Bump (push) Successful in 8s
- 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/
564 lines
22 KiB
PHP
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());
|