Skip to content

Commit

Permalink
Merge pull request #12 from cs278/refactor-command
Browse files Browse the repository at this point in the history
Add unit tests of advisory loading
  • Loading branch information
cs278 authored May 14, 2021
2 parents b76cd40 + 0a2d435 commit 27101f1
Show file tree
Hide file tree
Showing 9 changed files with 238 additions and 32 deletions.
7 changes: 7 additions & 0 deletions phpunit.xml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
beStrictAboutTodoAnnotatedTests="true"
>
<testsuites>
<testsuite name="Unit tests">
<directory suffix="Test.php">tests/unit</directory>
</testsuite>
<testsuite name="Integration tests">
<directory suffix="Test.php">tests/integration</directory>
</testsuite>
Expand All @@ -19,4 +22,8 @@
<directory suffix=".php">src</directory>
</whitelist>
</filter>

<php>
<ini name="zend.assertions" value="1" />
</php>
</phpunit>
2 changes: 1 addition & 1 deletion src/AdvisoriesInstaller.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
* @copyright 2020 Chris Smith
* @license MIT
*/
abstract class AdvisoriesInstaller
abstract class AdvisoriesInstaller implements AdvisoriesInstallerInterface
{
/** @var RepositoryManager */
private $repositoryManager;
Expand Down
33 changes: 33 additions & 0 deletions src/AdvisoriesInstallerInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

namespace Cs278\ComposerAudit;

/**
* Handles installing security advisories.
*
* @copyright 2020 Chris Smith
* @license MIT
*/
interface AdvisoriesInstallerInterface
{
/**
* Require the installer to check if updates are available.
*
* @return void
*/
public function mustUpdate();

/**
* Install advisories database package in to the given directory.
*
* @param string $varDirectory Directory to store the database
* @param string $packageName Package name of the advisories database
* @param string $packageConstraint Required constraint for the advisories
* database package.
*
* @return string The base directory of the advisories database, this will
* usually be $varDirectory or a sub-directory of it but
* consumers shouldn't rely on this.
*/
public function install($varDirectory, $packageName, $packageConstraint);
}
87 changes: 81 additions & 6 deletions src/AdvisoriesManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@

use Composer\Composer;
use Composer\Plugin\PluginInterface;
use Composer\Semver\Constraint\Constraint;
use Composer\Semver\Constraint\ConstraintInterface;
use Composer\Semver\Constraint\MultiConstraint;
use Composer\Semver\VersionParser;
use Symfony\Component\Yaml\Yaml;

/**
* Handles finding security advisories.
Expand All @@ -16,18 +21,25 @@
*/
final class AdvisoriesManager
{
/** @var AdvisoriesInstaller */
/** @var AdvisoriesInstallerInterface */
private $installer;

/** @var VersionParser */
private $versionParser;

private $packageName = 'sensiolabs/security-advisories';
private $packageConstraint = 'dev-master';

/** @var string */
private $directory;

public function __construct(AdvisoriesInstaller $installer)
/** @var array<string,array<mixed,mixed>> */
private $advisories;

public function __construct(AdvisoriesInstallerInterface $installer)
{
$this->installer = $installer;
$this->versionParser = new VersionParser();
}

public static function create(Composer $composer)
Expand All @@ -53,12 +65,75 @@ public function mustUpdate()
$this->installer->mustUpdate();
}

public function findAll()
/**
* Find all advisories.
*
* @return iterable<int,array<mixed,mixed>>
*/
public function findAll(): iterable
{
$advisoriesDir = $this->getDirectory();
if (!isset($this->advisories)) {
$this->advisories = [];

$advisoriesDir = $this->getDirectory();
\assert(is_dir($advisoriesDir));

// Find all the advisories for installed packages.
foreach (glob("$advisoriesDir/*/*/*.yaml") as $file) {
$advisory = Yaml::parseFile($file);

$this->advisories[$file] = $advisory;
}
}

yield from $this->advisories;
}

/**
* Find any advisory applying to the given package name and version.
*
* @return iterable<int,array<mixed,mixed>>
*/
public function findByPackageNameAndVersion(string $name, string $version): iterable
{
$reference = sprintf('composer://%s', $name);
$constraint = new Constraint('==', $this->versionParser->normalize($version));

foreach ($this->findAll() as $advisory) {
if ($advisory['reference'] === $reference) {
if ($this->createConstraint($advisory)->matches($constraint)) {
yield $advisory;
}
}
}
}

/**
* Construct contstraint from the advistory.
*/
private function createConstraint(array $advisory): ConstraintInterface
{
$constraints = [];

foreach ($advisory['branches'] as $branch) {
$branchConstraints = [];

foreach ($branch['versions'] as $version) {
$branchConstraints[] = $this->versionParser->parseConstraints($version);
}

if ($branchConstraints !== []) {
$constraints[] = count($branchConstraints) > 1
? new MultiConstraint($branchConstraints, true)
: $branchConstraints[0];
}
}

if (\count($constraints) === 1) {
return $constraints[0];
}

// Find all the advisories for installed packages.
return glob("$advisoriesDir/*/*/*.yaml");
return new MultiConstraint($constraints, false);
}

private function getDirectory()
Expand Down
30 changes: 5 additions & 25 deletions src/AuditCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -100,34 +100,14 @@ protected function execute(InputInterface $input, OutputInterface $output)
$packages = $lockData['packages'];
}

