瀏覽代碼

Package: store security advisories for friends of php

Stephan Vock 5 年之前
父節點
當前提交
0fdab169fb

+ 82 - 0
src/Packagist/WebBundle/Entity/SecurityAdvisory.php

@@ -0,0 +1,82 @@
+<?php declare(strict_types=1);
+
+namespace Packagist\WebBundle\Entity;
+
+use Doctrine\ORM\Mapping as ORM;
+use Packagist\WebBundle\SecurityAdvisory\RemoteSecurityAdvisory;
+
+/**
+ * @ORM\Entity()
+ * @ORM\Table(
+ *     name="security_advisory",
+ *     uniqueConstraints={@ORM\UniqueConstraint(name="source_packagename_idx", columns={"source","packageName"})},
+ *     indexes={
+ *         @ORM\Index(name="package_name_idx",columns={"packageName"})
+ *     }
+ * )
+ */
+class SecurityAdvisory
+{
+    /**
+     * @ORM\Id
+     * @ORM\Column(type="integer")
+     * @ORM\GeneratedValue(strategy="AUTO")
+     */
+    private $id;
+
+    /**
+     * @ORM\Column(type="string")
+     */
+    private $remoteId;
+
+    /**
+     * @ORM\Column(type="string")
+     */
+    private $packageName;
+
+    /**
+     * @ORM\Column(type="string")
+     */
+    private $title;
+
+    /**
+     * @ORM\Column(type="string", nullable=true)
+     */
+    private $link;
+
+    /**
+     * @ORM\Column(type="string", nullable=true)
+     */
+    private $cve;
+
+    /**
+     * @ORM\Column(type="string")
+     */
+    private $affectedVersions;
+
+    /**
+     * @ORM\Column(type="string")
+     */
+    private $source;
+
+    public function __construct(RemoteSecurityAdvisory $advisory, string $source)
+    {
+        $this->source = $source;
+        $this->updateAdvisory($advisory);
+    }
+
+    public function updateAdvisory(RemoteSecurityAdvisory $advisory): void
+    {
+        $this->remoteId = $advisory->getId();
+        $this->packageName = $advisory->getPackageName();
+        $this->title = $advisory->getTitle();
+        $this->link = $advisory->getLink();
+        $this->cve = $advisory->getCve();
+        $this->affectedVersions = $advisory->getAffectedVersions();
+    }
+
+    public function getRemoteId(): string
+    {
+        return $this->remoteId;
+    }
+}

+ 17 - 0
src/Packagist/WebBundle/Resources/config/services.yml

@@ -29,6 +29,9 @@ services:
     Packagist\WebBundle\Entity\:
         resource: '../../Entity/*Repository.php'
 
+    Packagist\WebBundle\SecurityAdvisory\:
+        resource: '../../SecurityAdvisory/*.php'
+
     packagist.twig.extension:
         public: true
         class: Packagist\WebBundle\Twig\PackagistExtension
@@ -182,6 +185,7 @@ services:
             - "@logger"
             - 'package:updates': '@updater_worker'
               'githubuser:migrate': '@github_user_migration_worker'
+              'security:advisory': '@security_advisory_worker'
 
     Packagist\WebBundle\Security\TwoFactorAuthManager:
         public: true
@@ -201,6 +205,10 @@ services:
         tags:
             - { name: kernel.event_subscriber }
 
+    Packagist\WebBundle\SecurityAdvisory\FriendsOfPhpSecurityAdvisoriesSource:
+        class: Packagist\WebBundle\SecurityAdvisory\FriendsOfPhpSecurityAdvisoriesSource
+        arguments: ["@doctrine"]
+
     scheduler:
         public: true
         class: Packagist\WebBundle\Service\Scheduler
@@ -221,5 +229,14 @@ services:
         class: Packagist\WebBundle\Service\GitHubUserMigrationWorker
         arguments: ["@logger", "@doctrine", "@guzzle_client", "%github.webhook_secret%"]
 
