Browse Source

Merge pull request #1046 from glaubinix/t/security-advisories

Security Advisories
Jordi Boggiano 5 years ago
parent
commit
e4365ad4ee

+ 29 - 0
src/Packagist/WebBundle/Controller/ApiController.php

@@ -13,6 +13,7 @@
 namespace Packagist\WebBundle\Controller;
 
 use Packagist\WebBundle\Entity\Package;
+use Packagist\WebBundle\Entity\SecurityAdvisory;
 use Packagist\WebBundle\Entity\User;
 use Packagist\WebBundle\Util\UserAgentParser;
 use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
@@ -246,6 +247,34 @@ class ApiController extends Controller
         return new JsonResponse(array('status' => 'success'), 201);
     }
 
+    /**
+     * @Route(
+     *     "/api/security-advisories/",
+     *     name="api_security_adivosries",
+     *     defaults={"_format" = "json"},
+     *     methods={"GET", "POST"}
+     * )
+     */
+    public function securityAdvisoryAction(Request $request): JsonResponse
+    {
+        $packageNames = array_filter((array) $request->get('packages'));
+        if ((!$request->query->has('updatedSince') && !$request->get('packages')) || (!$packageNames && $request->get('packages'))) {
+            return new JsonResponse(['status' => 'error', 'message' => 'Missing array of package names as the "packages" parameter'], 400);
+        }
+
+        $updatedSince = $request->query->getInt('updatedSince', 0);
+
+        /** @var array[] $advisories */
+        $advisories = $this->getDoctrine()->getRepository(SecurityAdvisory::class)->searchSecurityAdvisories($packageNames, $updatedSince);
+
+        $response = ['advisories' => []];
+        foreach ($advisories as $advisory) {
+            $response['advisories'][$advisory['packageName']][] = $advisory;
+        }
+
+        return new JsonResponse($response, 200);
+    }
+
     /**
      * @param string $name
      * @param string $version

+ 61 - 0
src/Packagist/WebBundle/Controller/PackageController.php

@@ -3,11 +3,14 @@
 namespace Packagist\WebBundle\Controller;
 
 use Composer\Package\Version\VersionParser;
+use Composer\Semver\Constraint\Constraint;
 use DateTimeImmutable;
 use Doctrine\ORM\NoResultException;
 use Packagist\WebBundle\Entity\Download;
 use Packagist\WebBundle\Entity\Package;
 use Packagist\WebBundle\Entity\PackageRepository;
+use Packagist\WebBundle\Entity\SecurityAdvisory;
+use Packagist\WebBundle\Entity\SecurityAdvisoryRepository;
 use Packagist\WebBundle\Entity\Version;
 use Packagist\WebBundle\Entity\Vendor;
 use Packagist\WebBundle\Entity\VersionRepository;
@@ -485,6 +488,21 @@ class PackageController extends Controller
         $data['dependents'] = $repo->getDependantCount($package->getName());
         $data['suggesters'] = $repo->getSuggestCount($package->getName());
 
+        /** @var SecurityAdvisoryRepository $securityAdvisoryRepository */
+        $securityAdvisoryRepository = $this->getDoctrine()->getRepository(SecurityAdvisory::class);
+        $securityAdvisories = $securityAdvisoryRepository->getPackageSecurityAdvisories($package->getName());
+        $data['securityAdvisories'] = count($securityAdvisories);
+        $data['hasVersionSecurityAdvisories'] = [];
+        foreach ($securityAdvisories as $advisory) {
+            $versionParser = new VersionParser();
+            $affectedVersionConstraint = $versionParser->parseConstraints($advisory['affectedVersions']);
+            foreach ($versions as $version) {
+                if (!isset($data['hasVersionSecurityAdvisories'][$version->getId()]) && $affectedVersionConstraint->matches(new Constraint('=', $version->getNormalizedVersion()))) {
+                    $data['hasVersionSecurityAdvisories'][$version->getId()] = true;
+                }
+            }
+        }
+
         if ($maintainerForm = $this->createAddMaintainerForm($package)) {
             $data['addMaintainerForm'] = $maintainerForm->createView();
         }
@@ -1120,6 +1138,49 @@ class PackageController extends Controller
         return $this->overallStatsAction($req, $package, $version);
     }
 
