Merge branch '4.4'
* 4.4: [Console] Revert wrong change [HttpClient] Add a canceled state to the ResponseInterface Fix small typo in Exception message Restrict secrets management to sodium+filesystem Add secrets management Proof of concept for encrypted secrets
This commit is contained in:
commit
077f7be805
@ -37,6 +37,7 @@ CHANGELOG
|
||||
* Added new `error_controller` configuration to handle system exceptions
|
||||
* Added sort option for `translation:update` command.
|
||||
* [BC Break] The `framework.messenger.routing.senders` config key is not deep merged anymore.
|
||||
* Added `secrets:*` commands and `%env(secret:...)%` processor to deal with secrets seamlessly.
|
||||
|
||||
4.3.0
|
||||
-----
|
||||
|
@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Bundle\FrameworkBundle\Command;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Secrets\AbstractVault;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\ConsoleOutputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* @author Nicolas Grekas <p@tchwork.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class SecretsDecryptToLocalCommand extends Command
|
||||
{
|
||||
protected static $defaultName = 'secrets:decrypt-to-local';
|
||||
|
||||
private $vault;
|
||||
private $localVault;
|
||||
|
||||
public function __construct(AbstractVault $vault, AbstractVault $localVault = null)
|
||||
{
|
||||
$this->vault = $vault;
|
||||
$this->localVault = $localVault;
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure()
|
||||
{
|
||||
$this
|
||||
->setDescription('Decrypts all secrets and stores them in the local vault.')
|
||||
->addOption('force', 'f', InputOption::VALUE_NONE, 'Forces overriding of secrets that already exist in the local vault')
|
||||
->setHelp(<<<'EOF'
|
||||
The <info>%command.name%</info> command list decrypts all secrets and stores them in the local vault..
|
||||
|
||||
<info>%command.full_name%</info>
|
||||
|
||||
When the option <info>--force</info> is provided, secrets that already exist in the local vault are overriden.
|
||||
|
||||
<info>%command.full_name% --force</info>
|
||||
EOF
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output);
|
||||
|
||||
if (null === $this->localVault) {
|
||||
$io->error('The local vault is disabled.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$secrets = $this->vault->list(true);
|
||||
|
||||
if (!$input->getOption('force')) {
|
||||
foreach ($this->localVault->list() as $k => $v) {
|
||||
unset($secrets[$k]);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($secrets as $k => $v) {
|
||||
if (null === $v) {
|
||||
$io->error($this->vault->getLastMessage());
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->localVault->seal($k, $v);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Bundle\FrameworkBundle\Command;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Secrets\AbstractVault;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\ConsoleOutputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* @author Nicolas Grekas <p@tchwork.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class SecretsEncryptFromLocalCommand extends Command
|
||||
{
|
||||
protected static $defaultName = 'secrets:encrypt-from-local';
|
||||
|
||||
private $vault;
|
||||
private $localVault;
|
||||
|
||||
public function __construct(AbstractVault $vault, AbstractVault $localVault = null)
|
||||
{
|
||||
$this->vault = $vault;
|
||||
$this->localVault = $localVault;
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure()
|
||||
{
|
||||
$this
|
||||
->setDescription('Encrypts all local secrets to the vault.')
|
||||
->addOption('force', 'f', InputOption::VALUE_NONE, 'Forces overriding of secrets that already exist in the vault')
|
||||
->setHelp(<<<'EOF'
|
||||
The <info>%command.name%</info> command list encrypts all local secrets and stores them in the vault..
|
||||
|
||||
<info>%command.full_name%</info>
|
||||
|
||||
When the option <info>--force</info> is provided, secrets that already exist in the vault are overriden.
|
||||
|
||||
<info>%command.full_name% --force</info>
|
||||
EOF
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output);
|
||||
|
||||
if (null === $this->localVault) {
|
||||
$io->error('The local vault is disabled.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$secrets = $this->localVault->list(true);
|
||||
|
||||
if (!$input->getOption('force')) {
|
||||
foreach ($this->vault->list() as $k => $v) {
|
||||
unset($secrets[$k]);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($secrets as $k => $v) {
|
||||
if (null === $v) {
|
||||
$io->error($this->localVault->getLastMessage());
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->vault->seal($k, $v);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Bundle\FrameworkBundle\Command;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Secrets\AbstractVault;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\ConsoleOutputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* @author Tobias Schultze <http://tobion.de>
|
||||
* @author Jérémy Derussé <jeremy@derusse.com>
|
||||
* @author Nicolas Grekas <p@tchwork.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class SecretsGenerateKeysCommand extends Command
|
||||
{
|
||||
protected static $defaultName = 'secrets:generate-keys';
|
||||
|
||||
private $vault;
|
||||
private $localVault;
|
||||
|
||||
public function __construct(AbstractVault $vault, AbstractVault $localVault = null)
|
||||
{
|
||||
$this->vault = $vault;
|
||||
$this->localVault = $localVault;
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure()
|
||||
{
|
||||
$this
|
||||
->setDescription('Generates new encryption keys.')
|
||||
->addOption('local', 'l', InputOption::VALUE_NONE, 'Updates the local vault.')
|
||||
->addOption('rotate', 'r', InputOption::VALUE_NONE, 'Re-encrypts existing secrets with the newly generated keys.')
|
||||
->setHelp(<<<'EOF'
|
||||
The <info>%command.name%</info> command generates a new encryption key.
|
||||
|
||||
<info>%command.full_name%</info>
|
||||
|
||||
If encryption keys already exist, the command must be called with
|
||||
the <info>--rotate</info> option in order to override those keys and re-encrypt
|
||||
existing secrets.
|
||||
|
||||
<info>%command.full_name% --rotate</info>
|
||||
EOF
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output);
|
||||
$vault = $input->getOption('local') ? $this->localVault : $this->vault;
|
||||
|
||||
if (null === $vault) {
|
||||
$io->success('The local vault is disabled.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!$input->getOption('rotate')) {
|
||||
if ($vault->generateKeys()) {
|
||||
$io->success($vault->getLastMessage());
|
||||
|
||||
if ($this->vault === $vault) {
|
||||
$io->caution('DO NOT COMMIT THE DECRYPTION KEY FOR THE PROD ENVIRONMENT⚠️');
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
$io->warning($vault->getLastMessage());
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$secrets = [];
|
||||
foreach ($vault->list(true) as $name => $value) {
|
||||
if (null === $value) {
|
||||
$io->error($vault->getLastMessage());
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$secrets[$name] = $value;
|
||||
}
|
||||
|
||||
if (!$vault->generateKeys(true)) {
|
||||
$io->warning($vault->getLastMessage());
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$io->success($vault->getLastMessage());
|
||||
|
||||
if ($secrets) {
|
||||
foreach ($secrets as $name => $value) {
|
||||
$vault->seal($name, $value);
|
||||
}
|
||||
|
||||
$io->comment('Existing secrets have been rotated to the new keys.');
|
||||
}
|
||||
|
||||
if ($this->vault === $vault) {
|
||||
$io->caution('DO NOT COMMIT THE DECRYPTION KEY FOR THE PROD ENVIRONMENT⚠️');
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Bundle\FrameworkBundle\Command;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Secrets\AbstractVault;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\Dumper;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\ConsoleOutputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* @author Tobias Schultze <http://tobion.de>
|
||||
* @author Jérémy Derussé <jeremy@derusse.com>
|
||||
* @author Nicolas Grekas <p@tchwork.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class SecretsListCommand extends Command
|
||||
{
|
||||
protected static $defaultName = 'secrets:list';
|
||||
|
||||
private $vault;
|
||||
private $localVault;
|
||||
|
||||
public function __construct(AbstractVault $vault, AbstractVault $localVault = null)
|
||||
{
|
||||
$this->vault = $vault;
|
||||
$this->localVault = $localVault;
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure()
|
||||
{
|
||||
$this
|
||||
->setDescription('Lists all secrets.')
|
||||
->addOption('reveal', 'r', InputOption::VALUE_NONE, 'Display decrypted values alongside names')
|
||||
->setHelp(<<<'EOF'
|
||||
The <info>%command.name%</info> command list all stored secrets.
|
||||
|
||||
<info>%command.full_name%</info>
|
||||
|
||||
When the option <info>--reveal</info> is provided, the decrypted secrets are also displayed.
|
||||
|
||||
<info>%command.full_name% --reveal</info>
|
||||
EOF
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output);
|
||||
|
||||
$io->comment('Use <info>"%env(secret:<name>)%"</info> to reference a secret in a config file.');
|
||||
|
||||
if (!$reveal = $input->getOption('reveal')) {
|
||||
$io->comment(sprintf('To reveal the secrets run <info>php %s %s --reveal</info>', $_SERVER['PHP_SELF'], $this->getName()));
|
||||
}
|
||||
|
||||
$secrets = $this->vault->list($reveal);
|
||||
$localSecrets = null !== $this->localVault ? $this->localVault->list($reveal) : null;
|
||||
|
||||
$rows = [];
|
||||
|
||||
$dump = new Dumper($output);
|
||||
$dump = static function (?string $v) use ($dump) {
|
||||
return null === $v ? '******' : $dump($v);
|
||||
};
|
||||
|
||||
foreach ($secrets as $name => $value) {
|
||||
$rows[$name] = [$name, $dump($value)];
|
||||
}
|
||||
|
||||
if (null !== $message = $this->vault->getLastMessage()) {
|
||||
$io->comment($message);
|
||||
}
|
||||
|
||||
foreach ($localSecrets ?? [] as $name => $value) {
|
||||
$rows[$name] = [$name, $rows[$name][1] ?? '', $dump($value)];
|
||||
}
|
||||
|
||||
uksort($rows, 'strnatcmp');
|
||||
|
||||
if (null !== $this->localVault && null !== $message = $this->localVault->getLastMessage()) {
|
||||
$io->comment($message);
|
||||
}
|
||||
|
||||
(new SymfonyStyle($input, $output))
|
||||
->table(['Secret', 'Value'] + (null !== $localSecrets ? [2 => 'Local Value'] : []), $rows);
|
||||
|
||||
$io->comment("Local values override secret values.\nUse <info>secrets:set --local</info> to defined them.");
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Bundle\FrameworkBundle\Command;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Secrets\AbstractVault;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\ConsoleOutputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* @author Jérémy Derussé <jeremy@derusse.com>
|
||||
* @author Nicolas Grekas <p@tchwork.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class SecretsRemoveCommand extends Command
|
||||
{
|
||||
protected static $defaultName = 'secrets:remove';
|
||||
|
||||
private $vault;
|
||||
private $localVault;
|
||||
|
||||
public function __construct(AbstractVault $vault, AbstractVault $localVault = null)
|
||||
{
|
||||
$this->vault = $vault;
|
||||
$this->localVault = $localVault;
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure()
|
||||
{
|
||||
$this
|
||||
->setDescription('Removes a secret from the vault.')
|
||||
->addArgument('name', InputArgument::REQUIRED, 'The name of the secret')
|
||||
->addOption('local', 'l', InputOption::VALUE_NONE, 'Updates the local vault.')
|
||||
->setHelp(<<<'EOF'
|
||||
The <info>%command.name%</info> command removes a secret from the vault.
|
||||
|
||||
<info>%command.full_name% <name></info>
|
||||
EOF
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output);
|
||||
$vault = $input->getOption('local') ? $this->localVault : $this->vault;
|
||||
|
||||
if (null === $vault) {
|
||||
$io->success('The local vault is disabled.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($vault->remove($name = $input->getArgument('name'))) {
|
||||
$io->success($vault->getLastMessage() ?? 'Secret was removed from the vault.');
|
||||
} else {
|
||||
$io->comment($vault->getLastMessage() ?? 'Secret was not found in the vault.');
|
||||
}
|
||||
|
||||
if ($this->vault === $vault && null !== $this->localVault->reveal($name)) {
|
||||
$io->comment('Note that this secret is overridden in the local vault.');
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
134
src/Symfony/Bundle/FrameworkBundle/Command/SecretsSetCommand.php
Normal file
134
src/Symfony/Bundle/FrameworkBundle/Command/SecretsSetCommand.php
Normal file
@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Bundle\FrameworkBundle\Command;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Secrets\AbstractVault;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\ConsoleOutputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* @author Tobias Schultze <http://tobion.de>
|
||||
* @author Jérémy Derussé <jeremy@derusse.com>
|
||||
* @author Nicolas Grekas <p@tchwork.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class SecretsSetCommand extends Command
|
||||
{
|
||||
protected static $defaultName = 'secrets:set';
|
||||
|
||||
private $vault;
|
||||
private $localVault;
|
||||
|
||||
public function __construct(AbstractVault $vault, AbstractVault $localVault = null)
|
||||
{
|
||||
$this->vault = $vault;
|
||||
$this->localVault = $localVault;
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure()
|
||||
{
|
||||
$this
|
||||
->setDescription('Sets a secret in the vault.')
|
||||
->addArgument('name', InputArgument::REQUIRED, 'The name of the secret')
|
||||
->addArgument('file', InputArgument::OPTIONAL, 'A file where to read the secret from or "-" for reading from STDIN')
|
||||
->addOption('local', 'l', InputOption::VALUE_NONE, 'Updates the local vault.')
|
||||
->addOption('random', 'r', InputOption::VALUE_OPTIONAL, 'Generates a random value.', false)
|
||||
->setHelp(<<<'EOF'
|
||||
The <info>%command.name%</info> command stores a secret in the vault.
|
||||
|
||||
<info>%command.full_name% <name></info>
|
||||
|
||||
To reference secrets in services.yaml or any other config
|
||||
files, use <info>"%env(secret:<name>)%"</info>.
|
||||
|
||||
By default, the secret value should be entered interactively.
|
||||
Alternatively, provide a file where to read the secret from:
|
||||
|
||||
<info>php %command.full_name% <name> filename</info>
|
||||
|
||||
Use "-" as a file name to read from STDIN:
|
||||
|
||||
<info>cat filename | php %command.full_name% <name> -</info>
|
||||
|
||||
Use <info>--local</info> to override secrets for local needs.
|
||||
EOF
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$errOutput = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output;
|
||||
$io = new SymfonyStyle($input, $errOutput);
|
||||
$name = $input->getArgument('name');
|
||||
$vault = $input->getOption('local') ? $this->localVault : $this->vault;
|
||||
|
||||
if (null === $vault) {
|
||||
$io->error('The local vault is disabled.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (0 < $random = $input->getOption('random') ?? 16) {
|
||||
$value = strtr(substr(base64_encode(random_bytes($random)), 0, $random), '+/', '-_');
|
||||
} elseif (!$file = $input->getArgument('file')) {
|
||||
$value = $io->askHidden('Please type the secret value');
|
||||
} elseif ('-' === $file) {
|
||||
$value = file_get_contents('php://stdin');
|
||||
} elseif (is_file($file) && is_readable($file)) {
|
||||
$value = file_get_contents($file);
|
||||
} elseif (!is_file($file)) {
|
||||
throw new \InvalidArgumentException(sprintf('File not found: "%s".', $file));
|
||||
} elseif (!is_readable($file)) {
|
||||
throw new \InvalidArgumentException(sprintf('File is not readable: "%s".', $file));
|
||||
}
|
||||
|
||||
if (null === $value) {
|
||||
$io->warning('No value provided, aborting.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($vault->generateKeys()) {
|
||||
$io->success($vault->getLastMessage());
|
||||
|
||||
if ($this->vault === $vault) {
|
||||
$io->caution('DO NOT COMMIT THE DECRYPTION KEY FOR THE PROD ENVIRONMENT⚠️');
|
||||
}
|
||||
}
|
||||
|
||||
$vault->seal($name, $value);
|
||||
|
||||
$io->success($vault->getLastMessage() ?? 'Secret was successfully stored in the vault.');
|
||||
|
||||
if (0 < $random) {
|
||||
$errOutput->write(' // The generated random value is: <comment>');
|
||||
$output->write($value);
|
||||
$errOutput->writeln('</comment>');
|
||||
$io->newLine();
|
||||
}
|
||||
|
||||
if ($this->vault === $vault && null !== $this->localVault->reveal($name)) {
|
||||
$io->comment('Note that this secret is overridden in the local vault.');
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
@ -115,11 +115,28 @@ class Configuration implements ConfigurationInterface
|
||||
$this->addRobotsIndexSection($rootNode);
|
||||
$this->addHttpClientSection($rootNode);
|
||||
$this->addMailerSection($rootNode);
|
||||
$this->addSecretsSection($rootNode);
|
||||
$this->addNotifierSection($rootNode);
|
||||
|
||||
return $treeBuilder;
|
||||
}
|
||||
|
||||
private function addSecretsSection(ArrayNodeDefinition $rootNode)
|
||||
{
|
||||
$rootNode
|
||||
->children()
|
||||
->arrayNode('secrets')
|
||||
->canBeDisabled()
|
||||
->children()
|
||||
->scalarNode('vault_directory')->defaultValue('%kernel.project_dir%/config/secrets/%kernel.environment%')->cannotBeEmpty()->end()
|
||||
->scalarNode('local_dotenv_file')->defaultValue('%kernel.project_dir%/.env.local')->end()
|
||||
->scalarNode('decryption_env_var')->defaultValue('base64:default::SYMFONY_DECRYPTION_SECRET')->end()
|
||||
->end()
|
||||
->end()
|
||||
->end()
|
||||
;
|
||||
}
|
||||
|
||||
private function addCsrfSection(ArrayNodeDefinition $rootNode)
|
||||
{
|
||||
$rootNode
|
||||
|
@ -336,6 +336,7 @@ class FrameworkExtension extends Extension
|
||||
$this->registerRouterConfiguration($config['router'], $container, $loader);
|
||||
$this->registerAnnotationsConfiguration($config['annotations'], $container, $loader);
|
||||
$this->registerPropertyAccessConfiguration($config['property_access'], $container, $loader);
|
||||
$this->registerSecretsConfiguration($config['secrets'], $container, $loader);
|
||||
|
||||
if ($this->isConfigEnabled($container, $config['serializer'])) {
|
||||
if (!class_exists('Symfony\Component\Serializer\Serializer')) {
|
||||
@ -1316,6 +1317,34 @@ class FrameworkExtension extends Extension
|
||||
;
|
||||
}
|
||||
|
||||
private function registerSecretsConfiguration(array $config, ContainerBuilder $container, XmlFileLoader $loader)
|
||||
{
|
||||
if (!$this->isConfigEnabled($container, $config)) {
|
||||
$container->removeDefinition('console.command.secrets_set');
|
||||
$container->removeDefinition('console.command.secrets_list');
|
||||
$container->removeDefinition('console.command.secrets_remove');
|
||||
$container->removeDefinition('console.command.secrets_generate_key');
|
||||
$container->removeDefinition('console.command.secrets_decrypt_to_local');
|
||||
$container->removeDefinition('console.command.secrets_encrypt_from_local');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$loader->load('secrets.xml');
|
||||
|
||||
$container->getDefinition('secrets.vault')->replaceArgument(0, $config['vault_directory']);
|
||||
|
||||
if (!$config['local_dotenv_file']) {
|
||||
$container->removeDefinition('secrets.local_vault');
|
||||
}
|
||||
|
||||
if ($config['decryption_env_var']) {
|
||||
$container->getDefinition('secrets.decryption_key')->replaceArgument(1, $config['decryption_env_var']);
|
||||
} else {
|
||||
$container->removeDefinition('secrets.decryption_key');
|
||||
}
|
||||
}
|
||||
|
||||
private function registerSecurityCsrfConfiguration(array $config, ContainerBuilder $container, XmlFileLoader $loader)
|
||||
{
|
||||
if (!$this->isConfigEnabled($container, $config)) {
|
||||
|
@ -199,5 +199,41 @@
|
||||
<argument type="service" id="debug.file_link_formatter" on-invalid="null" />
|
||||
<tag name="console.command" command="debug:error-renderer" />
|
||||
</service>
|
||||
|
||||
<service id="console.command.secrets_set" class="Symfony\Bundle\FrameworkBundle\Command\SecretsSetCommand">
|
||||
<argument type="service" id="secrets.vault" />
|
||||
<argument type="service" id="secrets.local_vault" on-invalid="ignore" />
|
||||
<tag name="console.command" command="secrets:set" />
|
||||
</service>
|
||||
|
||||
<service id="console.command.secrets_remove" class="Symfony\Bundle\FrameworkBundle\Command\SecretsRemoveCommand">
|
||||
<argument type="service" id="secrets.vault" />
|
||||
<argument type="service" id="secrets.local_vault" on-invalid="ignore" />
|
||||
<tag name="console.command" command="secrets:remove" />
|
||||
</service>
|
||||
|
||||
<service id="console.command.secrets_generate_key" class="Symfony\Bundle\FrameworkBundle\Command\SecretsGenerateKeysCommand">
|
||||
<argument type="service" id="secrets.vault" />
|
||||
<argument type="service" id="secrets.local_vault" on-invalid="ignore" />
|
||||
<tag name="console.command" command="secrets:generate-keys" />
|
||||
</service>
|
||||
|
||||
<service id="console.command.secrets_list" class="Symfony\Bundle\FrameworkBundle\Command\SecretsListCommand">
|
||||
<argument type="service" id="secrets.vault" />
|
||||
<argument type="service" id="secrets.local_vault" on-invalid="ignore" />
|
||||
<tag name="console.command" command="secrets:list" />
|
||||
</service>
|
||||
|
||||
<service id="console.command.secrets_decrypt_to_local" class="Symfony\Bundle\FrameworkBundle\Command\SecretsDecryptToLocalCommand">
|
||||
<argument type="service" id="secrets.vault" />
|
||||
<argument type="service" id="secrets.local_vault" on-invalid="ignore" />
|
||||
<tag name="console.command" command="secrets:decrypt-to-local" />
|
||||
</service>
|
||||
|
||||
<service id="console.command.secrets_encrypt_from_local" class="Symfony\Bundle\FrameworkBundle\Command\SecretsEncryptFromLocalCommand">
|
||||
<argument type="service" id="secrets.vault" />
|
||||
<argument type="service" id="secrets.local_vault" on-invalid="ignore" />
|
||||
<tag name="console.command" command="secrets:encrypt-from-local" />
|
||||
</service>
|
||||
</services>
|
||||
</container>
|
||||
|
@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" ?>
|
||||
|
||||
<container xmlns="http://symfony.com/schema/dic/services"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://symfony.com/schema/dic/services https://symfony.com/schema/dic/services/services-1.0.xsd">
|
||||
|
||||
<services>
|
||||
<service id="secrets.vault" class="Symfony\Bundle\FrameworkBundle\Secrets\SodiumVault">
|
||||
<argument>%kernel.project_dir%/config/secrets/%kernel.environment%</argument>
|
||||
<argument type="service" id="secrets.decryption_key" on-invalid="ignore" />
|
||||
</service>
|
||||
|
||||
<!--
|
||||
LazyString::fromCallable() is used as a wrapper to lazily read the SYMFONY_DECRYPTION_SECRET var from the env.
|
||||
By overriding this service and using the same strategy, the decryption key can be fetched lazily from any other service if needed.
|
||||
-->
|
||||
<service id="secrets.decryption_key" class="Symfony\Component\DependencyInjection\LazyString">
|
||||
<factory class="Symfony\Component\DependencyInjection\LazyString" method="fromCallable" />
|
||||
<argument type="service">
|
||||
<service class="Closure">
|
||||
<factory class="Closure" method="fromCallable" />
|
||||
<argument type="collection">
|
||||
<argument type="service" id="service_container" />
|
||||
<argument>getEnv</argument>
|
||||
</argument>
|
||||
</service>
|
||||
</argument>
|
||||
<argument>base64:default::SYMFONY_DECRYPTION_SECRET</argument>
|
||||
</service>
|
||||
|
||||
<service id="secrets.local_vault" class="Symfony\Bundle\FrameworkBundle\Secrets\DotenvVault">
|
||||
<argument>%kernel.project_dir%/.env.local</argument>
|
||||
</service>
|
||||
|
||||
<service id="secrets.env_var_processor" class="Symfony\Bundle\FrameworkBundle\Secrets\SecretEnvVarProcessor">
|
||||
<argument type="service" id="secrets.vault" />
|
||||
<argument type="service" id="secrets.local_vault" on-invalid="ignore" />
|
||||
<tag name="container.env_var_processor" />
|
||||
</service>
|
||||
</services>
|
||||
</container>
|
49
src/Symfony/Bundle/FrameworkBundle/Secrets/AbstractVault.php
Normal file
49
src/Symfony/Bundle/FrameworkBundle/Secrets/AbstractVault.php
Normal file
@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Bundle\FrameworkBundle\Secrets;
|
||||
|
||||
/**
|
||||
* @author Nicolas Grekas <p@tchwork.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
abstract class AbstractVault
|
||||
{
|
||||
protected $lastMessage;
|
||||
|
||||
public function getLastMessage(): ?string
|
||||
{
|
||||
return $this->lastMessage;
|
||||
}
|
||||
|
||||
abstract public function generateKeys(bool $override = false): bool;
|
||||
|
||||
abstract public function seal(string $name, string $value): void;
|
||||
|
||||
abstract public function reveal(string $name): ?string;
|
||||
|
||||
abstract public function remove(string $name): bool;
|
||||
|
||||
abstract public function list(bool $reveal = false): array;
|
||||
|
||||
protected function validateName(string $name): void
|
||||
{
|
||||
if (!preg_match('/^\w++$/D', $name)) {
|
||||
throw new \LogicException(sprintf('Invalid secret name "%s": only "word" characters are allowed.', $name));
|
||||
}
|
||||
}
|
||||
|
||||
protected function getPrettyPath(string $path)
|
||||
{
|
||||
return str_replace(getcwd().\DIRECTORY_SEPARATOR, '', $path);
|
||||
}
|
||||
}
|
110
src/Symfony/Bundle/FrameworkBundle/Secrets/DotenvVault.php
Normal file
110
src/Symfony/Bundle/FrameworkBundle/Secrets/DotenvVault.php
Normal file
@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Bundle\FrameworkBundle\Secrets;
|
||||
|
||||
/**
|
||||
* @author Nicolas Grekas <p@tchwork.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class DotenvVault extends AbstractVault
|
||||
{
|
||||
private $dotenvFile;
|
||||
|
||||
public function __construct(string $dotenvFile)
|
||||
{
|
||||
$this->dotenvFile = strtr($dotenvFile, '/', \DIRECTORY_SEPARATOR);
|
||||
}
|
||||
|
||||
public function generateKeys(bool $override = false): bool
|
||||
{
|
||||
$this->lastMessage = 'The dotenv vault doesn\'t encrypt secrets thus doesn\'t need keys.';
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function seal(string $name, string $value): void
|
||||
{
|
||||
$this->lastMessage = null;
|
||||
$this->validateName($name);
|
||||
$k = $name.'_SECRET';
|
||||
$v = str_replace("'", "'\\''", $value);
|
||||
|
||||
$content = file_exists($this->dotenvFile) ? file_get_contents($this->dotenvFile) : '';
|
||||
$content = preg_replace("/^$k=((\\\\'|'[^']++')++|.*)/m", "$k='$v'", $content, -1, $count);
|
||||
|
||||
if (!$count) {
|
||||
$content .= "$k='$v'\n";
|
||||
}
|
||||
|
||||
file_put_contents($this->dotenvFile, $content);
|
||||
|
||||
$this->lastMessage = sprintf('Secret "%s" %s in "%s".', $name, $count ? 'added' : 'updated', $this->getPrettyPath($this->dotenvFile));
|
||||
}
|
||||
|
||||
public function reveal(string $name): ?string
|
||||
{
|
||||
$this->lastMessage = null;
|
||||
$this->validateName($name);
|
||||
$k = $name.'_SECRET';
|
||||
$v = \is_string($_SERVER[$k] ?? null) ? $_SERVER[$k] : ($_ENV[$k] ?? null);
|
||||
|
||||
if (null === $v) {
|
||||
$this->lastMessage = sprintf('Secret "%s" not found in "%s".', $name, $this->getPrettyPath($this->dotenvFile));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $v;
|
||||
}
|
||||
|
||||
public function remove(string $name): bool
|
||||
{
|
||||
$this->lastMessage = null;
|
||||
$this->validateName($name);
|
||||
$k = $name.'_SECRET';
|
||||
|
||||
$content = file_exists($this->dotenvFile) ? file_get_contents($this->dotenvFile) : '';
|
||||
$content = preg_replace("/^$k=((\\\\'|'[^']++')++|.*)\n?/m", '', $content, -1, $count);
|
||||
|
||||
if ($count) {
|
||||
file_put_contents($this->dotenvFile, $content);
|
||||
$this->lastMessage = sprintf('Secret "%s" removed from file "%s".', $name, $this->getPrettyPath($this->dotenvFile));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->lastMessage = sprintf('Secret "%s" not found in "%s".', $name, $this->getPrettyPath($this->dotenvFile));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function list(bool $reveal = false): array
|
||||
{
|
||||
$this->lastMessage = null;
|
||||
$secrets = [];
|
||||
|
||||
foreach ($_ENV as $k => $v) {
|
||||
if (preg_match('/^(\w+)_SECRET$/D', $k, $m)) {
|
||||
$secrets[$m[1]] = $reveal ? $v : null;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($_SERVER as $k => $v) {
|
||||
if (\is_string($v) && preg_match('/^(\w+)_SECRET$/D', $k, $m)) {
|
||||
$secrets[$m[1]] = $reveal ? $v : null;
|
||||
}
|
||||
}
|
||||
|
||||
return $secrets;
|
||||
}
|
||||
}
|
@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Bundle\FrameworkBundle\Secrets;
|
||||
|
||||
use Symfony\Component\DependencyInjection\EnvVarProcessorInterface;
|
||||
use Symfony\Component\DependencyInjection\Exception\EnvNotFoundException;
|
||||
|
||||
/**
|
||||
* @author Tobias Schultze <http://tobion.de>
|
||||
* @author Nicolas Grekas <p@tchwork.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class SecretEnvVarProcessor implements EnvVarProcessorInterface
|
||||
{
|
||||
private $vault;
|
||||
private $localVault;
|
||||
|
||||
public function __construct(AbstractVault $vault, AbstractVault $localVault = null)
|
||||
{
|
||||
$this->vault = $vault;
|
||||
$this->localVault = $localVault;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public static function getProvidedTypes()
|
||||
{
|
||||
return [
|
||||
'secret' => 'string',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getEnv($prefix, $name, \Closure $getEnv)
|
||||
{
|
||||
if (null !== $this->localVault && null !== $secret = $this->localVault->reveal($name)) {
|
||||
return $secret;
|
||||
}
|
||||
|
||||
if (null !== $secret = $this->vault->reveal($name)) {
|
||||
return $secret;
|
||||
}
|
||||
|
||||
throw new EnvNotFoundException($this->vault->getLastMessage() ?? sprintf('Secret "%s" not found or decryption key is missing.', $name));
|
||||
}
|
||||
}
|
176
src/Symfony/Bundle/FrameworkBundle/Secrets/SodiumVault.php
Normal file
176
src/Symfony/Bundle/FrameworkBundle/Secrets/SodiumVault.php
Normal file
@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Bundle\FrameworkBundle\Secrets;
|
||||
|
||||
/**
|
||||
* @author Tobias Schultze <http://tobion.de>
|
||||
* @author Jérémy Derussé <jeremy@derusse.com>
|
||||
* @author Nicolas Grekas <p@tchwork.com>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class SodiumVault extends AbstractVault
|
||||
{
|
||||
private $encryptionKey;
|
||||
private $decryptionKey;
|
||||
private $pathPrefix;
|
||||
|
||||
/**
|
||||
* @param string|object|null $decryptionKey A string or a stringable object that defines the private key to use to decrypt the vault
|
||||
* or null to store generated keys in the provided $secretsDir
|
||||
*/
|
||||
public function __construct(string $secretsDir, $decryptionKey = null)
|
||||
{
|
||||
if (!\function_exists('sodium_crypto_box_seal')) {
|
||||
throw new \LogicException('The "sodium" PHP extension is required to deal with secrets. Alternatively, try running "composer require paragonie/sodium_compat" if you cannot enable the extension."');
|
||||
}
|
||||
|
||||
if (null !== $decryptionKey && !\is_string($decryptionKey) && !(\is_object($decryptionKey) && method_exists($decryptionKey, '__toString'))) {
|
||||
throw new \TypeError(sprintf('Decryption key should be a string or an object that implements the __toString() method, %s given.', \gettype($decryptionKey)));
|
||||
}
|
||||
|
||||
if (!is_dir($secretsDir) && !@mkdir($secretsDir, 0777, true) && !is_dir($secretsDir)) {
|
||||
throw new \RuntimeException(sprintf('Unable to create the secrets directory (%s)', $secretsDir));
|
||||
}
|
||||
|
||||
$this->pathPrefix = rtrim(strtr($secretsDir, '/', \DIRECTORY_SEPARATOR), \DIRECTORY_SEPARATOR).\DIRECTORY_SEPARATOR.basename($secretsDir).'.';
|
||||
$this->decryptionKey = $decryptionKey;
|
||||
}
|
||||
|
||||
public function generateKeys(bool $override = false): bool
|
||||
{
|
||||
$this->lastMessage = null;
|
||||
|
||||
if (null === $this->encryptionKey && '' !== $this->decryptionKey = (string) $this->decryptionKey) {
|
||||
throw new \LogicException('Cannot generate keys when a decryption key has been provided while instantiating the vault.');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->loadKeys();
|
||||
} catch (\LogicException $e) {
|
||||
// ignore failures to load keys
|
||||
}
|
||||
|
||||
if ('' !== $this->decryptionKey && !file_exists($this->pathPrefix.'sodium.encrypt.public')) {
|
||||
$this->export('sodium.encrypt.public', $this->encryptionKey);
|
||||
}
|
||||
|
||||
if (!$override && null !== $this->encryptionKey) {
|
||||
$this->lastMessage = sprintf('Sodium keys already exist at "%s*.{public,private}" and won\'t be overridden.', $this->getPrettyPath($this->pathPrefix));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->decryptionKey = sodium_crypto_box_keypair();
|
||||
$this->encryptionKey = sodium_crypto_box_publickey($this->decryptionKey);
|
||||
|
||||
$this->export('sodium.encrypt.public', $this->encryptionKey);
|
||||
$this->export('sodium.decrypt.private', $this->decryptionKey);
|
||||
|
||||
$this->lastMessage = sprintf('Sodium keys have been generated at "%s*.{public,private}".', $this->getPrettyPath($this->pathPrefix));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function seal(string $name, string $value): void
|
||||
{
|
||||
$this->lastMessage = null;
|
||||
$this->validateName($name);
|
||||
$this->loadKeys();
|
||||
$this->export($name.'.'.substr_replace(md5($name), '.sodium', -26), sodium_crypto_box_seal($value, $this->encryptionKey ?? sodium_crypto_box_publickey($this->decryptionKey)));
|
||||
$this->lastMessage = sprintf('Secret "%s" encrypted in "%s"; you can commit it.', $name, $this->getPrettyPath(\dirname($this->pathPrefix).\DIRECTORY_SEPARATOR));
|
||||
}
|
||||
|
||||
public function reveal(string $name): ?string
|
||||
{
|
||||
$this->lastMessage = null;
|
||||
$this->validateName($name);
|
||||
|
||||
if (!file_exists($file = $this->pathPrefix.$name.'.'.substr_replace(md5($name), '.sodium', -26))) {
|
||||
$this->lastMessage = sprintf('Secret "%s" not found in "%s".', $name, $this->getPrettyPath(\dirname($this->pathPrefix).\DIRECTORY_SEPARATOR));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->loadKeys();
|
||||
|
||||
if ('' === $this->decryptionKey) {
|
||||
$this->lastMessage = sprintf('Secrets cannot be revealed as no decryption key was found in "%s".', $this->getPrettyPath(\dirname($this->pathPrefix).\DIRECTORY_SEPARATOR));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return sodium_crypto_box_seal_open(include $file, $this->decryptionKey);
|
||||
}
|
||||
|
||||
public function remove(string $name): bool
|
||||
{
|
||||
$this->lastMessage = null;
|
||||
$this->validateName($name);
|
||||
|
||||
if (!file_exists($file = $this->pathPrefix.$name.'.'.substr_replace(md5($name), '.sodium', -26))) {
|
||||
$this->lastMessage = sprintf('Secret "%s" not found in "%s".', $name, $this->getPrettyPath(\dirname($this->pathPrefix).\DIRECTORY_SEPARATOR));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->lastMessage = sprintf('Secret "%s" removed from "%s".', $name, $this->getPrettyPath(\dirname($this->pathPrefix).\DIRECTORY_SEPARATOR));
|
||||
|
||||
return @unlink($file) || !file_exists($file);
|
||||
}
|
||||
|
||||
public function list(bool $reveal = false): array
|
||||
{
|
||||
$this->lastMessage = null;
|
||||
$secrets = [];
|
||||
$regexp = sprintf('{^%s(\w++)\.[0-9a-f]{6}\.sodium$}D', preg_quote(basename($this->pathPrefix)));
|
||||
|
||||
foreach (scandir(\dirname($this->pathPrefix)) as $name) {
|
||||
if (preg_match($regexp, $name, $m)) {
|
||||
$secrets[$m[1]] = $reveal ? $this->reveal($m[1]) : null;
|
||||
}
|
||||
}
|
||||
|
||||
return $secrets;
|
||||
}
|
||||
|
||||
private function loadKeys(): void
|
||||
{
|
||||
if (null !== $this->encryptionKey || '' !== $this->decryptionKey = (string) $this->decryptionKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (file_exists($this->pathPrefix.'sodium.decrypt.private')) {
|
||||
$this->decryptionKey = (string) include $this->pathPrefix.'sodium.decrypt.private';
|
||||
}
|
||||
|
||||
if (file_exists($this->pathPrefix.'sodium.encrypt.public')) {
|
||||
$this->encryptionKey = (string) include $this->pathPrefix.'sodium.encrypt.public';
|
||||
} elseif ('' !== $this->decryptionKey) {
|
||||
$this->encryptionKey = sodium_crypto_box_publickey($this->decryptionKey);
|
||||
} else {
|
||||
throw new \LogicException(sprintf('Encryption key not found in "%s".', \dirname($this->pathPrefix)));
|
||||
}
|
||||
}
|
||||
|
||||
private function export(string $file, string $data): void
|
||||
{
|
||||
$name = basename($this->pathPrefix.$file);
|
||||
$data = str_replace('%', '\x', rawurlencode($data));
|
||||
$data = sprintf("<?php // %s on %s\n\nreturn \"%s\";\n", $name, date('r'), $data);
|
||||
|
||||
if (false === file_put_contents($this->pathPrefix.$file, $data, LOCK_EX)) {
|
||||
$e = error_get_last();
|
||||
throw new \ErrorException($e['message'] ?? 'Failed to write secrets data.', 0, $e['type'] ?? E_USER_WARNING);
|
||||
}
|
||||
}
|
||||
}
|
@ -420,6 +420,12 @@ class ConfigurationTest extends TestCase
|
||||
'admin_recipients' => [],
|
||||
],
|
||||
'error_controller' => 'error_controller',
|
||||
'secrets' => [
|
||||
'enabled' => true,
|
||||
'vault_directory' => '%kernel.project_dir%/config/secrets/%kernel.environment%',
|
||||
'local_dotenv_file' => '%kernel.project_dir%/.env.local',
|
||||
'decryption_env_var' => 'base64:default::SYMFONY_DECRYPTION_SECRET',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
@ -280,7 +280,7 @@ abstract class FrameworkExtensionTest extends TestCase
|
||||
public function testWorkflowAreValidated()
|
||||
{
|
||||
$this->expectException('Symfony\Component\Workflow\Exception\InvalidDefinitionException');
|
||||
$this->expectExceptionMessage('A transition from a place/state must have an unique name. Multiple transitions named "go" from place/state "first" where found on StateMachine "my_workflow".');
|
||||
$this->expectExceptionMessage('A transition from a place/state must have an unique name. Multiple transitions named "go" from place/state "first" were found on StateMachine "my_workflow".');
|
||||
$this->createContainerFromFile('workflow_not_valid');
|
||||
}
|
||||
|
||||
|
@ -56,7 +56,7 @@ class PhpFrameworkExtensionTest extends FrameworkExtensionTest
|
||||
public function testWorkflowValidationStateMachine()
|
||||
{
|
||||
$this->expectException('Symfony\Component\Workflow\Exception\InvalidDefinitionException');
|
||||
$this->expectExceptionMessage('A transition from a place/state must have an unique name. Multiple transitions named "a_to_b" from place/state "a" where found on StateMachine "article".');
|
||||
$this->expectExceptionMessage('A transition from a place/state must have an unique name. Multiple transitions named "a_to_b" from place/state "a" were found on StateMachine "article".');
|
||||
$this->createContainerFromClosure(function ($container) {
|
||||
$container->loadFromExtension('framework', [
|
||||
'workflows' => [
|
||||
|
@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace Symfony\Bundle\FrameworkBundle\Tests\Secrets;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Bundle\FrameworkBundle\Secrets\DotenvVault;
|
||||
use Symfony\Component\Dotenv\Dotenv;
|
||||
|
||||
class DotenvVaultTest extends TestCase
|
||||
{
|
||||
private $envFile;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->envFile = sys_get_temp_dir().'/sf_secrets.env.test';
|
||||
@unlink($this->envFile);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
@unlink($this->envFile);
|
||||
}
|
||||
|
||||
public function testGenerateKeys()
|
||||
{
|
||||
$vault = new DotenvVault($this->envFile);
|
||||
|
||||
$this->assertFalse($vault->generateKeys());
|
||||
$this->assertSame('The dotenv vault doesn\'t encrypt secrets thus doesn\'t need keys.', $vault->getLastMessage());
|
||||
}
|
||||
|
||||
public function testEncryptAndDecrypt()
|
||||
{
|
||||
$vault = new DotenvVault($this->envFile);
|
||||
|
||||
$plain = "plain\ntext";
|
||||
|
||||
$vault->seal('foo', $plain);
|
||||
|
||||
unset($_SERVER['foo_SECRET'], $_ENV['foo_SECRET']);
|
||||
(new Dotenv(false))->load($this->envFile);
|
||||
|
||||
$decrypted = $vault->reveal('foo');
|
||||
$this->assertSame($plain, $decrypted);
|
||||
|
||||
$this->assertSame(['foo' => null], $vault->list());
|
||||
$this->assertSame(['foo' => $plain], $vault->list(true));
|
||||
|
||||
$this->assertTrue($vault->remove('foo'));
|
||||
$this->assertFalse($vault->remove('foo'));
|
||||
|
||||
unset($_SERVER['foo_SECRET'], $_ENV['foo_SECRET']);
|
||||
(new Dotenv(false))->load($this->envFile);
|
||||
|
||||
$this->assertSame([], $vault->list());
|
||||
}
|
||||
}
|
@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace Symfony\Bundle\FrameworkBundle\Tests\Secrets;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Bundle\FrameworkBundle\Secrets\SodiumVault;
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
|
||||
class SodiumVaultTest extends TestCase
|
||||
{
|
||||
private $secretsDir;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->secretsDir = sys_get_temp_dir().'/sf_secrets/test/';
|
||||
(new Filesystem())->remove($this->secretsDir);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
(new Filesystem())->remove($this->secretsDir);
|
||||
}
|
||||
|
||||
public function testGenerateKeys()
|
||||
{
|
||||
$vault = new SodiumVault($this->secretsDir);
|
||||
|
||||
$this->assertTrue($vault->generateKeys());
|
||||
$this->assertFileExists($this->secretsDir.'/test.sodium.encrypt.public');
|
||||
$this->assertFileExists($this->secretsDir.'/test.sodium.decrypt.private');
|
||||
|
||||
$encKey = file_get_contents($this->secretsDir.'/test.sodium.encrypt.public');
|
||||
$decKey = file_get_contents($this->secretsDir.'/test.sodium.decrypt.private');
|
||||
|
||||
$this->assertFalse($vault->generateKeys());
|
||||
$this->assertStringEqualsFile($this->secretsDir.'/test.sodium.encrypt.public', $encKey);
|
||||
$this->assertStringEqualsFile($this->secretsDir.'/test.sodium.decrypt.private', $decKey);
|
||||
|
||||
$this->assertTrue($vault->generateKeys(true));
|
||||
$this->assertStringNotEqualsFile($this->secretsDir.'/test.sodium.encrypt.public', $encKey);
|
||||
$this->assertStringNotEqualsFile($this->secretsDir.'/test.sodium.decrypt.private', $decKey);
|
||||
}
|
||||
|
||||
public function testEncryptAndDecrypt()
|
||||
{
|
||||
$vault = new SodiumVault($this->secretsDir);
|
||||
$vault->generateKeys();
|
||||
|
||||
$plain = "plain\ntext";
|
||||
|
||||
$vault->seal('foo', $plain);
|
||||
|
||||
$decrypted = $vault->reveal('foo');
|
||||
$this->assertSame($plain, $decrypted);
|
||||
|
||||
$this->assertSame(['foo' => null], $vault->list());
|
||||
$this->assertSame(['foo' => $plain], $vault->list(true));
|
||||
|
||||
$this->assertTrue($vault->remove('foo'));
|
||||
$this->assertFalse($vault->remove('foo'));
|
||||
|
||||
$this->assertSame([], $vault->list());
|
||||
}
|
||||
}
|
@ -37,6 +37,7 @@
|
||||
"symfony/console": "^4.4|^5.0",
|
||||
"symfony/css-selector": "^4.4|^5.0",
|
||||
"symfony/dom-crawler": "^4.4|^5.0",
|
||||
"symfony/dotenv": "^4.4|^5.0",
|
||||
"symfony/polyfill-intl-icu": "~1.0",
|
||||
"symfony/form": "^4.4|^5.0",
|
||||
"symfony/expression-language": "^4.4|^5.0",
|
||||
|
@ -115,7 +115,7 @@ class QuestionHelper extends Helper
|
||||
{
|
||||
$this->writePrompt($output, $question);
|
||||
|
||||
$inputStream = $this->inputStream ?: fopen('php://stdin', 'r');
|
||||
$inputStream = $this->inputStream ?: STDIN;
|
||||
$autocomplete = $question->getAutocompleterCallback();
|
||||
|
||||
if (null === $autocomplete || !Terminal::hasSttyAvailable()) {
|
||||
|
@ -4,6 +4,7 @@ CHANGELOG
|
||||
4.4.0
|
||||
-----
|
||||
|
||||
* added `canceled` to `ResponseInterface::getInfo()`
|
||||
* added `HttpClient::createForBaseUri()`
|
||||
* added `HttplugClient` with support for sync and async requests
|
||||
* added `max_duration` option
|
||||
|
@ -96,6 +96,7 @@ final class NativeHttpClient implements HttpClientInterface, LoggerAwareInterfac
|
||||
'response_headers' => [],
|
||||
'url' => $url,
|
||||
'error' => null,
|
||||
'canceled' => false,
|
||||
'http_method' => $method,
|
||||
'http_code' => 0,
|
||||
'redirect_count' => 0,
|
||||
|
@ -84,6 +84,7 @@ class MockResponse implements ResponseInterface
|
||||
*/
|
||||
public function cancel(): void
|
||||
{
|
||||
$this->info['canceled'] = true;
|
||||
$this->info['error'] = 'Response has been canceled.';
|
||||
$this->body = null;
|
||||
}
|
||||
|
@ -52,6 +52,7 @@ trait ResponseTrait
|
||||
'response_headers' => [],
|
||||
'http_code' => 0,
|
||||
'error' => null,
|
||||
'canceled' => false,
|
||||
];
|
||||
|
||||
/** @var resource */
|
||||
@ -178,6 +179,7 @@ trait ResponseTrait
|
||||
*/
|
||||
public function cancel(): void
|
||||
{
|
||||
$this->info['canceled'] = true;
|
||||
$this->info['error'] = 'Response has been canceled.';
|
||||
$this->close();
|
||||
}
|
||||
|
@ -105,6 +105,7 @@ class MockHttpClientTest extends HttpClientTestCase
|
||||
case 'testOnProgressError':
|
||||
case 'testReentrantBufferCallback':
|
||||
case 'testThrowingBufferCallback':
|
||||
case 'testInfoOnCanceledResponse':
|
||||
$responses[] = new MockResponse($body, ['response_headers' => $headers]);
|
||||
break;
|
||||
|
||||
|
@ -37,7 +37,7 @@ class StateMachineValidator implements DefinitionValidatorInterface
|
||||
// Enforcing uniqueness of the names of transitions starting at each node
|
||||
$from = reset($froms);
|
||||
if (isset($transitionFromNames[$from][$transition->getName()])) {
|
||||
throw new InvalidDefinitionException(sprintf('A transition from a place/state must have an unique name. Multiple transitions named "%s" from place/state "%s" where found on StateMachine "%s".', $transition->getName(), $from, $name));
|
||||
throw new InvalidDefinitionException(sprintf('A transition from a place/state must have an unique name. Multiple transitions named "%s" from place/state "%s" were found on StateMachine "%s".', $transition->getName(), $from, $name));
|
||||
}
|
||||
|
||||
$transitionFromNames[$from][$transition->getName()] = true;
|
||||
|
@ -88,15 +88,16 @@ interface ResponseInterface
|
||||
* another, as the request/response progresses.
|
||||
*
|
||||
* The following info MUST be returned:
|
||||
* - response_headers - an array modelled after the special $http_response_header variable
|
||||
* - redirect_count - the number of redirects followed while executing the request
|
||||
* - redirect_url - the resolved location of redirect responses, null otherwise
|
||||
* - start_time - the time when the request was sent or 0.0 when it's pending
|
||||
* - http_method - the HTTP verb of the last request
|
||||
* - http_code - the last response code or 0 when it is not known yet
|
||||
* - error - the error message when the transfer was aborted, null otherwise
|
||||
* - user_data - the value of the "user_data" request option, null if not set
|
||||
* - url - the last effective URL of the request
|
||||
* - canceled (bool) - true if the response was canceled using ResponseInterface::cancel(), false otherwise
|
||||
* - error (string|null) - the error message when the transfer was aborted, null otherwise
|
||||
* - http_code (int) - the last response code or 0 when it is not known yet
|
||||
* - http_method (string) - the HTTP verb of the last request
|
||||
* - redirect_count (int) - the number of redirects followed while executing the request
|
||||
* - redirect_url (string|null) - the resolved location of redirect responses, null otherwise
|
||||
* - response_headers (array) - an array modelled after the special $http_response_header variable
|
||||
* - start_time (float) - the time when the request was sent or 0.0 when it's pending
|
||||
* - url (string) - the last effective URL of the request
|
||||
* - user_data (mixed|null) - the value of the "user_data" request option, null if not set
|
||||
*
|
||||
* When the "capture_peer_cert_chain" option is true, the "peer_certificate_chain"
|
||||
* attribute SHOULD list the peer certificates as an array of OpenSSL X.509 resources.
|
||||
|
@ -515,6 +515,17 @@ abstract class HttpClientTestCase extends TestCase
|
||||
$response->getHeaders();
|
||||
}
|
||||
|
||||
public function testInfoOnCanceledResponse()
|
||||
{
|
||||
$client = $this->getHttpClient(__FUNCTION__);
|
||||
|
||||
$response = $client->request('GET', 'http://localhost:8057/timeout-header');
|
||||
|
||||
$this->assertFalse($response->getInfo('canceled'));
|
||||
$response->cancel();
|
||||
$this->assertTrue($response->getInfo('canceled'));
|
||||
}
|
||||
|
||||
public function testCancelInStream()
|
||||
{
|
||||
$client = $this->getHttpClient(__FUNCTION__);
|
||||
|
Reference in New Issue
Block a user