+    security_advisory_worker:
+        public: true
+        class: Packagist\WebBundle\Service\SecurityAdvisoryWorker
+        arguments:
+            - "@locker"
+            - "@logger"
+            - "@doctrine"
+            - 'sensiolabs/security-advisories': "@Packagist\\WebBundle\\SecurityAdvisory\\FriendsOfPhpSecurityAdvisoriesSource"
+
 parameters:
     security.exception_listener.class: Packagist\WebBundle\Security\ExceptionListener

+ 77 - 0
src/Packagist/WebBundle/SecurityAdvisory/FriendsOfPhpSecurityAdvisoriesSource.php

@@ -0,0 +1,77 @@
+<?php declare(strict_types=1);
+
+namespace Packagist\WebBundle\SecurityAdvisory;
+
+use Composer\Downloader\TransportException;
+use Composer\Downloader\ZipDownloader;
+use Composer\Factory;
+use Composer\IO\ConsoleIO;
+use Composer\Package\CompletePackage;
+use Composer\Package\Loader\ArrayLoader;
+use Packagist\WebBundle\Entity\Package;
+use Psr\Log\LoggerInterface;
+use Symfony\Bridge\Doctrine\RegistryInterface;
+use Symfony\Component\Filesystem\Filesystem;
+use Symfony\Component\Finder\Finder;
+use Symfony\Component\Yaml\Yaml;
+
+class FriendsOfPhpSecurityAdvisoriesSource implements SecurityAdvisorySourceInterface
+{
+    public const SOURCE_NAME = self::SECURITY_PACKAGE;
+    public const SECURITY_PACKAGE = 'sensiolabs/security-advisories';
+
+    /** @var RegistryInterface */
+    private $doctrine;
+    /** @var LoggerInterface */
+    private $logger;
+
+    public function __construct(RegistryInterface $doctrine)
+    {
+        $this->doctrine = $doctrine;
+    }
+
+    public function getAdvisories(ConsoleIO $io): ?array
+    {
+        /** @var Package $package */
+        $package = $this->doctrine->getRepository(Package::class)->findOneBy(['name' => self::SECURITY_PACKAGE]);
+        if (!$package || !($version = $package->getVersion('9999999-dev'))) {
+            return [];
+        }
+
+        $config = Factory::createConfig($io);
+
+        $loader = new ArrayLoader(null, true);
+        /** @var CompletePackage $composerPackage */
+        $composerPackage = $loader->load($version->toArray([]), CompletePackage::class);
+
+        $localDir = null;
+        $advisories = null;
+        try {
+            $rfs = Factory::createRemoteFilesystem($io, $config, []);
+            $downloader = new ZipDownloader($io, $config, null, null, null, $rfs);
+            $downloader->setOutputProgress(false);
+            $localDir = sys_get_temp_dir() . '/' . uniqid('friends-of-php-advisories', true);
+            $downloader->download($composerPackage, $localDir);
+
+            $finder = new Finder();
+            $finder->name('*.yaml');
+            $advisories = [];
+            /** @var \SplFileInfo $file */
+            foreach ($finder->in($localDir) as $file) {
+                $content = Yaml::parse(file_get_contents($file->getRealPath()));
+                $advisories[] = RemoteSecurityAdvisory::createFromFriendsOfPhp($file->getRelativePathname(), $content);
+            }
+        } catch (TransportException $e) {
+            $this->logger->error('Failed to download "sensiolabs/security-advisories" zip file', [
+                'exception' => $e,
+            ]);
+        } finally {
+            if ($localDir) {
+                $filesystem = new Filesystem();
+                $filesystem->remove($localDir);
+            }
+        }
+
+        return $advisories;
+    }
+}

+ 75 - 0
src/Packagist/WebBundle/SecurityAdvisory/RemoteSecurityAdvisory.php

