mirror of
https://gh.wpcy.net/https://github.com/aspirepress/AspireCloud.git
synced 2026-07-17 11:37:03 +08:00
403 lines
14 KiB
PHP
403 lines
14 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Models\Package;
|
|
use App\Models\PackageRelease;
|
|
use App\Models\Labels;
|
|
use GuzzleHttp\Promise\PromiseInterface;
|
|
use Illuminate\Http\Client\Response as HttpResponse;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
/**
|
|
* Query CVE Labeller API for Latest Package Releases
|
|
*
|
|
* This command checks the LATEST RELEASE of ALL packages for vulnerabilities.
|
|
* The --severity flag determines which subset of packages to check in this run:
|
|
*
|
|
* --severity=high : Checks packages where latest release currently has HIGH vulnerabilities
|
|
* --severity=medium : Checks packages where latest release currently has MEDIUM vulnerabilities
|
|
* --severity=low : Checks packages where latest release currently has LOW vulnerabilities
|
|
* --severity=none : Checks packages where latest release has NO known vulnerabilities
|
|
* (no flag) : Checks ALL packages' latest releases
|
|
*
|
|
* The --package flag allows checking a single specific package:
|
|
* --package=woocommerce : Checks only the WooCommerce package by slug
|
|
* --package=did:plc:xxx : Checks only the package with the specified DID
|
|
*
|
|
* Scheduler runs different severity levels at different frequencies:
|
|
* - High severity: Every 10 minutes (critical vulnerabilities need frequent monitoring)
|
|
* - Medium severity: Every 30 minutes
|
|
* - Low severity: Every 1 hour
|
|
* - No vulnerabilities: Every 2 hours (to catch newly discovered issues)
|
|
*/
|
|
class QueryCveLabelsCommand extends Command
|
|
{
|
|
protected $signature = 'cve:query {--severity= : Filter by severity: high, medium, low, or none} {--package= : Query a specific package by slug or DID}';
|
|
|
|
protected $description = 'Query CVE Labeller API for latest package release vulnerability information';
|
|
|
|
private function getApiUrl(): string
|
|
{
|
|
return config('cve.api_url', env('CVE_LABELLER_API_URL', 'http://api.cve-labeller.local/api/query'));
|
|
}
|
|
|
|
private function getTimeout(): int
|
|
{
|
|
return (int) config('cve.api_timeout', env('CVE_LABELLER_API_TIMEOUT', 30));
|
|
}
|
|
|
|
private function getBatchSize(): int
|
|
{
|
|
return (int) config('cve.batch_size', env('CVE_LABELLER_BATCH_SIZE', 50));
|
|
}
|
|
|
|
public function handle(): int
|
|
{
|
|
if (!$this->isEnabled()) {
|
|
$this->info('CVE Labeller scanning is disabled');
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
$severity = $this->option('severity');
|
|
$packageFilter = $this->option('package');
|
|
|
|
// Build description of what we're querying
|
|
$description = 'Querying CVE Labeller API for ';
|
|
|
|
if ($packageFilter) {
|
|
$description .= "package '{$packageFilter}'";
|
|
} elseif ($severity) {
|
|
$description .= "latest releases with {$severity} severity";
|
|
} else {
|
|
$description .= "ALL latest releases";
|
|
}
|
|
|
|
$this->info($description);
|
|
|
|
// Get latest releases based on filters
|
|
$latestReleases = $this->getLatestReleases($severity, $packageFilter);
|
|
|
|
if ($latestReleases->isEmpty()) {
|
|
$this->info('No package releases to query');
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
$this->info("Found {$latestReleases->count()} latest package release(s) to check");
|
|
|
|
$batchSize = $this->getBatchSize();
|
|
$chunks = $latestReleases->chunk($batchSize);
|
|
$progressBar = $this->output->createProgressBar($chunks->count());
|
|
$progressBar->start();
|
|
|
|
foreach ($chunks as $chunk) {
|
|
$this->processChunk($chunk);
|
|
$progressBar->advance();
|
|
|
|
// Add optional delay between batches to avoid overwhelming the API
|
|
$delay = (int) config('cve.batch_delay', env('CVE_BATCH_DELAY', 0));
|
|
if ($delay > 0) {
|
|
usleep($delay * 1000); // Convert milliseconds to microseconds
|
|
}
|
|
}
|
|
|
|
$progressBar->finish();
|
|
$this->newLine();
|
|
$this->info('CVE query completed successfully');
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
/**
|
|
* Check if CVE scanning is enabled
|
|
*/
|
|
public function isEnabled(): bool
|
|
{
|
|
return (bool) config('cve.enabled', env('CVE_LABELLER_ENABLED', true));
|
|
}
|
|
|
|
/**
|
|
* Get latest release for each package, optionally filtered by current vulnerability severity and/or specific package
|
|
*
|
|
* This method:
|
|
* 1. Finds the latest release for EACH package (based on created_at timestamp)
|
|
* 2. Optionally filters to only packages where that latest release has a specific severity
|
|
* 3. Optionally filters to a single specific package by slug or DID
|
|
*
|
|
* Examples:
|
|
* - getLatestReleases(null, null): Returns latest release of ALL packages
|
|
* - getLatestReleases('high', null): Returns latest releases that currently have HIGH severity vulnerabilities
|
|
* - getLatestReleases('none', null): Returns latest releases that have NO known vulnerabilities
|
|
* - getLatestReleases(null, 'woocommerce'): Returns latest release of WooCommerce package only
|
|
* - getLatestReleases(null, 'did:plc:xxx'): Returns latest release of package with specific DID
|
|
* - getLatestReleases('high', 'woocommerce'): Returns WooCommerce's latest release if it has HIGH severity
|
|
*/
|
|
private function getLatestReleases(?string $severity, ?string $packageFilter): \Illuminate\Support\Collection
|
|
{
|
|
// Start with all packages
|
|
$query = Package::query();
|
|
|
|
// Filter by specific package if requested
|
|
if ($packageFilter) {
|
|
if (str_starts_with($packageFilter, 'did:')) {
|
|
// Filter by DID
|
|
$query->where('did', $packageFilter);
|
|
} else {
|
|
// Filter by slug
|
|
$query->where('slug', $packageFilter);
|
|
}
|
|
}
|
|
|
|
if ($severity) {
|
|
$query->whereHas('releases', function ($q) use ($severity) {
|
|
// Subquery to find the latest release ID
|
|
$q->whereIn('id', function ($subquery) {
|
|
$subquery->select('id')
|
|
->from('package_releases as pr')
|
|
->whereColumn('pr.package_id', 'packages.id')
|
|
->orderBy('pr.created_at', 'desc')
|
|
->limit(1);
|
|
})->whereHas('labels', function ($labelQ) use ($severity) {
|
|
$labelQ->where('value', $severity);
|
|
});
|
|
});
|
|
}
|
|
|
|
// Get packages with their latest release
|
|
$packages = $query->with(['releases' => function ($releaseQuery) {
|
|
$releaseQuery->orderBy('created_at', 'desc')->limit(1);
|
|
}])->get();
|
|
|
|
// Extract just the latest releases
|
|
return $packages->map(function ($package) {
|
|
return $package->releases->first(); // Latest release due to ordering
|
|
})->filter(); // Remove any nulls
|
|
}
|
|
|
|
/**
|
|
* Process a chunk of package releases
|
|
*/
|
|
private function processChunk($chunk): void
|
|
{
|
|
try {
|
|
$ids = $chunk->map(function (PackageRelease $release) {
|
|
return $this->buildPackageIdentifier($release);
|
|
})->filter()->implode(',');
|
|
|
|
if ($ids === '') {
|
|
return;
|
|
}
|
|
|
|
if (config('cve.log_api_requests', env('CVE_LOG_API_REQUESTS', false))) {
|
|
Log::debug('CVE API Request', [
|
|
'ids' => $ids,
|
|
'count' => $chunk->count(),
|
|
]);
|
|
}
|
|
|
|
/** @var HttpResponse $response */
|
|
$response = $this->queryApiWithRetry($ids);
|
|
|
|
if (! $response->successful()) {
|
|
Log::error('CVE API request failed', [
|
|
'status' => $response->status(),
|
|
'body' => $response->body(),
|
|
]);
|
|
return;
|
|
}
|
|
|
|
$labels = $response->json();
|
|
|
|
if (! is_array($labels)) {
|
|
Log::error('CVE API returned invalid response', [
|
|
'response' => $response->body(),
|
|
]);
|
|
return;
|
|
}
|
|
|
|
if (config('cve.log_api_requests', env('CVE_LOG_API_REQUESTS', false))) {
|
|
Log::debug('CVE API Response', ['label_count' => count($labels)]);
|
|
}
|
|
|
|
$this->storeLabels($labels, $chunk);
|
|
|
|
} catch (\Throwable $e) {
|
|
Log::error('Error processing CVE labels chunk', [
|
|
'error' => $e->getMessage(),
|
|
'trace' => $e->getTraceAsString(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Query API with retry logic (always returns a Response)
|
|
*/
|
|
private function queryApiWithRetry(string $ids): HttpResponse
|
|
{
|
|
$attempts = (int) config('cve.retry_attempts', env('CVE_RETRY_ATTEMPTS', 3));
|
|
$delaySec = (int) config('cve.retry_delay', env('CVE_RETRY_DELAY', 5));
|
|
$multiplier = (float) config('cve.retry_multiplier', env('CVE_RETRY_MULTIPLIER', 2));
|
|
|
|
$delayFn = function (int $attempt, ?HttpResponse $response = null) use ($delaySec, $multiplier) {
|
|
// $attempt starts at 1, so subtract 1 to start at base delay for the first retry
|
|
return (int) ($delaySec * 1000 * pow($multiplier, $attempt - 1));
|
|
};
|
|
|
|
$result = Http::timeout($this->getTimeout())
|
|
->retry($attempts, $delayFn, throw: false)
|
|
->get($this->getApiUrl(), ['ids' => $ids]);
|
|
|
|
// Ensure we always return a concrete Response
|
|
if ($result instanceof PromiseInterface) {
|
|
/** @var HttpResponse $resolved */
|
|
$resolved = $result->wait();
|
|
return $resolved;
|
|
}
|
|
|
|
/** @var HttpResponse $result */
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Build package identifier for API query
|
|
*
|
|
* Format: "fairpm:{did}/releases/{version}" or "fairpm:{slug}/releases/{version}"
|
|
*
|
|
* The 'did' and 'version' are columns in the database:
|
|
* - did: The package's decentralized identifier (e.g., "did:plc:e3rm6t7cspgpzaf47kn3nnsl")
|
|
* - version: The release version (e.g., "3.2.8.1")
|
|
*
|
|
* Examples:
|
|
* - fairpm:did:plc:e3rm6t7cspgpzaf47kn3nnsl/releases/3.2.8.1
|
|
* - fairpm:woocommerce/releases/9.3.3
|
|
*/
|
|
private function buildPackageIdentifier(PackageRelease $release): ?string
|
|
{
|
|
$package = $release->package;
|
|
|
|
if (!$package) {
|
|
return null;
|
|
}
|
|
|
|
if ($package->did && str_starts_with($package->did, 'did:plc:')) {
|
|
return "fairpm:{$package->did}/releases/{$release->version}";
|
|
}
|
|
|
|
return "fairpm:{$package->slug}/releases/{$release->version}";
|
|
}
|
|
|
|
/**
|
|
* Store vulnerability labels in database
|
|
*/
|
|
private function storeLabels(array $labels, $releases): void
|
|
{
|
|
// Create a map of package identifiers to releases for quick lookup
|
|
$releaseMap = $releases->mapWithKeys(function (PackageRelease $release) {
|
|
$identifier = $this->buildPackageIdentifier($release);
|
|
return [$identifier => $release];
|
|
});
|
|
|
|
DB::transaction(function () use ($labels, $releaseMap) {
|
|
foreach ($labels as $label) {
|
|
if (!isset($label['subject']) || !isset($label['value'])) {
|
|
continue;
|
|
}
|
|
|
|
$subject = $label['subject'];
|
|
$type = $label['type'];
|
|
$class = $label['class'];
|
|
$release = $releaseMap[$subject] ?? null;
|
|
|
|
if (!$release) {
|
|
// Try to find release by parsing the subject
|
|
$release = $this->findReleaseFromSubject($subject);
|
|
}
|
|
|
|
if (!$release) {
|
|
Log::warning('Could not find release for subject', ['subject' => $subject]);
|
|
continue;
|
|
}
|
|
|
|
$value = Labels::parsevalue($label['value']);
|
|
|
|
if (empty($value)) {
|
|
continue;
|
|
}
|
|
|
|
// Update or create the vulnerability label
|
|
$vulnerabilityLabel = Labels::updateOrCreate(
|
|
[
|
|
'package_release_id' => $release->id,
|
|
],
|
|
[
|
|
'type' => $type,
|
|
'class' => $class,
|
|
'value' => $value,
|
|
'source' => $label['source'] ?? 'unknown',
|
|
'data' => json_encode($label),
|
|
'updated_at' => now(),
|
|
]
|
|
);
|
|
|
|
// Send alert if high severity and alerts are enabled
|
|
if ($value === Labels::VALUE_HIGH && $this->shouldSendAlert($vulnerabilityLabel)) {
|
|
$this->sendAlert($vulnerabilityLabel);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Find package release from subject identifier
|
|
*
|
|
* Parses subjects like:
|
|
* - "fairpm:did:plc:xxx/releases/1.0.0"
|
|
* - "fairpm:woocommerce/releases/9.3.3"
|
|
*/
|
|
private function findReleaseFromSubject(string $subject): ?PackageRelease
|
|
{
|
|
// Parse subject: "fairpm:{identifier}/releases/{version}"
|
|
if (!preg_match('#^fairpm:([^/]+)/releases/(.+)$#', $subject, $matches)) {
|
|
return null;
|
|
}
|
|
|
|
$packageIdentifier = $matches[1];
|
|
$version = $matches[2];
|
|
|
|
// Try to find by DID first (DIDs start with "did:")
|
|
if (str_starts_with($packageIdentifier, 'did:')) {
|
|
return PackageRelease::whereHas('package', function ($q) use ($packageIdentifier) {
|
|
$q->where('did', $packageIdentifier);
|
|
})->where('version', $version)->first();
|
|
}
|
|
|
|
// Try to find by slug
|
|
return PackageRelease::whereHas('package', function ($q) use ($packageIdentifier) {
|
|
$q->where('slug', $packageIdentifier);
|
|
})->where('version', $version)->first();
|
|
}
|
|
|
|
/**
|
|
* Check if alerts should be sent for this vulnerability
|
|
*/
|
|
private function shouldSendAlert(Labels $label): bool
|
|
{
|
|
return (bool) config('cve.email_alerts_enabled', env('CVE_EMAIL_ALERTS_ENABLED', false));
|
|
}
|
|
|
|
/**
|
|
* Send alert notification for high severity vulnerability
|
|
*/
|
|
private function sendAlert(Labels $label): void
|
|
{
|
|
// Log the high severity finding
|
|
Log::warning('High severity vulnerability detected', [
|
|
'package_release_id' => $label->package_release_id,
|
|
'value' => $label->value,
|
|
'source' => $label->source,
|
|
]);
|
|
}
|
|
}
|