$packages = array_map(static function (array $package): array {
return [
'name' => $package['name'],
'version' => $package['version'],
'reference' => sprintf('composer://%s', $package['name']),
];
}, $packages);

$packages = array_column($packages, 'version', 'reference');
$packages = array_column($packages, 'version', 'name');

$advisories = [];

// Find all the advisories for installed packages.
foreach ($advisoriesManager->findAll() as $file) {
$advisory = Yaml::parseFile($file);
$advisory['_file'] = $file;

if (isset($packages[$advisory['reference']])) {
$installedVersion = $packages[$advisory['reference']];

foreach ($advisory['branches'] as $branch) {
$constraint = implode(',', $branch['versions']);

if (Semver::satisfies($installedVersion, $constraint)) {
$advisories[$advisory['reference']][] = $advisory;
break;
}
}
foreach ($packages as $name => $version) {
foreach ($advisoriesManager->findByPackageNameAndVersion($name, $version) as $advisory) {
$advisories[$name][] = $advisory;
}
}

Expand Down Expand Up @@ -158,7 +138,7 @@ protected function execute(InputInterface $input, OutputInterface $output)
ksort($advisories, \SORT_NATURAL | \SORT_ASC);

foreach ($advisories as $reference => $packageAdvisories) {
$output->writeln(sprintf('<info>%s (%s)</info>', $reference, $packages[$reference]));
$output->writeln(sprintf('<info>composer://%s (%s)</info>', $reference, $packages[$reference]));

foreach ($packageAdvisories as $advisory) {
$title = $advisory['title'];
Expand Down
91 changes: 91 additions & 0 deletions tests/unit/AdvisoriesManagerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php

declare(strict_types=1);

namespace Cs278\ComposerAudit;

use Composer\Composer;
use Composer\Semver\Semver;
use PHPUnit\Framework\TestCase;
use Symfony\Bridge\PhpUnit\SetUpTearDownTrait;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Process\Process;

use function Cs278\Mktemp\temporaryDir;

/**
* @covers Cs278\ComposerAudit\AdvisoriesManager
*/
final class AdvisoriesManagerTest extends TestCase
{
use SetUpTearDownTrait;

/**
* @dataProvider dataFindByPackageNameAndVersion
*
*/
public function testFindByPackageNameAndVersion(array $expected, string $packageName, string $packageVersion, string $advisories)
{
$manager = $this->createManager($advisories);
$results = [];

foreach ($manager->findByPackageNameAndVersion($packageName, $packageVersion) as $advisory) {
$results[] = $advisory['title'];

self::assertEquals(sprintf('composer://%s', $packageName), $advisory['reference']);
}

self::assertEquals($expected, $results);
}

public function dataFindByPackageNameAndVersion(): iterable
{
yield [
[],
'foo/bar',
'13.37.0',
'empty',
];
yield [
[
'CVE-9999-1234567: Left the front door open',
],
'foo/bar',
'13.37',
'simple',
];
}

private function createManager(string $advisories): AdvisoriesManager
{
$installer = new class($advisories) implements AdvisoriesInstallerInterface {
private $advisories;

public function __construct(string $advisories)
{
$this->advisories = __DIR__.'/advisories/'.$advisories;

if (!is_dir($this->advisories)) {
throw new \InvalidArgumentException(sprintf(
'%s is invalid, `%s` is not a directory',
$advisories,
$this->advisories
));
}
}

public function mustUpdate()
{
return; // No op
}

public function install($varDirectory, $packageName, $packageConstraint)
{
return $this->advisories;
}
};

return new AdvisoriesManager($installer);
}
}
6 changes: 6 additions & 0 deletions tests/unit/advisories/empty/composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@

{
"name": "sensiolabs/security-advisories",
"description": "Database of known security vulnerabilities in various PHP projects and libraries",
"license": "Unlicense"
}
6 changes: 6 additions & 0 deletions tests/unit/advisories/simple/composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@

{
"name": "sensiolabs/security-advisories",
"description": "Database of known security vulnerabilities in various PHP projects and libraries",
"license": "Unlicense"
}
8 changes: 8 additions & 0 deletions tests/unit/advisories/simple/foo/bar/vuln1.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
title: "CVE-9999-1234567: Left the front door open"
link: https://example.com/CVE-9999-1234567
cve: CVE-9999-1234567
branches:
"1337":
time: 2020-01-01 12:32:00
versions: ['>=13.37.0', '<13.37.100']
reference: composer://foo/bar

0 comments on commit 27101f1

Please sign in to comment.