@@ -0,0 +1,75 @@
+<?php declare(strict_types=1);
+
+namespace Packagist\WebBundle\SecurityAdvisory;
+
+class RemoteSecurityAdvisory
+{
+    /** @var string */
+    private $id;
+    /** @var string */
+    private $title;
+    /** @var string */
+    private $packageName;
+    /** @var string */
+    private $affectedVersions;
+    /** @var string */
+    private $link;
+    /** @var ?string */
+    private $cve;
+
+    public function __construct(string $id, string $title, string $packageName, string $affectedVersions, string $link, $cve)
+    {
+        $this->id = $id;
+        $this->title = $title;
+        $this->packageName = $packageName;
+        $this->affectedVersions = $affectedVersions;
+        $this->link = $link;
+        $this->cve = $cve;
+    }
+
+    public function getId(): string
+    {
+        return $this->id;
+    }
+
+    public function getTitle(): string
+    {
+        return $this->title;
+    }
+
+    public function getPackageName(): string
+    {
+        return $this->packageName;
+    }
+
+    public function getAffectedVersions(): string
+    {
+        return $this->affectedVersions;
+    }
+
+    public function getLink(): string
+    {
+        return $this->link;
+    }
+
+    public function getCve(): ?string
+    {
+        return $this->cve;
+    }
+
+    public static function createFromFriendsOfPhp(string $fileNameWithPath, array $info): RemoteSecurityAdvisory
+    {
+        $affectedVersion = implode('|', array_map(function (array $branchInfo) {
+            return implode(',', $branchInfo['versions']);
+        }, $info['branches']));
+
+        return new RemoteSecurityAdvisory(
+            $fileNameWithPath,
+            $info['title'],
+            str_replace('composer://', '', $info['reference']),
+            $affectedVersion,
+            $info['link'],
+            $info['cve'] ?? null
+        );
+    }
+}

+ 13 - 0
src/Packagist/WebBundle/SecurityAdvisory/SecurityAdvisorySourceInterface.php

@@ -0,0 +1,13 @@
+<?php declare(strict_types=1);
+
+namespace Packagist\WebBundle\SecurityAdvisory;
+
+use Composer\IO\ConsoleIO;
+
+interface SecurityAdvisorySourceInterface
+{
+    /**
+     * @return null|RemoteSecurityAdvisory[]
+     */
+    public function getAdvisories(ConsoleIO $io): ?array;
+}

+ 14 - 0
src/Packagist/WebBundle/Service/Locker.php

@@ -27,6 +27,20 @@ class Locker
         $this->getConn()->fetchColumn('SELECT RELEASE_LOCK(:id)', ['id' => 'package_update_'.$packageId]);
     }
 
+    public function lockSecurityAdvisory(string $source, int $timeout = 0)
+    {
+        $this->getConn()->connect('master');
+
+        return (bool) $this->getConn()->fetchColumn('SELECT GET_LOCK(:id, :timeout)', ['id' => 'security_advisory_'.$source, 'timeout' => $timeout]);
+    }
+
+    public function unlockSecurityAdvisory(string $source)
+    {
+        $this->getConn()->connect('master');
+
+        $this->getConn()->fetchColumn('SELECT RELEASE_LOCK(:id)', ['id' => 'security_advisory_'.$source]);
+    }
+
     public function lockCommand(string $command, int $timeout = 0)
     {
         $this->getConn()->connect('master');

+ 5 - 0
src/Packagist/WebBundle/Service/Scheduler.php

@@ -61,6 +61,11 @@ class Scheduler
         return $this->createJob('githubuser:migrate', ['id' => $userId, 'old_scope' => $oldScope, 'new_scope' => $newScope], $userId);
     }
 