+    /**
+     * @Route(
+     *      "/packages/{name}/advisories",
+     *      name="view_package_advisories",
+     *      requirements={"name"="([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+?|ext-[A-Za-z0-9_.-]+?)"}
+     * )
+     */
+    public function securityAdvisoriesAction(Request $request, $name)
+    {
+        /** @var SecurityAdvisoryRepository $repo */
+        $repo = $this->getDoctrine()->getRepository(SecurityAdvisory::class);
+        $securityAdvisories = $repo->getPackageSecurityAdvisories($name);
+
+        $data = [];
+        $data['name'] = $name;
+
+        $data['matchingAdvisories'] = [];
+        if ($versionId = $request->query->getInt('version')) {
+            $version = $this->getDoctrine()->getRepository(Version::class)->findOneBy([
+                'name' => $name,
+                'id' => $versionId,
+            ]);
+            if ($version) {
+                $versionSecurityAdvisories = [];
+                $versionParser = new VersionParser();
+                foreach ($securityAdvisories as $advisory) {
+                    $affectedVersionConstraint = $versionParser->parseConstraints($advisory['affectedVersions']);
+                    if ($affectedVersionConstraint->matches(new Constraint('=', $version->getNormalizedVersion()))) {
+                        $versionSecurityAdvisories[] = $advisory;
+                    }
+                }
+
+                $data['version'] = $version->getVersion();
+                $securityAdvisories = $versionSecurityAdvisories;
+            }
+        }
+
+        $data['securityAdvisories'] = $securityAdvisories;
+        $data['count'] = count($securityAdvisories);
+
+        return $this->render('PackagistWebBundle:package:security_advisories.html.twig', $data);
+    }
+
     private function createAddMaintainerForm(Package $package)
     {
         if (!$user = $this->getUser()) {

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

@@ -0,0 +1,129 @@
+<?php declare(strict_types=1);
+
+namespace Packagist\WebBundle\Entity;
+
+use Doctrine\ORM\Mapping as ORM;
+use Packagist\WebBundle\SecurityAdvisory\RemoteSecurityAdvisory;
+
+/**
+ * @ORM\Entity(repositoryClass="Packagist\WebBundle\Entity\SecurityAdvisoryRepository")
+ * @ORM\Table(
+ *     name="security_advisory",
+ *     uniqueConstraints={@ORM\UniqueConstraint(name="source_remoteid_idx", columns={"source","remoteId"})},
+ *     indexes={
+ *         @ORM\Index(name="package_name_idx",columns={"packageName"}),
+ *         @ORM\Index(name="updated_at_idx",columns={"updatedAt"})
+ *     }
+ * )
+ */
+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;
+
+    /**
+     * @ORM\Column(type="datetime")
+     */
+    private $updatedAt;
+
+    public function __construct(RemoteSecurityAdvisory $advisory, string $source)
+    {
+        $this->source = $source;
+        $this->updateAdvisory($advisory);
+    }
+
+    public function updateAdvisory(RemoteSecurityAdvisory $advisory): void
+    {
+        if (
+            $this->remoteId !== $advisory->getId() ||
+            $this->packageName !== $advisory->getPackageName() ||
+            $this->title !== $advisory->getTitle() ||
+            $this->link !== $advisory->getLink() ||
+            $this->cve !== $advisory->getCve() ||
+            $this->affectedVersions !== $advisory->getAffectedVersions()
+        ) {
+            $this->updatedAt = new \DateTime();
+        }
+
+        $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;
+    }
+
+    public function getPackageName(): string
+    {
+        return $this->packageName;
+    }
+
+    public function getTitle(): string
+    {
+        return $this->title;
+    }
+
+    public function getLink(): ?string
+    {
+        return $this->link;
+    }
+
+    public function getCve(): ?string
+    {
+        return $this->cve;
+    }
+
+    public function getAffectedVersions(): string
+    {
+        return $this->affectedVersions;
+    }
+
+    public function getSource(): string
+    {
+        return $this->source;
+    }
+}

+ 45 - 0
src/Packagist/WebBundle/Entity/SecurityAdvisoryRepository.php

@@ -0,0 +1,45 @@
+<?php declare(strict_types=1);
+
+namespace Packagist\WebBundle\Entity;
+
+use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
+use Doctrine\DBAL\Connection;
+use Symfony\Bridge\Doctrine\RegistryInterface;
+
+class SecurityAdvisoryRepository extends ServiceEntityRepository
+{
+    public function __construct(RegistryInterface $registry)
+    {
+        parent::__construct($registry, SecurityAdvisory::class);
+    }
+
+    public function getPackageSecurityAdvisories(string $name): array
+    {
+        $sql = 'SELECT s.*
+            FROM security_advisory s
+            WHERE s.packageName = :name
+            ORDER BY s.id DESC';
+
+        return $this->getEntityManager()->getConnection()
+            ->fetchAll($sql, ['name' => $name]);
+    }
+
+    public function searchSecurityAdvisories(array $packageNames, int $updatedSince): array
+    {
+        $sql = 'SELECT s.packageName, s.remoteId, s.title, s.link, s.cve, s.affectedVersions, s.source
+            FROM security_advisory s
+            WHERE s.updatedAt >= :updatedSince ' .
+            (count($packageNames) > 0 ? ' AND s.packageName IN (:packageNames)' : '')
+            .' ORDER BY s.id DESC';
+
+        return $this->getEntityManager()->getConnection()
+            ->fetchAll(
+                $sql,
+                [
+                    'packageNames' => $packageNames,
+                    'updatedSince' => date('Y-m-d H:i:s', $updatedSince),
+                ],
+                ['packageNames' => Connection::PARAM_STR_ARRAY]
+            );
+    }
+}

+ 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"
+            - 'FriendsOfPHP/security-advisories': "@Packagist\\WebBundle\\SecurityAdvisory\\FriendsOfPhpSecurityAdvisoriesSource"
+
 parameters:
     security.exception_listener.class: Packagist\WebBundle\Security\ExceptionListener

+ 12 - 0
src/Packagist/WebBundle/Resources/public/css/main.css

@@ -1114,6 +1114,18 @@ input:focus:invalid:focus, textarea:focus:invalid:focus, select:focus:invalid:fo
 .package .package-aside i:hover {
   color: #a5aab0;
 }
+.package .package-aside a.advisory-alert {
+    margin-left: -20px;
+}
+.package .package-aside a.advisory-alert:hover, .package .package-aside a.advisory-alert:active {
+    text-decoration: none;
+}
+.package .package-aside a.advisory-alert i {
+    color: #ff4533;
+}
+.package .package-aside a.advisory-alert:hover i {
+    color: #cd3729;
+}
 
 .package .details-toggler.open, .package .details-toggler.open a, .package .details-toggler.open i {
   background: #f28d1a;

+ 2 - 0
src/Packagist/WebBundle/Resources/translations/messages.en.yml

@@ -64,6 +64,8 @@ packages:
     suggesters: suggesters
     suggesters_title: Suggesters Packages
     from: "Packages from %vendor%"
+    security_advisory_title: Security Advisories
+    security_advisories: Security Advisories
 
 browse:
     packages: Packages

+ 59 - 0
src/Packagist/WebBundle/Resources/views/package/security_advisories.html.twig

@@ -0,0 +1,59 @@
+{% extends "PackagistWebBundle::layout.html.twig" %}
+
+{% set showSearchDesc = 'hide' %}
+
+{% block head_additions %}<meta name="robots" content="noindex, nofollow">{% endblock %}
+
+{% block title %}{{ 'packages.security_advisory_title'|trans }} - {{ name }} - {{ parent() }}{% endblock %}
+
+{% block content %}
+    <div class="row">
+        <div class="col-xs-12 package">
+            <div class="package-header">
+                <h2 class="title">
+                    <a href="{{ path("view_package", {name: name}) }}">{{ name }}</a>
+                    {{ 'packages.security_advisories'|trans }}
+                    {% if version is defined %}
+                        for {{ version }}
+                    {% endif %}
+                    <small>({{ count }})</small>
+                </h2>
+            </div>
+        </div>
+    </div>
+
+    <section class="row">
+        <section class="col-md-12">
+            {% if securityAdvisories|length %}
+                <ul class="packages list-unstyled">
+                    {% for advisory in securityAdvisories %}
+                        <li class="row">
+                            <div class="col-xs-12 package-item">
+                                <div class="row">
+                                    <div class="col-sm-8 col-lg-9">
+                                        <h4 class="font-bold">
+                                            <a href="{{ advisory.link }}">{{ advisory.title }}</a>
+                                        </h4>
+                                        {% if advisory.cve %}
+                                            <p>
+                                                <a href="http://cve.mitre.org/cgi-bin/cvename.cgi?name={{ advisory.cve }}">{{ advisory.cve }}</a>
+                                            </p>
+                                        {% endif %}
+                                        <p>Affected version: {{ advisory.affectedVersions }}</p>
+                                    </div>
+                                    <div class="col-sm-4 col-lg-3">
+                                        <p>Reported by:<br/>{{ advisory.source }}</p>
+                                    </div>
+                                </div>
+                            </div>
+                        </li>
+                    {% endfor %}
+                </ul>
+            {% else %}
+                <div class="alert alert-danger">
+                    <p>{{ 'listing.no_security_advisories'|trans }}</p>
+                </div>
+            {% endif %}
+        </section>
+    </section>
+{% endblock %}

+ 6 - 0
src/Packagist/WebBundle/Resources/views/package/version_list.html.twig

@@ -11,6 +11,12 @@
                         {% endif -%}
                     </a>
 
+                    {% if hasVersionSecurityAdvisories[version.id]|default(false) %}
+                        <a class="advisory-alert" href="{{ path('view_package_advisories', {name: package.name, version: version.id}) }}">
+                            <i class="glyphicon glyphicon-alert " title="Version has security advisories"></i>
+                        </a>
+                    {% endif %}
+
                     {% if deleteVersionCsrfToken is defined and deleteVersionCsrfToken is not empty %}
                     <form class="delete-version" action="{{ path("delete_version", {"versionId": version.id}) }}" method="DELETE">
                         <input type="hidden" name="_token" value="{{ deleteVersionCsrfToken }}" />

+ 17 - 1
src/Packagist/WebBundle/Resources/views/package/view_package.html.twig

@@ -194,6 +194,14 @@
                                     {{ suggesters|number_format(0, '.', '&#8201;')|raw }}
                                 </p>
                             {% endif %}
+                            {% if securityAdvisories is defined %}
+                                <p>
+                                    <span>
+                                        <a href="{{ path('view_package_advisories', {name: package.name}) }}" rel="nofollow">Security</a>:
+                                    </span>
+                                    {{ securityAdvisories|number_format(0, '.', '&#8201;')|raw }}
+                                </p>
+                            {% endif %}
                             {% if package.gitHubStars is not null %}
                                 <p>
                                     <span>
@@ -283,7 +291,15 @@
                             {% include 'PackagistWebBundle:package:version_details.html.twig' with {version: expandedVersion} %}
                         {% endif %}
                     </div>
-                    {% include 'PackagistWebBundle:package:version_list.html.twig' with {versions: versions, expandedId: expandedVersion.id, deleteVersionCsrfToken: deleteVersionCsrfToken|default(null), package: package, showUpdated: true, showUpdateButton: not hasActions and app.user and not package.wasUpdatedInTheLast24Hours()} %}
+                    {% include 'PackagistWebBundle:package:version_list.html.twig' with {
+                        versions: versions,
+                        expandedId: expandedVersion.id,
+                        deleteVersionCsrfToken: deleteVersionCsrfToken|default(null),
+                        package: package,
+                        showUpdated: true,
+                        showUpdateButton: not hasActions and app.user and not package.wasUpdatedInTheLast24Hours(),
+                        hasVersionSecurityAdvisories: hasVersionSecurityAdvisories
+                    } %}
                 </div>
             {% elseif package.crawledAt is null %}
                 <p class="col-xs-12">This package has not been crawled yet, some information is missing.</p>

+ 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 = 'FriendsOfPHP/security-advisories';
+    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(self::SOURCE_NAME, 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(sprintf('Failed to download "%s" zip file', self::SECURITY_PACKAGE), [
+                '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',

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

@@ -0,0 +1,32 @@
+<?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());
+    }
+}