diff --git a/src/Package/V2Dumper.php b/src/Package/V2Dumper.php index c9c913231..875ad47a2 100644 --- a/src/Package/V2Dumper.php +++ b/src/Package/V2Dumper.php @@ -379,7 +379,15 @@ private function writeV2File(Package $package, string $name, string $path, strin // fetch the file to ensure the region we hit after has a hot cache so we can verify correctly that the purge worked if (file_exists($path)) { - $this->fetchCdnFile($relativePath); + try { + $this->cdnClient->fetchPublicMetadata($relativePath); + } catch (\Exception $e) { + // ignore 404s as if the file is not there yet it means it's unlikely to be cached in a bad state on some other region + if ($e->getCode() !== 404) { + throw $e; + } + $this->httpClient->reset(); + } } $this->filesystem->mkdir(\dirname($path)); diff --git a/src/Service/FilterListWorker.php b/src/Service/FilterListWorker.php index c42e117f8..6faf3c1ca 100644 --- a/src/Service/FilterListWorker.php +++ b/src/Service/FilterListWorker.php @@ -13,14 +13,20 @@ namespace App\Service; use App\Entity\FilterListEntry; +use App\Entity\Package; use App\FilterList\FilterLists; use App\FilterList\List\FilterListInterface; use App\FilterList\FilterListEntryUpdateListener; use App\FilterList\FilterListResolver; use App\Entity\Job; +use App\Model\DownloadManager; use Psr\Log\LoggerInterface; use Seld\Signal\SignalHandler; use Doctrine\Persistence\ManagerRegistry; +use Symfony\Component\Mailer\MailerInterface; +use Symfony\Component\Mime\Address; +use Symfony\Component\Mime\Email; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; final readonly class FilterListWorker { @@ -36,6 +42,10 @@ public function __construct( private array $filterLists, private FilterListResolver $malwareFeedResolver, private FilterListEntryUpdateListener $malwarePackageVersionUpdateListener, + private MailerInterface $mailer, + private DownloadManager $downloadManager, + private string $mailFromEmail, + private UrlGeneratorInterface $urlGenerator, ) {} /** @@ -78,6 +88,41 @@ public function process(Job $job, SignalHandler $signal): array $this->malwarePackageVersionUpdateListener->flushChangesToPackages(); + /** @var array> $newEntriesByPackage */ + $newEntriesByPackage = []; + foreach ($new as $entry) { + $newEntriesByPackage[$entry->getPackageName()][] = $entry; + } + + foreach ($newEntriesByPackage as $packageName => $entries) { + $package = $this->doctrine->getRepository(Package::class)->findOneBy(['name' => $packageName]); + $downloads = $package ? $this->downloadManager->getTotalDownloads($package->getId()) : 0; + $packageUrl = $this->urlGenerator->generate('view_package', ['name' => $packageName], UrlGeneratorInterface::ABSOLUTE_URL); + + if ($downloads >= 10_000) { + $subject = '[URGENT] Filter list entry added for high-download package ' . $packageName . ' (' . number_format($downloads) . ' downloads)'; + $body = 'A new filter list entry has been added for ' . $packageName . ' which has ' . number_format($downloads) . " total downloads. This requires urgent attention.\n\n"; + } else { + $subject = 'Filter list entry added for ' . $packageName; + $body = 'A new filter list entry has been added for ' . $packageName . ".\n\n"; + } + + $body .= 'Package: ' . $packageUrl . "\n" + . 'List: ' . $list->value . "\n" + . 'Versions: ' . implode(', ', array_map(fn (FilterListEntry $e) => $e->getVersion(), $entries)) . "\n" + . 'Reason: ' . ($entries[0]->getReason() ?? 'N/A') . "\n" + . 'Link: ' . ($entries[0]->getLink() ?? 'N/A') . "\n"; + + $message = (new Email()) + ->subject($subject) + ->from(new Address($this->mailFromEmail)) + ->to($this->mailFromEmail) + ->text($body) + ; + $message->getHeaders()->addTextHeader('X-Auto-Response-Suppress', 'OOF, DR, RN, NRN, AutoReply'); + $this->mailer->send($message); + } + $this->locker->unlockFilterList(self::FILTER_LIST_WORKER_RUN); return [ diff --git a/tests/FilterListWorkerTest.php b/tests/FilterListWorkerTest.php index 6cfe9a73b..e00d5bc72 100644 --- a/tests/FilterListWorkerTest.php +++ b/tests/FilterListWorkerTest.php @@ -15,11 +15,14 @@ use App\Entity\Job; use App\Entity\FilterListEntry; use App\Entity\FilterListEntryRepository; +use App\Entity\Package; +use App\Entity\PackageRepository; use App\FilterList\FilterLists; use App\FilterList\List\FilterListInterface; use App\FilterList\FilterListEntryUpdateListener; use App\FilterList\FilterListResolver; use App\FilterList\RemoteFilterListEntry; +use App\Model\DownloadManager; use App\Service\Locker; use App\Service\FilterListWorker; use Doctrine\DBAL\Connection; @@ -29,6 +32,8 @@ use PHPUnit\Framework\TestCase; use Psr\Log\NullLogger; use Seld\Signal\SignalHandler; +use Symfony\Component\Mailer\MailerInterface; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; class FilterListWorkerTest extends TestCase { @@ -36,14 +41,21 @@ class FilterListWorkerTest extends TestCase private FilterListInterface&MockObject $filterList; private EntityManager&MockObject $em; private FilterListEntryRepository&MockObject $filterListEntryRepository; + private PackageRepository $packageRepository; private Locker&MockObject $locker; + private MailerInterface&MockObject $mailer; + private DownloadManager $downloadManager; + private UrlGeneratorInterface $urlGenerator; protected function setUp(): void { $this->filterList = $this->createMock(FilterListInterface::class); $this->locker = $this->createMock(Locker::class); + $this->mailer = $this->createMock(MailerInterface::class); + $this->downloadManager = $this->createStub(DownloadManager::class); + $this->urlGenerator = $this->createStub(UrlGeneratorInterface::class); $doctrine = $this->createStub(ManagerRegistry::class); - $this->worker = new FilterListWorker($this->locker, new NullLogger(), $doctrine, [FilterLists::AIKIDO_MALWARE->value => $this->filterList], new FilterListResolver(), new FilterListEntryUpdateListener($doctrine)); + $this->worker = new FilterListWorker($this->locker, new NullLogger(), $doctrine, [FilterLists::AIKIDO_MALWARE->value => $this->filterList], new FilterListResolver(), new FilterListEntryUpdateListener($doctrine), $this->mailer, $this->downloadManager, 'test@example.com', $this->urlGenerator); $this->em = $this->createMock(EntityManager::class); @@ -56,11 +68,13 @@ protected function setUp(): void ->willReturn($this->createStub(Connection::class)); $this->filterListEntryRepository = $this->createMock(FilterListEntryRepository::class); + $this->packageRepository = $this->createStub(PackageRepository::class); $doctrine ->method('getRepository') ->willReturnMap([ [FilterListEntry::class, null, $this->filterListEntryRepository], + [Package::class, null, $this->packageRepository], ]); } @@ -94,6 +108,14 @@ public function testProcess(): void ->method('remove') ->with($this->identicalTo($existingEntryToBeDeleted)); + $this->urlGenerator + ->method('generate') + ->willReturn('https://packagist.org/packages/vendor/new-malware'); + + $this->mailer + ->expects($this->once()) + ->method('send'); + $result = $this->worker->process($this->createJob(), SignalHandler::create()); $this->assertSame(Job::STATUS_COMPLETED, $result['status']); @@ -205,5 +227,9 @@ private function expectNoPersistAndRemove(): void $this->em ->expects($this->never()) ->method('flush'); + + $this->mailer + ->expects($this->never()) + ->method('send'); } }