+    public function scheduleSecurityAdvisory(string $source, \DateTimeInterface $executeAfter = null): Job
+    {
+        return $this->createJob('security:advisory', ['source' => $source], null, $executeAfter);
+    }
+
     private function getPendingUpdateJob(int $packageId, $updateEqualRefs = false, $deleteBefore = false)
     {
         $result = $this->doctrine->getManager()->getConnection()->fetchAssoc(

+ 85 - 0
src/Packagist/WebBundle/Service/SecurityAdvisoryWorker.php

@@ -0,0 +1,85 @@
+<?php declare(strict_types=1);
+
+namespace Packagist\WebBundle\Service;
+
+use Composer\Console\HtmlOutputFormatter;
+use Composer\Factory;
+use Composer\IO\BufferIO;
+use Packagist\WebBundle\Entity\Job;
+use Packagist\WebBundle\Entity\SecurityAdvisory;
+use Packagist\WebBundle\SecurityAdvisory\SecurityAdvisorySourceInterface;
+use Psr\Log\LoggerInterface;
+use Seld\Signal\SignalHandler;
+use Symfony\Bridge\Doctrine\RegistryInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class SecurityAdvisoryWorker
+{
+    /** @var Locker */
+    private $locker;
+    /** @var LoggerInterface */
+    private $logger;
+    /** @var RegistryInterface */
+    private $doctrine;
+    /** @var SecurityAdvisorySourceInterface[] */
+    private $sources;
+
+    public function __construct(Locker $locker, LoggerInterface $logger, RegistryInterface $doctrine, array $sources)
+    {
+        $this->locker = $locker;
+        $this->sources = $sources;
+        $this->logger = $logger;
+        $this->doctrine = $doctrine;
+    }
+
+    public function process(Job $job, SignalHandler $signal): array
+    {
+        $sourceName = $job->getPayload()['source'];
+        $lockAcquired = $this->locker->lockSecurityAdvisory($sourceName);
+        if (!$lockAcquired) {
+            return ['status' => Job::STATUS_RESCHEDULE, 'after' => new \DateTime('+5 minutes')];
+        }
+
+        $io = new BufferIO('', OutputInterface::VERBOSITY_VERY_VERBOSE, new HtmlOutputFormatter(Factory::createAdditionalStyles()));
+
+        /** @var SecurityAdvisorySourceInterface $source */
+        $source = $this->sources[$sourceName];
+        $remoteAdvisories = $source->getAdvisories($io);
+        if (null === $remoteAdvisories) {
+            $this->logger->info('Security advisory update failed, skipping', ['source' => $source]);
+
+            return ['status' => Job::STATUS_ERRORED, 'message' => 'Security advisory update failed, skipped'];
+        }
+
+        /** @var SecurityAdvisory[] $existingAdvisoryMap */
+        $existingAdvisoryMap = [];
+        /** @var SecurityAdvisory[] $existingAdvisories */
+        $existingAdvisories = $this->doctrine->getRepository(SecurityAdvisory::class)->findBy(['source' => $sourceName]);
+        foreach ($existingAdvisories as $advisory) {
+            $existingAdvisoryMap[$advisory->getRemoteId()] = $advisory;
+        }
+
+        foreach ($remoteAdvisories as $remoteAdvisory) {
+            if (isset($existingAdvisoryMap[$remoteAdvisory->getId()])) {
+                $existingAdvisoryMap[$remoteAdvisory->getId()]->updateAdvisory($remoteAdvisory);
+                unset($existingAdvisoryMap[$remoteAdvisory->getId()]);
+            } else {
+                $this->doctrine->getManager()->persist(new SecurityAdvisory($remoteAdvisory, $sourceName));
+            }
+        }
+
+        foreach ($existingAdvisoryMap as $advisory) {
+            $this->doctrine->getManager()->remove($advisory);
+        }
+
+        $this->doctrine->getManager()->flush();
+
+        $this->locker->unlockSecurityAdvisory($sourceName);
+
+        return [
+            'status' => Job::STATUS_COMPLETED,
+            'message' => 'Update of '.$sourceName.' security advisory complete',
+            'details' => '<pre>'.$io->getOutput().'</pre>',
+        ];
+    }
+}

+ 5 - 0
src/Packagist/WebBundle/Service/UpdaterWorker.php

@@ -2,6 +2,7 @@
 
 namespace Packagist\WebBundle\Service;
 
+use Packagist\WebBundle\SecurityAdvisory\FriendsOfPhpSecurityAdvisoriesSource;
 use Packagist\WebBundle\Service\Scheduler;
 use Psr\Log\LoggerInterface;
 use Composer\Package\Loader\ArrayLoader;
@@ -245,6 +246,10 @@ class UpdaterWorker
             $this->locker->unlockPackageUpdate($id);
         }
 
+        if ($packageName === FriendsOfPhpSecurityAdvisoriesSource::SECURITY_PACKAGE) {
+            $this->scheduler->scheduleSecurityAdvisory(FriendsOfPhpSecurityAdvisoriesSource::SOURCE_NAME);
+        }
+
         return [
             'status' => Job::STATUS_COMPLETED,
             'message' => 'Update of '.$packageName.' complete',

+ 34 - 0
src/Packagist/WebBundle/Tests/SecurityAdvisory/RemoteSecurityAdvisoryTest.php

@@ -0,0 +1,34 @@
+<?php declare(strict_types=1);
+
+namespace Packagist\WebBundle\Tests\SecurityAdvisory;
+
+use Packagist\WebBundle\SecurityAdvisory\RemoteSecurityAdvisory;
+use PHPUnit\Framework\TestCase;
+
+class RemoteSecurityAdvisoryTest extends TestCase
+{
+    public function testCreateFromFriendsOfPhp(): void
+    {
+        $advisory = RemoteSecurityAdvisory::createFromFriendsOfPhp('3f/pygmentize/2017-05-15.yaml', [
+            'title' => 'Remote Code Execution',
+            'link' => 'https://github.com/dedalozzo/pygmentize/issues/1',
+            'cve' => null,
+            'branches' => [
+                '1.x' => [
+                    'time' => '2017-05-15 09:09:00',
+                    'versions' => ['<1.2'],
+                ],
+            ],
+            'reference' => 'composer://3f/pygmentize'
+        ]);
+
+        $this->assertSame('3f/pygmentize/2017-05-15.yaml', $advisory->getId());
+        $this->assertSame('Remote Code Execution', $advisory->getTitle());
+        $this->assertSame('https://github.com/dedalozzo/pygmentize/issues/1', $advisory->getLink());
+        $this->assertNull($advisory->getCve());
+        $this->assertSame('<1.2', $advisory->getAffectedVersions());
+        $this->assertSame('3f/pygmentize', $advisory->getPackageName());
+
+
+    }
+}

+ 146 - 0
src/Packagist/WebBundle/Tests/SecurityAdvisoryWorkerTest.php

@@ -0,0 +1,146 @@
+<?php declare(strict_types=1);
+
+namespace Packagist\WebBundle\Tests;
+
+use Doctrine\ORM\EntityManager;
+use Doctrine\ORM\EntityRepository;
+use Packagist\WebBundle\Entity\Job;
+use Packagist\WebBundle\Entity\SecurityAdvisory;
+use Packagist\WebBundle\SecurityAdvisory\RemoteSecurityAdvisory;
+use Packagist\WebBundle\SecurityAdvisory\SecurityAdvisorySourceInterface;
+use Packagist\WebBundle\Service\Locker;
+use Packagist\WebBundle\Service\SecurityAdvisoryWorker;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\NullLogger;
+use Seld\Signal\SignalHandler;
+use Symfony\Bridge\Doctrine\RegistryInterface;
+
+class SecurityAdvisoryWorkerTest extends TestCase
+{
+    /** @var SecurityAdvisoryWorker */
+    private $worker;
+    /** @var SecurityAdvisorySourceInterface&\PHPUnit\Framework\MockObject\MockObject */
+    private $source;
+    /** @var EntityManager&\PHPUnit\Framework\MockObject\MockObject */
+    private $em;
+    /** @var \PHPUnit\Framework\MockObject\MockObject */
+    private $securityAdvisoryRepository;
+
+    protected function setUp(): void
+    {
+        $this->source = $this->getMockBuilder(SecurityAdvisorySourceInterface::class)->disableOriginalConstructor()->getMock();
+        $locker = $this->getMockBuilder(Locker::class)->disableOriginalConstructor()->getMock();
+        $doctrine = $this->getMockBuilder(RegistryInterface::class)->disableOriginalConstructor()->getMock();
+        $this->worker = new SecurityAdvisoryWorker($locker, new NullLogger(), $doctrine, ['test' => $this->source]);
+
+        $this->em = $this->getMockBuilder(EntityManager::class)->disableOriginalConstructor()->getMock();
+
+        $doctrine
+            ->method('getManager')
+            ->willReturn($this->em);
+
+        $locker
+            ->method('lockSecurityAdvisory')
+            ->willReturn(true);
+
+        $this->securityAdvisoryRepository = $this->getMockBuilder(EntityRepository::class)->disableOriginalConstructor()->getMock();
+
+        $doctrine
+            ->method('getRepository')
+            ->with($this->equalTo(SecurityAdvisory::class))
+            ->willReturn($this->securityAdvisoryRepository);
+    }
+
+    public function testProcess(): void
+    {
+        $advisory1Existing = $this->getMockBuilder(RemoteSecurityAdvisory::class)->disableOriginalConstructor()->getMock();
+        $advisory2New = $this->getMockBuilder(RemoteSecurityAdvisory::class)->disableOriginalConstructor()->getMock();
+        $advisories = [
+            $advisory1Existing,
+            $advisory2New,
+        ];
+
+        $advisory1Existing
+            ->method('getId')
+            ->willReturn('remote-id-1');
+
+        $existingAdvisory1 = $this->getMockBuilder(SecurityAdvisory::class)->disableOriginalConstructor()->getMock();
+        $existingAdvisory1
+            ->method('getRemoteId')
+            ->willReturn('remote-id-1');
+
+        $existingAdvisory1
+            ->expects($this->once())
+            ->method('updateAdvisory')
+            ->with($this->equalTo($advisory1Existing));
+
+        $existingAdvisory2ToBeDeleted = $this->getMockBuilder(SecurityAdvisory::class)->disableOriginalConstructor()->getMock();
+        $existingAdvisory2ToBeDeleted
+            ->method('getRemoteId')
+            ->willReturn('to-be-deleted');
+
+        $this->source
+            ->expects($this->once())
+            ->method('getAdvisories')
+            ->willReturn($advisories);
+
+        $this->em
+            ->expects($this->once())
+            ->method('persist');
+
+        $this->em
+            ->expects($this->once())
+            ->method('remove')
+            ->with($this->equalTo($existingAdvisory2ToBeDeleted));
+
+        $this->securityAdvisoryRepository
+            ->method('findBy')
+            ->with($this->equalTo(['source' => 'test']))
+            ->willReturn([$existingAdvisory1, $existingAdvisory2ToBeDeleted]);
+
+        $job = new Job();
+        $job->setPayload(['source' => 'test']);
+        $this->worker->process($job, SignalHandler::create());
+    }
+
+    public function testProcessNone(): void
+    {
+        $this->source
+            ->expects($this->once())
+            ->method('getAdvisories')
+            ->willReturn([]);
+
+        $this->em
+            ->expects($this->never())
+            ->method('persist');
+
+        $this->securityAdvisoryRepository
+            ->method('findBy')
+            ->with($this->equalTo(['source' => 'test']))
+            ->willReturn([]);
+
+        $job = new Job();
+        $job->setPayload(['source' => 'test']);
+        $this->worker->process($job, SignalHandler::create());
+    }
+
+    public function testProcessFailed(): void
+    {
+        $this->source
+            ->expects($this->once())
+            ->method('getAdvisories')
+            ->willReturn(null);
+
+        $this->em
+            ->expects($this->never())
+            ->method('flush');
+
+        $this->securityAdvisoryRepository
+            ->expects($this->never())
+            ->method('findBy');
+
+        $job = new Job();
+        $job->setPayload(['source' => 'test']);
+        $this->worker->process($job, SignalHandler::create());
+    }
+}