diff --git a/doc/tasks.md b/doc/tasks.md index b24c2547..6070ea9d 100644 --- a/doc/tasks.md +++ b/doc/tasks.md @@ -55,6 +55,7 @@ grumphp: robo: ~ securitychecker_enlightn: ~ securitychecker_local: ~ + securitychecker_roave: ~ securitychecker_symfony: ~ shell: ~ stylelint: ~ @@ -117,6 +118,7 @@ Every task has it's own default configuration. It is possible to overwrite the p - [Security Checker](tasks/securitychecker.md) - [Enlightn](tasks/securitychecker/enlightn.md) - [Local](tasks/securitychecker/local.md) + - [Roave](tasks/securitychecker/roave.md) - [Symfony](tasks/securitychecker/symfony.md) - [Shell](tasks/shell.md) - [Stylelint](tasks/stylelint.md) diff --git a/doc/tasks/securitychecker/roave.md b/doc/tasks/securitychecker/roave.md new file mode 100644 index 00000000..da713176 --- /dev/null +++ b/doc/tasks/securitychecker/roave.md @@ -0,0 +1,42 @@ +# Roave Security Checker + +The Security Checker will check your `composer.lock` file for known security vulnerabilities. + +***Composer*** + +``` +composer require --dev roave/security-advisories:lastest-dev +``` +More information about the library can be found on [github](https://github.com/Roave/SecurityAdvisories). + +***Config*** + +The task lives under the `securitychecker_roave` namespace and has the following configurable parameters: + +```yaml +# grumphp.yml +grumphp: + tasks: + securitychecker_roave: + jsonfile: ./composer.json + lockfile: ./composer.lock + run_always: false +``` + +**jsonfile** + +*Default: ./composer.json* + +If your `composer.json` file is located in an exotic location, you can specify the location with this option. By default, the task will try to load a `composer.json` file in the current directory. + +**lockfile** + +*Default: ./composer.lock* + +If your `composer.lock` file is located in an exotic location, you can specify the location with this option. By default, the task will try to load a `composer.lock` file in the current directory. + +**run_always** + +*Default: false* + +When this option is set to `false`, the task will only run when the `composer.lock` file has changed. If it is set to `true`, the `composer.lock` file will be checked on every commit. diff --git a/resources/config/tasks.yml b/resources/config/tasks.yml index 37fc5294..654daa24 100644 --- a/resources/config/tasks.yml +++ b/resources/config/tasks.yml @@ -336,6 +336,14 @@ services: tags: - {name: grumphp.task, task: securitychecker_local} + GrumPHP\Task\SecurityCheckerRoave: + arguments: + - '@process_builder' + - '@formatter.raw_process' + - '@grumphp.util.filesystem' + tags: + - {name: grumphp.task, task: securitychecker_roave} + GrumPHP\Task\SecurityCheckerSymfony: arguments: - '@process_builder' diff --git a/src/Task/SecurityCheckerRoave.php b/src/Task/SecurityCheckerRoave.php new file mode 100644 index 00000000..14d380e9 --- /dev/null +++ b/src/Task/SecurityCheckerRoave.php @@ -0,0 +1,114 @@ +filesystem = $filesystem; + } + + public static function getConfigurableOptions(): OptionsResolver + { + $resolver = new OptionsResolver(); + $resolver->setDefaults([ + 'jsonfile' => './composer.json', + 'lockfile' => './composer.lock', + 'run_always' => false, + ]); + + $resolver->addAllowedTypes('jsonfile', ['string']); + $resolver->addAllowedTypes('lockfile', ['string']); + $resolver->addAllowedTypes('run_always', ['bool']); + + return $resolver; + } + + public function canRunInContext(ContextInterface $context): bool + { + return $context instanceof GitPreCommitContext || $context instanceof RunContext; + } + + public function run(ContextInterface $context): TaskResultInterface + { + + $config = $this->getConfig()->getOptions(); + $composerFile = $config['jsonfile']; + + if (!$this->filesystem->isFile($composerFile)) { + return TaskResult::createSkipped($this, $context); + } + + if (!$this->hasRoaveSecurityAdvisoriesInstalled($composerFile)) { + return TaskResult::createFailed( + $this, + $context, + 'This task is only available when roave/security-advisories is installed as a library.' + ); + } + + $config = $this->getConfig()->getOptions(); + $files = $context->getFiles() + ->path(pathinfo($config['lockfile'], PATHINFO_DIRNAME)) + ->name(pathinfo($config['lockfile'], PATHINFO_BASENAME)); + if (0 === \count($files) && !$config['run_always']) { + return TaskResult::createSkipped($this, $context); + } + + $arguments = $this->processBuilder->createArgumentsForCommand('composer'); + $arguments->add('update'); + $arguments->add('--dry-run'); + $arguments->add('roave/security-advisories'); + + $process = $this->processBuilder->buildProcess($arguments); + $process->run(); + + if (!$process->isSuccessful()) { + return TaskResult::createFailed($this, $context, $this->formatter->format($process)); + } + + return TaskResult::createPassed($this, $context); + } + + private function hasRoaveSecurityAdvisoriesInstalled(string $composerFile): bool + { + $json = $this->filesystem->readPath($composerFile); + try { + $package = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + return false; + } + + if (array_key_exists('require', $package) + && array_key_exists('roave/security-advisories', $package['require'])) { + return true; + } + + if (array_key_exists('require-dev', $package) + && array_key_exists('roave/security-advisories', $package['require-dev'])) { + return true; + } + + return false; + } +} diff --git a/test/Unit/Task/SecurityCheckerRoaveTest.php b/test/Unit/Task/SecurityCheckerRoaveTest.php new file mode 100644 index 00000000..e96b1c86 --- /dev/null +++ b/test/Unit/Task/SecurityCheckerRoaveTest.php @@ -0,0 +1,187 @@ +filesystem = $this->prophesize(Filesystem::class); + + return new SecurityCheckerRoave( + $this->processBuilder->reveal(), + $this->formatter->reveal(), + $this->filesystem->reveal() + ); + } + + private function mockComposerJsonWithRoaveSecurityAdvisories(): void + { + $this->filesystem->isFile(Argument::exact('./composer.json'))->willReturn(true); + $this->filesystem->readPath(Argument::exact('./composer.json'))->willReturn( + json_encode([ + 'require' => ['roave/security-advisories'=>'dev-latest'], + ]) + ); + } + + private function mockComposerJsonWithoutRoaveSecurityAdvisories(): void + { + $this->filesystem->isFile(Argument::exact('./composer.json'))->willReturn(true); + $this->filesystem->readPath(Argument::exact('./composer.json'))->willReturn( + json_encode([ + 'require' => [], + ]) + ); + } + + private function mockMissingComposerJson(): void + { + $this->filesystem->isFile(Argument::exact('./composer.json'))->willReturn(false); + } + + public function provideConfigurableOptions(): iterable + { + yield 'defaults' => [ + [], + [ + 'jsonfile' => './composer.json', + 'lockfile' => './composer.lock', + 'run_always' => false, + ] + ]; + } + + public function provideRunContexts(): iterable + { + yield 'run-context' => [ + true, + $this->mockContext(RunContext::class) + ]; + + yield 'pre-commit-context' => [ + true, + $this->mockContext(GitPreCommitContext::class) + ]; + + yield 'other' => [ + false, + $this->mockContext() + ]; + } + + public function provideFailsOnStuff(): iterable + { + yield 'exitCode1' => [ + [], + $this->mockContext(RunContext::class, ['composer.lock']), + function () { + $this->mockProcessBuilder('composer', $process = $this->mockProcess(1)); + $this->mockComposerJsonWithRoaveSecurityAdvisories(); + $this->formatter->format($process)->willReturn('nope'); + }, + 'nope' + ]; + yield 'no-roave-security-advisories' => + [ + [], + $this->mockContext(RunContext::class, ['composer.lock']), + function () { + $this->mockProcessBuilder('composer', $this->mockProcess(0)); + $this->mockComposerJsonWithoutRoaveSecurityAdvisories(); + }, + 'This task is only available when roave/security-advisories is installed as a library.' + ]; + } + + public function providePassesOnStuff(): iterable + { + yield 'exitCode0' => [ + [], + $this->mockContext(RunContext::class, ['composer.lock']), + function () { + $this->mockProcessBuilder('composer', $this->mockProcess(0)); + $this->mockComposerJsonWithRoaveSecurityAdvisories(); + }, + ]; + yield 'exitCode0WhenRunAlways' => [ + [ + 'run_always' => true + ], + $this->mockContext(RunContext::class, ['notrelated.php']), + function () { + $this->mockProcessBuilder('composer', $this->mockProcess(0)); + $this->mockComposerJsonWithRoaveSecurityAdvisories(); + } + ]; + } + + public function provideSkipsOnStuff(): iterable + { + yield 'no-files' => [ + [], + $this->mockContext(RunContext::class), + function () { + $this->mockComposerJsonWithRoaveSecurityAdvisories(); + } + ]; + yield 'no-composer.json-file' => [ + [ + 'run_always' => true + ], + $this->mockContext(RunContext::class, []), + function () { + $this->mockMissingComposerJson(); + } + ]; + } + + /** + * @test + * @dataProvider provideExternalTaskRuns + */ + public function it_runs_external_task( + array $config, + ContextInterface $context, + string $taskName, + array $cliArguments, + ?Process $process = null + ): void + { + $configurator = function () { + $this->mockComposerJsonWithRoaveSecurityAdvisories(); + }; + \Closure::bind($configurator, $this)(); + + parent::it_runs_external_task($config,$context,$taskName,$cliArguments,$process); + } + + public function provideExternalTaskRuns(): iterable + { + yield 'defaults' => [ + [], + $this->mockContext(RunContext::class, ['composer.lock']), + 'composer', + [ + 'update', + '--dry-run', + 'roave/security-advisories', + ] + ]; + } +}