Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion bin/console.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use Gared\EtherScan\Console\GenerateFileHashesCommand;
use Gared\EtherScan\Console\GenerateRevisionLookupCommand;
use Gared\EtherScan\Console\ScanCommand;
use Gared\EtherScan\Console\GenerateFileHashesAllVersionsCommand;
use Symfony\Component\Console\Application;

require __DIR__ . '/../vendor/autoload.php';
Expand All @@ -15,4 +16,5 @@
$application->add(new GenerateRevisionLookupCommand());
$application->add(new GenerateFileHashesCommand());
$application->add(new CheckFileHashesCommand());
$application->run();
$application->add(new GenerateFileHashesAllVersionsCommand());
$application->run();
226 changes: 226 additions & 0 deletions src/Console/GenerateFileHashesAllVersionsCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
<?php
declare(strict_types=1);

namespace Gared\EtherScan\Console;

use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\RequestOptions;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

#[AsCommand(
name: 'ether:generate-file-hashes-all-versions',
description: 'Generate file hashes for specific file on all versions of etherpad'
)]
class GenerateFileHashesAllVersionsCommand extends Command
{
protected function configure(): void
{
$this
->addOption('matches-count', null, InputArgument::OPTIONAL, 'Minimum count of matches for version to be considered valid', 3)
->addArgument('file', InputArgument::REQUIRED, 'File path to check');
Comment on lines +24 to +26
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The option is registered using InputArgument::OPTIONAL, but addOption() expects InputOption mode constants (e.g., VALUE_OPTIONAL / VALUE_REQUIRED). With the current code the option may not be parsed as intended on different Symfony versions; switch to Symfony\Component\Console\Input\InputOption and the appropriate VALUE_* constant.

Copilot uses AI. Check for mistakes.
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
$filePath = $input->getArgument('file');
$countVersionsMatch = $input->getOption('matches-count');

$allInstances = $this->getInstances();
$instanceResults = new InstanceResults();

foreach ($this->getAllVersions($allInstances) as $version) {
$this->scanVersionInstances($allInstances, $version, $filePath, $instanceResults, $output, $countVersionsMatch);
}
Comment on lines +31 to +39
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because strict_types is enabled, $input->getOption('matches-count') will be a string when provided via CLI, but scanVersionInstances() requires an int. This will throw a TypeError when the option is used; cast/validate the option value to int (and consider rejecting non-numeric input).

Copilot uses AI. Check for mistakes.

$listInstances = $instanceResults->getInstancesByVersion();
uksort($listInstances, function ($a, $b) {
return version_compare($a, $b);
});

$versionRanges = [];

$table = new Table($output);
$table->setHeaders(['Version', 'Count Instances', 'File Hashes']);
foreach ($listInstances as $version => $instances) {
$fileHashes = [];
$fileHashForVersion = null;
foreach ($instances as $instance) {
if ($instance->fileHash !== null) {
$fileHashes[] = $instance->fileHash;
}
}

$fileHashesWithCount = array_count_values($fileHashes);
foreach ($fileHashesWithCount as $fileHash => $count) {
if ($count >= $countVersionsMatch) {
$fileHashForVersion = $fileHash;
}
}

if ($fileHashForVersion !== null) {
$versionRanges[$fileHashForVersion][] = $version;
}


$versionString = '<info>' . $version . '</info>';
if (count($instances) < $countVersionsMatch) {
$versionString = '<comment>' . $version . '</comment>';
} else if ($fileHashForVersion === null) {
$versionString = '<error>' . $version . '</error>';
}

$table->addRow([$versionString, count($instances), ...$fileHashes]);
}

$table->render();


$table = new Table($output);
$table->setHeaders(['File Hash', 'Minimum Version', 'Maximum Version']);

foreach ($versionRanges as $fileHash => $versions) {
usort($versions, function ($a, $b) {
return version_compare($a, $b);
});

$minimumVersion = $versions[array_key_first($versions)];
$maximumVersion = $versions[array_key_last($versions)];

$table->addRow([$fileHash, $minimumVersion, $maximumVersion]);
}

$table->render();

return self::SUCCESS;
}

/**
* @param list<array{name: string, scan: array<mixed>}> $allInstances
*/
private function scanVersionInstances(array $allInstances, string $version, string $file, InstanceResults $instanceResults, OutputInterface $output, int $countVersionsMatchNeeded): void
{
$foundMatchesForHash = [];
$scannedInstances = 0;

foreach ($this->getInstancesByVersion($allInstances, $version) as $instance) {
$fileContent = $this->getFile($instance['name'], $file);
$fileHash = $fileContent !== null ? hash('md5', $fileContent) : null;
$scannedInstances++;

$instanceResult = new InstanceResult($instance['name'], $version, $fileHash, $fileContent);
$instanceResults->add($instanceResult);

if ($instanceResult->fileHash === null) {
$output->writeln('<error>Could not get hash for instance ' . $instance['name'] . '</error>', OutputInterface::VERBOSITY_VERY_VERBOSE);

if ($scannedInstances > 4 && count($foundMatchesForHash) === 0) {
break;
}

continue;
}

if ($this->matches($instanceResults, $instanceResult, $version)) {
$output->writeln('Match found for version ' . $version . ' and hash ' . $instanceResult->fileHash);

Comment on lines +116 to +131
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

matches() will always return true because the current InstanceResult is added to InstanceResults before calling matches(), and matches() compares against all instances for the version (including itself). This makes the match logic ineffective; either check for matches before adding, or have matches() ignore the current instance (e.g., by name).

Copilot uses AI. Check for mistakes.
if (!isset($foundMatchesForHash[$instanceResult->fileHash])) {
$foundMatchesForHash[$instanceResult->fileHash] = 0;
}

$foundMatchesForHash[$instanceResult->fileHash]++;
if ($foundMatchesForHash[$instanceResult->fileHash] === $countVersionsMatchNeeded) {
break;
}
} else {
// $output->writeln('<error>Mismatch found for version ' . $version . ' and hashes ' . $fileHash . ' != ' . $lastInstanceResult->fileHash . ' for servers: ' . $instanceResult->name . ', ' . $lastInstanceResult->name . '</error>');
// $output->writeln($lastInstanceResult->name . ': (' . mb_strlen($lastInstanceResult->fileContent) . ') ' . mb_substr($lastInstanceResult->fileContent, 0, 500), OutputInterface::VERBOSITY_DEBUG);
// $output->writeln($instanceResult->name . ': (' . mb_strlen($instanceResult->fileContent) . ') ' . mb_substr($instanceResult->fileContent, 0, 500), OutputInterface::VERBOSITY_DEBUG);
}
}
}

private function matches(InstanceResults $instanceResults, InstanceResult $instanceResult, string $version): bool
{
foreach ($instanceResults->getInstancesForVersion($version) as $instance) {
if ($instance->fileHash === $instanceResult->fileHash) {
return true;
}
}

return false;
}

/**
* @param list<array{name: string, scan: array<mixed>}> $instances
* @param string $version
* @return list<array{name: string, scan: array<mixed>}>
*/
private function getInstancesByVersion(array $instances, string $version): array
{
$filteredInstances = [];
foreach ($instances as $instance) {
if ($instance['scan']['version'] === $version) {
$filteredInstances[] = $instance;
}
}
shuffle($filteredInstances);
return $filteredInstances;
}

/**
* @param list<array{name: string, scan: array<mixed>}> $instances
* @return list<string>
*/
private function getAllVersions(array $instances): array
{
$versions = [];
foreach ($instances as $instance) {
$version = $instance['scan']['version'];
if (!in_array($version, $versions, true)) {
$versions[] = $version;
}
}
shuffle($versions);
return $versions;
}

/**
* @return list<array{name: string, scan: array<mixed>}>
*/
private function getInstances(): array
{
$client = new Client();
$response = $client->get('https://ether-scan.stefans-entwicklerecke.de/api/instances');

$body = (string)$response->getBody();
$data = json_decode($body, true);
return $data['instances'] ?? [];
Comment on lines +198 to +203
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getInstances() does not handle request failures and assumes json_decode() returns an array. If the API is unreachable or returns invalid JSON, $data will be null and $data['instances'] will throw ("Cannot access offset on null"). Add exception handling/timeouts and validate that decoded data is an array before indexing.

Suggested change
$client = new Client();
$response = $client->get('https://ether-scan.stefans-entwicklerecke.de/api/instances');
$body = (string)$response->getBody();
$data = json_decode($body, true);
return $data['instances'] ?? [];
try {
$client = new Client([
RequestOptions::TIMEOUT => 3.0,
RequestOptions::CONNECT_TIMEOUT => 1.0,
]);
$response = $client->get('https://ether-scan.stefans-entwicklerecke.de/api/instances');
$body = (string) $response->getBody();
$data = json_decode($body, true);
if (!is_array($data)) {
return [];
}
$instances = $data['instances'] ?? [];
return is_array($instances) ? $instances : [];
} catch (GuzzleException) {
return [];
}

Copilot uses AI. Check for mistakes.
}

private function getFile(string $url, string $path): ?string
{
try {
$client = new Client([
'base_uri' => $url,
RequestOptions::TIMEOUT => 3.0,
RequestOptions::CONNECT_TIMEOUT => 1.0,
'verify' => false,
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The GuzzleHttp\Client in getFile() is configured with 'verify' => false, which disables TLS certificate validation for HTTPS requests to instance URLs. An attacker who can intercept or spoof network traffic (e.g., via DNS poisoning or a malicious proxy) could serve tampered file contents, leading to incorrect hash generation and undermining the integrity of the scan results. Remove the verify override and rely on default certificate verification (or a properly configured CA bundle) so that HTTPS connections are authenticated and protected against man-in-the-middle attacks.

Suggested change
'verify' => false,

Copilot uses AI. Check for mistakes.
// 'debug' => true,
]);
$response = $client->get($path, [
'headers' => ['Accept-Encoding' => 'gzip'],
]);

return (string)$response->getBody();
} catch (GuzzleException) {
}

return null;
}
}
16 changes: 16 additions & 0 deletions src/Console/InstanceResult.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new file is missing declare(strict_types=1); after the opening <?php. The rest of src/ consistently enables strict types, so this should match to avoid accidental scalar coercions.

