diff --git a/doc/tasks.md b/doc/tasks.md index 0c8c7884..bc618fad 100644 --- a/doc/tasks.md +++ b/doc/tasks.md @@ -54,6 +54,7 @@ grumphp: psalm: ~ rector: ~ robo: ~ + securitychecker_composeraudit: ~ securitychecker_enlightn: ~ securitychecker_local: ~ securitychecker_roave: ~ @@ -119,6 +120,7 @@ Every task has its own default configuration. It is possible to overwrite the pa - [Rector](tasks/rector.md) - [Robo](tasks/robo.md) - [Security Checker](tasks/securitychecker.md) + - [Composer Audit](tasks/securitychecker/composeraudit.md) - [Enlightn](tasks/securitychecker/enlightn.md) - [Local](tasks/securitychecker/local.md) - [Roave](tasks/securitychecker/roave.md) @@ -205,7 +207,7 @@ interface TaskInterface } ``` -* `getConfigurableOptions`: This method has to return all configurable options for the task. +* `getConfigurableOptions`: This method has to return all configurable options for the task. * `canRunInContext`: Tells GrumPHP if it can run in `pre-commit`, `commit-msg` or `run` context. * `run`: Executes the task and returns a result * `getConfig`: Provides the resolved configuration for the task or an empty config for newly instantiated tasks. @@ -260,7 +262,7 @@ For a more detailed view on how to use these classes, you can scroll through our In some cases you might want to run the same task but with different configuration. Good news: This is perfectly possible! -You can use any name you want for the task, as long as you configure an existing task in the metadata section. +You can use any name you want for the task, as long as you configure an existing task in the metadata section. Configuration of the additional task will look like this: ```yaml diff --git a/doc/tasks/securitychecker.md b/doc/tasks/securitychecker.md index b6458094..bea62d39 100644 --- a/doc/tasks/securitychecker.md +++ b/doc/tasks/securitychecker.md @@ -4,6 +4,7 @@ The SensioLabs Security Checker API is abandoned You can use one of following tasks as a replacement: +- [securitychecker_composeraudit](securitychecker/composeraudit.md) - [securitychecker_enlightn](securitychecker/enlightn.md) - [securitychecker_local](securitychecker/local.md) - [securitychecker_roave](securitychecker/roave.md) diff --git a/doc/tasks/securitychecker/composeraudit.md b/doc/tasks/securitychecker/composeraudit.md new file mode 100644 index 00000000..0da96d56 --- /dev/null +++ b/doc/tasks/securitychecker/composeraudit.md @@ -0,0 +1,49 @@ +# Composer Audit Security Checker + +The Security Checker will check your `composer.lock` file for known security vulnerabilities. + +***Config*** + +The task lives under the `securitychecker_composeraudit` namespace and has the following configurable parameters: + +```yaml +# grumphp.yml +grumphp: + tasks: + securitychecker_composeraudit: + format: null + locked: true + no_dev: false + run_always: false + working_dir: null +``` + +**format** + +*Default: null* + +You can choose the format of the output. The available options are `table`, `plain`, `json` and `summary`. By default, grumphp will use the format `table`. + +**locked** + +*Default: true* + +Audit packages from the lock file, regardless of what is currently in vendor dir. + +**no_dev** + +*Default: false* + +When this option is set to `true`, the task will skip packages under `require-dev`. + +**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. + +**working_dir** + +*Default: null + +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. \ No newline at end of file diff --git a/resources/config/tasks.yml b/resources/config/tasks.yml index d459ce1a..c1d5aafe 100644 --- a/resources/config/tasks.yml +++ b/resources/config/tasks.yml @@ -336,6 +336,13 @@ services: tags: - {name: grumphp.task, task: securitychecker} + GrumPHP\Task\SecurityCheckerComposeraudit: + arguments: + - '@process_builder' + - '@formatter.raw_process' + tags: + - {name: grumphp.task, task: securitychecker_composeraudit} + GrumPHP\Task\SecurityCheckerEnlightn: arguments: - '@process_builder' diff --git a/src/Task/SecurityCheckerComposeraudit.php b/src/Task/SecurityCheckerComposeraudit.php new file mode 100644 index 00000000..d57dc1a6 --- /dev/null +++ b/src/Task/SecurityCheckerComposeraudit.php @@ -0,0 +1,72 @@ + + */ +class SecurityCheckerComposeraudit extends AbstractExternalTask +{ + public static function getConfigurableOptions(): ConfigOptionsResolver + { + $resolver = new OptionsResolver(); + $resolver->setDefaults([ + 'format' => null, + 'locked' => true, + 'no_dev' => false, + 'run_always' => false, + 'working_dir' => null, + ]); + + $resolver->addAllowedTypes('format', ['null', 'string']); + $resolver->addAllowedTypes('locked', ['bool']); + $resolver->addAllowedTypes('no_dev', ['bool']); + $resolver->addAllowedTypes('run_always', ['bool']); + $resolver->addAllowedTypes('working_dir', ['null', 'string']); + + return ConfigOptionsResolver::fromOptionsResolver($resolver); + } + + public function canRunInContext(ContextInterface $context): bool + { + return $context instanceof GitPreCommitContext || $context instanceof RunContext; + } + + public function run(ContextInterface $context): TaskResultInterface + { + $config = $this->getConfig()->getOptions(); + $files = $context->getFiles() + ->path(pathinfo("composer.lock", PATHINFO_DIRNAME)) + ->name(pathinfo("composer.lock", PATHINFO_BASENAME)); + if (0 === \count($files) && !$config['run_always']) { + return TaskResult::createSkipped($this, $context); + } + + $arguments = $this->processBuilder->createArgumentsForCommand('composer'); + $arguments->add('audit'); + $arguments->addOptionalArgument('--format=%s', $config['format']); + $arguments->addOptionalArgument('--locked', $config['locked']); + $arguments->addOptionalArgument('--no-dev', $config['no_dev']); + $arguments->addOptionalArgument('--working-dir=%s', $config['working_dir']); + + $process = $this->processBuilder->buildProcess($arguments); + $process->run(); + + if (!$process->isSuccessful()) { + return TaskResult::createFailed($this, $context, $this->formatter->format($process)); + } + + return TaskResult::createPassed($this, $context); + } +} diff --git a/test/Unit/Task/SecurityCheckerComposerauditTest.php b/test/Unit/Task/SecurityCheckerComposerauditTest.php new file mode 100644 index 00000000..fae103a7 --- /dev/null +++ b/test/Unit/Task/SecurityCheckerComposerauditTest.php @@ -0,0 +1,164 @@ +processBuilder->reveal(), + $this->formatter->reveal() + ); + } + + public function provideConfigurableOptions(): iterable + { + yield 'defaults' => [ + [], + [ + 'format' => null, + 'locked' => true, + 'no_dev' => false, + 'run_always' => false, + 'working_dir' => null, + ] + ]; + } + + 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->formatter->format($process)->willReturn('nope'); + }, + 'nope' + ]; + } + + public function providePassesOnStuff(): iterable + { + yield 'exitCode0' => [ + [], + $this->mockContext(RunContext::class, ['composer.lock']), + function () { + $this->mockProcessBuilder('composer', $this->mockProcess(0)); + } + ]; + yield 'exitCode0WhenRunAlways' => [ + [ + 'run_always' => true + ], + $this->mockContext(RunContext::class, ['notrelated.php']), + function () { + $this->mockProcessBuilder('composer', $this->mockProcess(0)); + } + ]; + } + + public function provideSkipsOnStuff(): iterable + { + yield 'no-files' => [ + [], + $this->mockContext(RunContext::class), + function () {} + ]; + yield 'no-composer-file' => [ + [], + $this->mockContext(RunContext::class, ['thisisnotacomposerfile.lock']), + function () {} + ]; + } + + public function provideExternalTaskRuns(): iterable + { + yield 'defaults' => [ + [], + $this->mockContext(RunContext::class, ['composer.lock']), + 'composer', + [ + 'audit', + '--locked', + ] + ]; + + yield 'format' => [ + [ + 'format' => 'json', + ], + $this->mockContext(RunContext::class, ['composer.lock']), + 'composer', + [ + 'audit', + '--format=json', + '--locked', + ] + ]; + + yield 'locked' => [ + [ + 'locked' => false, + ], + $this->mockContext(RunContext::class, ['composer.lock']), + 'composer', + [ + 'audit', + ] + ]; + + yield 'no-dev' => [ + [ + 'no_dev' => true, + ], + $this->mockContext(RunContext::class, ['composer.lock']), + 'composer', + [ + 'audit', + '--locked', + '--no-dev', + ] + ]; + + yield 'working-dir' => [ + [ + 'working_dir' => 'dir', + ], + $this->mockContext(RunContext::class, ['composer.lock']), + 'composer', + [ + 'audit', + '--locked', + '--working-dir=dir', + ] + ]; + } +}