Skip to content

Commit

Permalink
Add audit command to check for security issues
Browse files Browse the repository at this point in the history
  • Loading branch information
GuySartorelli committed May 30, 2022
1 parent 556450b commit 19a57ef
Show file tree
Hide file tree
Showing 12 changed files with 272 additions and 1 deletion.
19 changes: 19 additions & 0 deletions doc/03-cli.md
Expand Up @@ -103,6 +103,7 @@ resolution.
* **--no-autoloader:** Skips autoloader generation.
* **--no-progress:** Removes the progress display that can mess with some
terminals or scripts which don't handle backspace characters.
* **--no-audit:** Does not run the audit step after installation is complete.
* **--optimize-autoloader (-o):** Convert PSR-0/4 autoloading to classmap to get a faster
autoloader. This is recommended especially for production, but can take
a bit of time to run so it is currently not done by default.
Expand Down Expand Up @@ -182,6 +183,7 @@ and this feature is only available for your root package dependencies.
* **--dev:** Install packages listed in `require-dev` (this is the default behavior).
* **--no-dev:** Skip installing packages listed in `require-dev`. The autoloader generation skips the `autoload-dev` rules.
* **--no-install:** Does not run the install step after updating the composer.lock file.
* **--no-audit:** Does not run the audit steps after updating the composer.lock file.
* **--lock:** Only updates the lock file hash to suppress warning about the
lock file being out of date.
* **--with:** Temporary version constraint to add, e.g. foo/bar:1.0.0 or foo/bar=1.0.0
Expand Down Expand Up @@ -253,6 +255,7 @@ If you do not specify a package, Composer will prompt you to search for a packag
terminals or scripts which don't handle backspace characters.
* **--no-update:** Disables the automatic update of the dependencies (implies --no-install).
* **--no-install:** Does not run the install step after updating the composer.lock file.
* **--no-audit:** Does not run the audit steps after updating the composer.lock file.
* **--update-no-dev:** Run the dependency update with the `--no-dev` option.
* **--update-with-dependencies (-w):** Also update dependencies of the newly required packages, except those that are root requirements.
* **--update-with-all-dependencies (-W):** Also update dependencies of the newly required packages, including those that are root requirements.
Expand Down Expand Up @@ -850,6 +853,7 @@ By default the command checks for the packages on packagist.org.
mode.
* **--remove-vcs:** Force-remove the VCS metadata without prompting.
* **--no-install:** Disables installation of the vendors.
* **--no-audit:** Does not run the audit steps after installation is complete.
* **--ignore-platform-reqs:** ignore all platform requirements (`php`, `hhvm`,
`lib-*` and `ext-*`) and force the installation even if the local machine does
not fulfill these.
Expand Down Expand Up @@ -955,6 +959,21 @@ php composer.phar archive vendor/package 2.0.21 --format=zip
* **--dir:** Write the archive to this directory (default: ".")
* **--file:** Write the archive with the given file name.
## audit
This command is used to audit the packages in your composer.lock
for possible security issues. Currently this only checks for and
lists security vulnerability advisories according to the
[packagist api](https://packagist.org/apidoc#list-security-advisories).
```sh
php composer.phar audit
```
### Options
* **--no-dev:** Disables auditing of require-dev packages.
## help
To get more information about a certain command, you can use `help`.
Expand Down
15 changes: 15 additions & 0 deletions phpstan/baseline.neon
Expand Up @@ -4578,6 +4578,21 @@ parameters:
count: 1
path: ../src/Composer/SelfUpdate/Versions.php

-
message: "#^Method Composer\\\\Command\\\\AuditCommand\\:\\:configure\\(\\) has no return type specified\\.$#"
count: 1
path: src/Composer/Command/AuditCommand.php

-
message: "#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#"
count: 2
path: src/Composer/Util/Auditor.php

-
message: "#^Short ternary operator is not allowed\\. Use null coalesce operator if applicable or consider using long ternary\\.$#"
count: 1
path: src/Composer/Util/Auditor.php

-
message: "#^Call to function in_array\\(\\) requires parameter \\#3 to be set\\.$#"
count: 1
Expand Down
49 changes: 49 additions & 0 deletions src/Composer/Command/AuditCommand.php
@@ -0,0 +1,49 @@
<?php

namespace Composer\Command;

use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Composer\Factory;
use Composer\Util\Auditor;
use Composer\Util\Filesystem;
use Symfony\Component\Console\Input\InputOption;

class AuditCommand extends BaseCommand
{
protected function configure()
{
$this
->setName('audit')
->setDescription('Checks for security vulnerability advisories for packages in your composer.lock.')
->setDefinition(array(
new InputOption('no-dev', null, InputOption::VALUE_NONE, 'Disables auditing of require-dev packages.'),
))
->setHelp(
<<<EOT
The <info>audit</info> command checks for security vulnerability advisories for packages in your composer.lock.
If you do not want to include dev dependencies in the audit you can omit them with --no-dev
Read more at https://getcomposer.org/doc/03-cli.md#audit
EOT
)
;
}

protected function execute(InputInterface $input, OutputInterface $output)
{
$lockFile = Factory::getLockFile(Factory::getComposerFile());
if (!Filesystem::isReadable($lockFile)) {
$this->getIO()->writeError('<error>' . $lockFile . ' is not readable.</error>');
return 1;
}

$composer = $this->requireComposer($input->getOption('no-plugins'), $input->getOption('no-scripts'));
$locker = $composer->getLocker();
$packages = $locker->getLockedRepository(!$input->getOption('no-dev'))->getPackages();
$httpDownloader = Factory::createHttpDownloader($this->getIO(), $composer->getConfig());

return Auditor::audit($this->getIO(), $httpDownloader, $packages, false);
}
}
4 changes: 3 additions & 1 deletion src/Composer/Command/CreateProjectCommand.php
Expand Up @@ -88,6 +88,7 @@ protected function configure(): void
new InputOption('keep-vcs', null, InputOption::VALUE_NONE, 'Whether to prevent deleting the vcs folder.'),
new InputOption('remove-vcs', null, InputOption::VALUE_NONE, 'Whether to force deletion of the vcs folder without prompting.'),
new InputOption('no-install', null, InputOption::VALUE_NONE, 'Whether to skip installation of the package dependencies.'),
new InputOption('no-audit', null, InputOption::VALUE_NONE, 'Whether to skip auditing of the installed package dependencies.'),
new InputOption('ignore-platform-req', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Ignore a specific platform requirement (php & ext- packages).'),
new InputOption('ignore-platform-reqs', null, InputOption::VALUE_NONE, 'Ignore all platform requirements (php & ext- packages).'),
new InputOption('ask', null, InputOption::VALUE_NONE, 'Whether to ask for project directory.'),
Expand Down Expand Up @@ -257,7 +258,8 @@ public function installProject(IOInterface $io, Config $config, InputInterface $
->setSuggestedPackagesReporter($this->suggestedPackagesReporter)
->setOptimizeAutoloader($config->get('optimize-autoloader'))
->setClassMapAuthoritative($config->get('classmap-authoritative'))
->setApcuAutoloader($config->get('apcu-autoloader'));
->setApcuAutoloader($config->get('apcu-autoloader'))
->setAudit(!$input->getOption('no-audit'));

if (!$composer->getLocker()->isLocked()) {
$installer->setUpdate(true);
Expand Down
2 changes: 2 additions & 0 deletions src/Composer/Command/InstallCommand.php
Expand Up @@ -49,6 +49,7 @@ protected function configure()
new InputOption('no-autoloader', null, InputOption::VALUE_NONE, 'Skips autoloader generation'),
new InputOption('no-progress', null, InputOption::VALUE_NONE, 'Do not output download progress.'),
new InputOption('no-install', null, InputOption::VALUE_NONE, 'Do not use, only defined here to catch misuse of the install command.'),
new InputOption('no-audit', null, InputOption::VALUE_NONE, 'Skip the audit step after installation is complete.'),
new InputOption('verbose', 'v|vv|vvv', InputOption::VALUE_NONE, 'Shows more details including new commits pulled in when updating packages.'),
new InputOption('optimize-autoloader', 'o', InputOption::VALUE_NONE, 'Optimize autoloader during autoloader dump'),
new InputOption('classmap-authoritative', 'a', InputOption::VALUE_NONE, 'Autoload classes from the classmap only. Implicitly enables `--optimize-autoloader`.'),
Expand Down Expand Up @@ -128,6 +129,7 @@ protected function execute(InputInterface $input, OutputInterface $output)
->setClassMapAuthoritative($authoritative)
->setApcuAutoloader($apcu, $apcuPrefix)
->setPlatformRequirementFilter($this->getPlatformRequirementFilter($input))
->setAudit(!$input->getOption('no-audit'))
;

if ($input->getOption('no-plugins')) {
Expand Down
1 change: 1 addition & 0 deletions src/Composer/Command/RemoveCommand.php
Expand Up @@ -280,6 +280,7 @@ protected function execute(InputInterface $input, OutputInterface $output)
->setUpdateAllowTransitiveDependencies($updateAllowTransitiveDependencies)
->setPlatformRequirementFilter($this->getPlatformRequirementFilter($input))
->setDryRun($dryRun)
->setAudit(false)
;

// if no lock is present, we do not do a partial update as
Expand Down
2 changes: 2 additions & 0 deletions src/Composer/Command/RequireCommand.php
Expand Up @@ -78,6 +78,7 @@ protected function configure()
new InputOption('no-progress', null, InputOption::VALUE_NONE, 'Do not output download progress.'),
new InputOption('no-update', null, InputOption::VALUE_NONE, 'Disables the automatic update of the dependencies (implies --no-install).'),
new InputOption('no-install', null, InputOption::VALUE_NONE, 'Skip the install step after updating the composer.lock file.'),
new InputOption('no-audit', null, InputOption::VALUE_NONE, 'Skip the audit step after updating the composer.lock file.'),
new InputOption('update-no-dev', null, InputOption::VALUE_NONE, 'Run the dependency update with the --no-dev option.'),
new InputOption('update-with-dependencies', 'w', InputOption::VALUE_NONE, 'Allows inherited dependencies to be updated, except those that are root requirements.'),
new InputOption('update-with-all-dependencies', 'W', InputOption::VALUE_NONE, 'Allows all inherited dependencies to be updated, including those that are root requirements.'),
Expand Down Expand Up @@ -414,6 +415,7 @@ private function doUpdate(InputInterface $input, OutputInterface $output, IOInte
->setPlatformRequirementFilter($this->getPlatformRequirementFilter($input))
->setPreferStable($input->getOption('prefer-stable'))
->setPreferLowest($input->getOption('prefer-lowest'))
->setAudit(!$input->getOption('no-audit'))
;

// if no lock is present, or the file is brand new, we do not do a
Expand Down
2 changes: 2 additions & 0 deletions src/Composer/Command/UpdateCommand.php
Expand Up @@ -58,6 +58,7 @@ protected function configure()
new InputOption('no-dev', null, InputOption::VALUE_NONE, 'Disables installation of require-dev packages.'),
new InputOption('lock', null, InputOption::VALUE_NONE, 'Overwrites the lock file hash to suppress warning about the lock file being out of date without updating package versions. Package metadata like mirrors and URLs are updated if they changed.'),
new InputOption('no-install', null, InputOption::VALUE_NONE, 'Skip the install step after updating the composer.lock file.'),
new InputOption('no-audit', null, InputOption::VALUE_NONE, 'Skip the audit step after updating the composer.lock file.'),
new InputOption('no-autoloader', null, InputOption::VALUE_NONE, 'Skips autoloader generation'),
new InputOption('no-suggest', null, InputOption::VALUE_NONE, 'DEPRECATED: This flag does not exist anymore.'),
new InputOption('no-progress', null, InputOption::VALUE_NONE, 'Do not output download progress.'),
Expand Down Expand Up @@ -227,6 +228,7 @@ protected function execute(InputInterface $input, OutputInterface $output)
->setPreferStable($input->getOption('prefer-stable'))
->setPreferLowest($input->getOption('prefer-lowest'))
->setTemporaryConstraints($temporaryConstraints)
->setAudit(!$input->getOption('no-audit'))
;

if ($input->getOption('no-plugins')) {
Expand Down
1 change: 1 addition & 0 deletions src/Composer/Console/Application.php
Expand Up @@ -529,6 +529,7 @@ protected function getDefaultCommands(): array
new Command\UpdateCommand(),
new Command\SearchCommand(),
new Command\ValidateCommand(),
new Command\AuditCommand(),
new Command\ShowCommand(),
new Command\SuggestsCommand(),
new Command\RequireCommand(),
Expand Down
21 changes: 21 additions & 0 deletions src/Composer/Installer.php
Expand Up @@ -61,6 +61,7 @@
use Composer\Repository\LockArrayRepository;
use Composer\Script\ScriptEvents;
use Composer\Semver\Constraint\ConstraintInterface;
use Composer\Util\Auditor;
use Composer\Util\Platform;

/**
Expand Down Expand Up @@ -163,6 +164,8 @@ class Installer
protected $writeLock;
/** @var bool */
protected $executeOperations = true;
/** @var bool */
protected $audit = true;

/** @var bool */
protected $updateMirrors = false;
Expand Down Expand Up @@ -381,6 +384,11 @@ public function run(): int
gc_enable();
}

if ($this->audit) {
$packages = $this->locker->getLockedRepository($this->devMode)->getPackages();
Auditor::audit($this->io, Factory::createHttpDownloader($this->io, $this->config), $packages);
}

return 0;
}

Expand Down Expand Up @@ -1418,6 +1426,19 @@ public function setExecuteOperations(bool $executeOperations = true): self
return $this;
}

/**
* Should an audit be run after installation is complete?
*
* @param boolean $audit
* @return Installer
*/
public function setAudit(bool $audit): self
{
$this->audit = $audit;

return $this;
}

/**
* Disables plugins.
*
Expand Down
107 changes: 107 additions & 0 deletions src/Composer/Util/Auditor.php
@@ -0,0 +1,107 @@
<?php

namespace Composer\Util;

use Composer\IO\IOInterface;
use Composer\Package\BasePackage;
use Composer\Semver\Semver;
use InvalidArgumentException;

class Auditor
{
public const API_URL = 'https://packagist.org/api/security-advisories/';

/**
* @param IOInterface $io
* @param HttpDownloader $httpDownloader
* @param BasePackage[] $packages
* @param bool $warningOnly If true, outputs a warning. If false, outputs an error.
* @return int
* @throws InvalidArgumentException if no packages are passed in
*/
public static function audit(IOInterface $io, HttpDownloader $httpDownloader, array $packages, bool $warningOnly = true): int
{
$advisories = static::getAdvisories($httpDownloader, $packages);
$format = $warningOnly ? 'warning' : 'error';
if (!empty($advisories)) {
$error = ["<$format>Found the following security vulnerability advisories:"];
foreach ($advisories as $package => $packageAdvisories) {
$error[] = "$package:";
foreach ($packageAdvisories as $advisory) {
$cve = $advisory['cve'] ?: 'NO CVE';
$error[] = "\t{$cve}";
$error[] = "\t\tTitle: {$advisory['title']}";
$error[] = "\t\tURL: {$advisory['link']}";
$error[] = "\t\tAffected versions: {$advisory['affectedVersions']}";
$error[] = "\t\tReported on: {$advisory['reportedAt']}";
}
}
$error[] = "</$format>";
$io->writeError($error);
return 1;
}
$io->writeError('<info>No issues found</info>');
return 0;
}

/**
* Get advisories from packagist.org that match the package versions
*
* @param HttpDownloader $httpDownloader
* @param BasePackage[] $packages
* @param ?int $updatedSince Timestamp
* @param bool $filterByVersion If true, don't filter by the package versions
* @return string[][][]
* @throws InvalidArgumentException if no packages are passed in
*/
public static function getAdvisories(
HttpDownloader $httpDownloader,
array $packages,
int $updatedSince = null,
bool $filterByVersion = false
): array
{
if (empty($packages)) {
throw new InvalidArgumentException('At least one package must be passed in.');
}

// Get advisories for the given packages
$body = ['packages' => []];
foreach ($packages as $package) {
$body['packages'][] = $package->getName();
}

// Add updatedSince if passed in
if ($updatedSince !== null) {
$body['updatedSince'] = $updatedSince;
}

// Get API response
$response = $httpDownloader->get(static::API_URL, [
'http' => [
'method' => 'POST',
'header' => ['Content-type: application/x-www-form-urlencoded'],
'content' => http_build_query($body),
'timeout' => 3,
],
]);
$advisories = $response->decodeJson()['advisories'];

if (!$filterByVersion) {
// Filter out any advisories that aren't relevant for these package versions
$relevantAdvisories = [];
foreach ($packages as $package) {
if (array_key_exists($package->getName(), $advisories)) {
foreach ($advisories[$package->getName()] as $advisory) {
if (Semver::satisfies($package->getVersion(), $advisory['affectedVersions'])) {
$relevantAdvisories[$package->getName()][] = $advisory;
}
}
}
}
return $relevantAdvisories;
}

return $advisories;
}
}

0 comments on commit 19a57ef

Please sign in to comment.