Suggested change
<?php
<?php
declare(strict_types=1);

Copilot uses AI. Check for mistakes.
declare(strict_types=1);

namespace Gared\EtherScan\Console;

class InstanceResult
{
public function __construct(
public readonly string $name,
public readonly string $version,
public readonly ?string $fileHash = null,
public readonly ?string $fileContent = null,
)
{
}
}
47 changes: 47 additions & 0 deletions src/Console/InstanceResults.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new file is missing declare(strict_types=1); after the opening <?php. The rest of src/ consistently enables strict types, so this should match to avoid accidental scalar coercions.

Suggested change
<?php
<?php
declare(strict_types=1);

Copilot uses AI. Check for mistakes.
declare(strict_types=1);

namespace Gared\EtherScan\Console;

class InstanceResults
{
/**
* @var list<InstanceResult>
*/
private array $instances = [];

public function add(InstanceResult $instanceResult): void
{
$this->instances[] = $instanceResult;
}

/**
* @return array<string, list<InstanceResult>>
*/
public function getInstancesByVersion(): array
{
$result = [];
foreach ($this->instances as $instance) {
$version = $instance->version;
if (!isset($result[$version])) {
$result[$version] = [];
}
$result[$version][] = $instance;
}
return $result;
}

/**
* @return list<InstanceResult>
*/
public function getInstancesForVersion(string $version): array
{
$result = [];
foreach ($this->instances as $instance) {
if ($instance->version === $version) {
$result[] = $instance;
}
}
return $result;
}
}
8 changes: 4 additions & 4 deletions src/Service/FileHashLookupService.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ class FileHashLookupService
/**
* @var array<string, array<string, array<string|null>>>
*/
private const FILE_HASH_VERSIONS = [
private const array FILE_HASH_VERSIONS = [
'static/js/AttributePool.js' => [
'4edf12f374e005bfa7d0fc6681caa67f' => [null, '1.8.0'],
'64ac4ec21f716d36d37a4b1a9aa0debe' => ['1.8.3', '1.8.4'],
Expand Down Expand Up @@ -71,15 +71,15 @@ class FileHashLookupService
'fc1965c84113e78fb5b29b68c8fc84f8' => ['1.9.1', '1.9.1'],
'e1d8c5fc1e4fcfe28b527828543a4729' => ['1.9.2', '2.1.0'],
'96fd880e3e348fe4b45170b7c750a0b1' => ['2.1.1', '2.1.1'],
'a9aa5b16c8e3ff79933156220cb87dbf' => ['2.2.2', null],
'a9aa5b16c8e3ff79933156220cb87dbf' => ['2.2.2', '2.2.2'],
],
'static/css/pad.css' => [
'169c79ec1a44c5c45dfce64c0f62c7ef' => [null, '1.9.7'],
'2a37d1ffbd906c905fe7f1b42564caa5' => ['2.0.0', '2.1.0'],
'8fab111c95434eac9414f0d8ea5d81b8' => ['2.1.1', '2.1.1'],
'8ae26862f7716d1bada457fdc92bb1d1' => ['2.2.2', '2.3.2'],
'12ba3a5933f399b882cf847d407c31f0' => ['2.4.1', '2.5.0'],
'53c72fe8218c95773dcfce173dacb3f6' => ['2.5.1', null],
'12ba3a5933f399b882cf847d407c31f0' => ['2.4.1', '2.5.1'],
'53c72fe8218c95773dcfce173dacb3f6' => ['2.5.2', null],
],
'static/skins/colibris/index.js' => [
'eb3857ee08d0c2217649dcb61b1c8e73' => ['2.1.1', '2.2.7'],
Expand Down