cf0b2726fd
Universal: Cascade Main → Dev / Cascade main → branches (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Authored-by: Moko Consulting
331 lines
8.4 KiB
PHP
331 lines
8.4 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: MokoStandards.Scripts.Monitoring
|
|
* INGROUP: MokoStandards
|
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
|
* PATH: /monitoring/joomla-version-audit.php
|
|
* VERSION: 01.00.00
|
|
* BRIEF: Audit Joomla core and extension versions across sites via the Joomla API
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
final class JoomlaVersionAudit
|
|
{
|
|
/** @var array<string, mixed> */
|
|
private array $args = [];
|
|
|
|
/** @var array<array{url: string, joomlaVersion: string, behind: bool, extensions: array<array{name: string, version: string}>, error: string}> */
|
|
private array $results = [];
|
|
|
|
public function run(): int
|
|
{
|
|
$this->args = $this->parseArgs();
|
|
|
|
$sites = $this->resolveSites();
|
|
|
|
if (count($sites) === 0) {
|
|
$this->log('No sites provided. Use --sites <json-file>.');
|
|
return 1;
|
|
}
|
|
|
|
$latestVersion = $this->args['latest'] ?? null;
|
|
|
|
foreach ($sites as $site) {
|
|
$this->log("Auditing: {$site['url']}");
|
|
$entry = $this->auditSite($site, $latestVersion);
|
|
$this->results[] = $entry;
|
|
}
|
|
|
|
if (!empty($this->args['json'])) {
|
|
$this->outputJson();
|
|
} else {
|
|
$this->outputTable();
|
|
}
|
|
|
|
$hasBehind = false;
|
|
foreach ($this->results as $r) {
|
|
if ($r['behind'] || $r['error'] !== '') {
|
|
$hasBehind = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
return $hasBehind ? 1 : 0;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function parseArgs(): array
|
|
{
|
|
$args = [
|
|
'sites' => null,
|
|
'json' => false,
|
|
'latest' => null,
|
|
];
|
|
|
|
$argv = $_SERVER['argv'] ?? [];
|
|
$argc = count($argv);
|
|
|
|
for ($i = 1; $i < $argc; $i++) {
|
|
switch ($argv[$i]) {
|
|
case '--sites':
|
|
$args['sites'] = $argv[++$i] ?? null;
|
|
break;
|
|
case '--json':
|
|
$args['json'] = true;
|
|
break;
|
|
case '--latest':
|
|
$args['latest'] = $argv[++$i] ?? null;
|
|
break;
|
|
}
|
|
}
|
|
|
|
return $args;
|
|
}
|
|
|
|
/**
|
|
* @return list<array{url: string, token: string}>
|
|
*/
|
|
private function resolveSites(): array
|
|
{
|
|
$file = $this->args['sites'] ?? null;
|
|
|
|
if (empty($file)) {
|
|
return [];
|
|
}
|
|
|
|
if (!is_file($file) || !is_readable($file)) {
|
|
$this->log("Cannot read sites file: {$file}");
|
|
return [];
|
|
}
|
|
|
|
$raw = file_get_contents($file);
|
|
if ($raw === false) {
|
|
$this->log("Failed to read sites file: {$file}");
|
|
return [];
|
|
}
|
|
|
|
$data = json_decode($raw, true);
|
|
if (!is_array($data)) {
|
|
$this->log("Sites file is not valid JSON: {$file}");
|
|
return [];
|
|
}
|
|
|
|
$sites = [];
|
|
foreach ($data as $item) {
|
|
if (is_array($item) && isset($item['url'], $item['token'])) {
|
|
$sites[] = [
|
|
'url' => rtrim((string) $item['url'], '/'),
|
|
'token' => (string) $item['token'],
|
|
];
|
|
}
|
|
}
|
|
|
|
return $sites;
|
|
}
|
|
|
|
/**
|
|
* @param array{url: string, token: string} $site
|
|
* @return array{url: string, joomlaVersion: string, behind: bool, extensions: array<array{name: string, version: string}>, error: string}
|
|
*/
|
|
private function auditSite(array $site, ?string $latestVersion): array
|
|
{
|
|
$entry = [
|
|
'url' => $site['url'],
|
|
'joomlaVersion' => '',
|
|
'behind' => false,
|
|
'extensions' => [],
|
|
'error' => '',
|
|
];
|
|
|
|
// Fetch Joomla version from config/application endpoint
|
|
$configData = $this->apiGet($site['url'] . '/api/index.php/v1/config/application', $site['token']);
|
|
|
|
if ($configData === null) {
|
|
$entry['error'] = 'Failed to fetch site configuration';
|
|
return $entry;
|
|
}
|
|
|
|
// The Joomla API returns config attributes; extract version from the response
|
|
$joomlaVersion = $this->extractJoomlaVersion($configData);
|
|
$entry['joomlaVersion'] = $joomlaVersion;
|
|
|
|
// Compare against latest known version
|
|
if ($latestVersion !== null && $joomlaVersion !== '') {
|
|
if (version_compare($joomlaVersion, $latestVersion, '<')) {
|
|
$entry['behind'] = true;
|
|
}
|
|
}
|
|
|
|
// Fetch extensions list
|
|
$extData = $this->apiGet($site['url'] . '/api/index.php/v1/extensions', $site['token']);
|
|
|
|
if ($extData !== null && isset($extData['data']) && is_array($extData['data'])) {
|
|
foreach ($extData['data'] as $ext) {
|
|
$attrs = $ext['attributes'] ?? $ext;
|
|
$name = $attrs['name'] ?? $attrs['element'] ?? 'unknown';
|
|
$version = $attrs['version'] ?? '?';
|
|
|
|
// Filter to meaningful extensions (components, modules, plugins, templates)
|
|
$type = $attrs['type'] ?? '';
|
|
if (in_array($type, ['component', 'module', 'plugin', 'template', 'package', 'library'], true)) {
|
|
$entry['extensions'][] = [
|
|
'name' => (string) $name,
|
|
'version' => (string) $version,
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
return $entry;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
private function apiGet(string $url, string $token): ?array
|
|
{
|
|
$context = stream_context_create([
|
|
'http' => [
|
|
'method' => 'GET',
|
|
'timeout' => 30,
|
|
'ignore_errors' => true,
|
|
'follow_location' => 1,
|
|
'max_redirects' => 3,
|
|
'header' => implode("\r\n", [
|
|
"X-Joomla-Token: {$token}",
|
|
'Accept: application/vnd.api+json',
|
|
'Content-Type: application/json',
|
|
]),
|
|
],
|
|
'ssl' => [
|
|
'verify_peer' => true,
|
|
'verify_peer_name' => true,
|
|
],
|
|
]);
|
|
|
|
$body = @file_get_contents($url, false, $context);
|
|
if ($body === false) {
|
|
$this->log(" API request failed: {$url}");
|
|
return null;
|
|
}
|
|
|
|
$data = json_decode($body, true);
|
|
if (!is_array($data)) {
|
|
$this->log(" Invalid JSON response from: {$url}");
|
|
return null;
|
|
}
|
|
|
|
return $data;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $configData
|
|
*/
|
|
private function extractJoomlaVersion(array $configData): string
|
|
{
|
|
// Joomla's Web Services API returns config data in a JSON:API structure.
|
|
// The version may appear in data.attributes or in meta.
|
|
if (isset($configData['meta']['cms-version'])) {
|
|
return (string) $configData['meta']['cms-version'];
|
|
}
|
|
|
|
// Fallback: look through data attributes for a version-like field
|
|
if (isset($configData['data']) && is_array($configData['data'])) {
|
|
foreach ($configData['data'] as $item) {
|
|
$attrs = $item['attributes'] ?? [];
|
|
// Some Joomla API responses put the version in the attributes
|
|
if (isset($attrs['cms-version'])) {
|
|
return (string) $attrs['cms-version'];
|
|
}
|
|
}
|
|
}
|
|
|
|
// Second fallback: look for X-Powered-By or similar in response
|
|
if (isset($configData['data']['attributes'])) {
|
|
$attrs = $configData['data']['attributes'];
|
|
foreach ($attrs as $key => $value) {
|
|
if (stripos($key, 'version') !== false && is_string($value)) {
|
|
return $value;
|
|
}
|
|
}
|
|
}
|
|
|
|
return 'unknown';
|
|
}
|
|
|
|
private function outputTable(): void
|
|
{
|
|
$latestVersion = $this->args['latest'] ?? null;
|
|
|
|
foreach ($this->results as $r) {
|
|
$this->log('');
|
|
$this->log(str_repeat('=', 70));
|
|
$this->log("Site: {$r['url']}");
|
|
|
|
if ($r['error'] !== '') {
|
|
$this->log(" Error: {$r['error']}");
|
|
continue;
|
|
}
|
|
|
|
$versionDisplay = $r['joomlaVersion'];
|
|
if ($r['behind'] && $latestVersion !== null) {
|
|
$versionDisplay .= " (BEHIND, latest: {$latestVersion})";
|
|
}
|
|
$this->log(" Joomla Version: {$versionDisplay}");
|
|
|
|
if (count($r['extensions']) === 0) {
|
|
$this->log(' Extensions: none found');
|
|
continue;
|
|
}
|
|
|
|
// Extension table
|
|
$colName = 4;
|
|
$colVersion = 7;
|
|
|
|
foreach ($r['extensions'] as $ext) {
|
|
$colName = max($colName, strlen($ext['name']));
|
|
$colVersion = max($colVersion, strlen($ext['version']));
|
|
}
|
|
|
|
$colName = min($colName, 50);
|
|
$colVersion = min($colVersion, 20);
|
|
|
|
$fmt = " %-{$colName}s | %-{$colVersion}s";
|
|
|
|
$this->log('');
|
|
$this->log(sprintf($fmt, 'Name', 'Version'));
|
|
$this->log(' ' . str_repeat('-', $colName + $colVersion + 4));
|
|
|
|
foreach ($r['extensions'] as $ext) {
|
|
$nameDisplay = strlen($ext['name']) > 50 ? substr($ext['name'], 0, 47) . '...' : $ext['name'];
|
|
$this->log(sprintf($fmt, $nameDisplay, $ext['version']));
|
|
}
|
|
}
|
|
|
|
$this->log('');
|
|
}
|
|
|
|
private function outputJson(): void
|
|
{
|
|
echo json_encode($this->results, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
|
|
}
|
|
|
|
private function log(string $message): void
|
|
{
|
|
fwrite(STDERR, $message . "\n");
|
|
}
|
|
}
|
|
|
|
$audit = new JoomlaVersionAudit();
|
|
exit($audit->run());
|