Commit 56ebc243 authored by Hendrik Heneke's avatar Hendrik Heneke
Browse files

Added commands for secrets management.

parent ed373eda
Pipeline #376 passed with stage
in 37 seconds
<?php
declare(strict_types=1);
namespace HHIT\ConfigGenerator\Command;
use HHIT\ConfigGenerator\Generator\Factory;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
abstract class AbstractCommand extends Command
{
protected string $projectDir;
public function __construct(string $projectDir, ?string $name = null)
{
$this->projectDir = $projectDir;
parent::__construct($name);
}
protected function configure()
{
$this->addOption('project', 'p', InputOption::VALUE_OPTIONAL, 'project (dir)', $this->projectDir);
$this->addOption('env', 'e', InputOption::VALUE_OPTIONAL, 'environment', 'dev');
}
protected function createFactory(InputInterface $input): Factory
{
$env = $input->getOption('env');
$factory = new Factory($input->hasOption('project') ? $input->getOption('project') : $this->projectDir, $env);
$factory->bootEnv();
return $factory;
}
}
<?php
declare(strict_types=1);
namespace HHIT\ConfigGenerator\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class DumpPrivateKeyCommand extends AbstractCommand
{
protected static $defaultName = 'dump-private-key';
protected function execute(InputInterface $input, OutputInterface $output)
{
$factory = $this->createFactory($input);
$output->writeln('<info>' . $factory->dumpPrivateKey() . '</info>');
return 0;
}
}
......@@ -9,35 +9,22 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
class GenerateConfigsCommand extends Command
class GenerateConfigsCommand extends AbstractCommand
{
protected static $defaultName = "generate-configs";
private string $projectDir;
public function __construct(string $projectDir, ?string $name = null)
{
$this->projectDir = $projectDir;
parent::__construct($name);
}
protected function configure()
{
$this->addOption('project', 'p', InputOption::VALUE_OPTIONAL, 'project (dir)', $this->projectDir);
$this->addOption('env', 'e', InputOption::VALUE_OPTIONAL, 'environment', 'dev');
parent::configure();
$this->addOption('config', 'c', InputOption::VALUE_OPTIONAL, 'configuration file');
$this->addOption('overwrite', null, InputOption::VALUE_NONE, 'overwrite existing files');
}
public function execute(InputInterface $input, OutputInterface $output)
{
$env = $input->getOption('env');
$factory = new Factory($input->hasOption('project') ? $input->getOption('project') : $this->projectDir, $env);
$factory->bootEnv();
$factory = $this->createFactory($input);
$generator = $factory->createGenerator();
$output->writeln("<info>Generating configuration files for {$env}</info>");
$output->writeln("<info>Generating configuration files</info>");
$success = $generator->processConfigurations(
'symfony',
$input->getOption('overwrite') ? true : false,
......
<?php
declare(strict_types=1);
namespace HHIT\ConfigGenerator\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;
class GenerateKeysCommand extends AbstractCommand
{
protected static $defaultName = 'generate-keys';
protected function configure()
{
parent::configure();
$this->addOption('local', 'l', InputOption::VALUE_NONE, 'Updates the local vault.');
$this->addOption('rotate', 'r', InputOption::VALUE_NONE, 'Re-encrypts existing secrets with the newly generated keys.');
}
public function execute(InputInterface $input, OutputInterface $output)
{
$factory = $this->createFactory($input);
$vault = $factory->createSodiumVault();
$localVault = $factory->createDotenvVault();
$io = new SymfonyStyle($input, $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output);
$vaultToUse = $input->getOption('local') ? $localVault : $vault;
if (null === $vaultToUse) {
$io->success('The local vault is disabled.');
return 1;
}
if (!$input->getOption('rotate')) {
if ($vaultToUse->generateKeys()) {
$io->success($vaultToUse->getLastMessage());
if ($vault === $vaultToUse) {
$io->caution('DO NOT COMMIT THE DECRYPTION KEY FOR THE PROD ENVIRONMENT⚠️');
}
return 0;
}
$io->warning($vaultToUse->getLastMessage());
return 1;
}
$secrets = [];
foreach ($vaultToUse->list(true) as $name => $value) {
if (null === $value) {
$io->error($vaultToUse->getLastMessage());
return 1;
}
$secrets[$name] = $value;
}
if (!$vaultToUse->generateKeys(true)) {
$io->warning($vaultToUse->getLastMessage());
return 1;
}
$io->success($vaultToUse->getLastMessage());
if ($secrets) {
foreach ($secrets as $name => $value) {
$vaultToUse->seal($name, $value);
}
$io->comment('Existing secrets have been rotated to the new keys.');
}
if ($vault === $vaultToUse) {
$io->caution('DO NOT COMMIT THE DECRYPTION KEY FOR THE PROD ENVIRONMENT⚠️');
}
return 0;
}
}
<?php
declare(strict_types=1);
namespace HHIT\ConfigGenerator\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;
class ListSecretsCommand extends AbstractCommand
{
protected static $defaultName = "list-secrets";
protected function configure()
{
parent::configure();
$this->addOption('reveal', 'r', InputOption::VALUE_NONE, 'Display decrypted values alongside names');
}
public function execute(InputInterface $input, OutputInterface $output)
{
$factory = $this->createFactory($input);
$vault = $factory->createSodiumVault();
$localVault = $factory->createDotenvVault();
$io = new SymfonyStyle($input, $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output);
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 = $vault->list($reveal);
$localSecrets = null !== $localVault ? $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 = $vault->getLastMessage()) {
$io->comment($message);
}
foreach ($localSecrets ?? [] as $name => $value) {
if (isset($rows[$name])) {
$rows[$name][] = $dump($value);
}
}
if (null !== $localVault && null !== $message = $localVault->getLastMessage()) {
$io->comment($message);
}
(new SymfonyStyle($input, $output))
->table(['Secret', 'Value'] + (null !== $localSecrets ? [2 => 'Local Value'] : []), $rows);
return 0;
}
}
<?php
declare(strict_types=1);
namespace HHIT\ConfigGenerator\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;
class RemoveSecretsCommand extends AbstractCommand
{
protected static $defaultName = 'remove-secrets';
protected function configure()
{
parent::configure();
$this->addArgument('name', InputArgument::REQUIRED, 'The name of the secret');
$this->addOption('local', 'l', InputOption::VALUE_NONE, 'Updates the local vault.');
}
public function execute(InputInterface $input, OutputInterface $output)
{
$factory = $this->createFactory($input);
$vault = $factory->createSodiumVault();
$localVault = $factory->createDotenvVault();
$io = new SymfonyStyle($input, $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output);
$vault = $input->getOption('local') ? $localVault : $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 ($vault === $vault && null !== $localVault->reveal($name)) {
$io->comment('Note that this secret is overridden in the local vault.');
}
return 0;
}
}
<?php
declare(strict_types=1);
namespace HHIT\ConfigGenerator\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
class SavePrivateKeyCommand extends AbstractCommand
{
protected static $defaultName = 'save-private-key';
protected function configure()
{
parent::configure();
$this->addOption('key', 'k', InputOption::VALUE_REQUIRED, 'private key');
}
protected function execute(InputInterface $input, OutputInterface $output)
{
$factory = $this->createFactory($input);
$key = $input->getOption('key');
if (!$key) {
throw new \RuntimeException('Key required!');
}
$factory->savePrivateKey($key);
$output->writeln('<info>private key saved</info>');
return 0;
}
}
<?php
declare(strict_types=1);
namespace HHIT\ConfigGenerator\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;
class SetSecretsCommand extends AbstractCommand
{
protected static $defaultName = "set-secrets";
protected function configure()
{
parent::configure();
$this->addArgument('name', InputArgument::REQUIRED, 'The name of the secret');
$this->addArgument('file', InputArgument::OPTIONAL, 'A file where to read the secret from or "-" for reading from STDIN');
$this->addOption('local', 'l', InputOption::VALUE_NONE, 'Updates the local vault.');
$this->addOption('random', 'r', InputOption::VALUE_OPTIONAL, 'Generates a random value.', false);
}
public function execute(InputInterface $input, OutputInterface $output)
{
$factory = $this->createFactory($input);
$vault = $factory->createSodiumVault();
$localVault = $factory->createDotenvVault();
$errOutput = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output;
$io = new SymfonyStyle($input, $errOutput);
$name = $input->getArgument('name');
$vaultToUse = $input->getOption('local') ? $localVault : $vault;
if (null === $vaultToUse) {
$io->error('The local vault is disabled.');
return 1;
}
if ($localVault === $vaultToUse && !\array_key_exists($name, $vault->list())) {
$io->error(sprintf('Secret "%s" does not exist in the vault, you cannot override it locally.', $name));
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');
if (null === $value) {
$io->warning('No value provided: using empty string');
$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 ($vaultToUse->generateKeys()) {
$io->success($vaultToUse->getLastMessage());
if ($vaultToUse === $vault) {
$io->caution('DO NOT COMMIT THE DECRYPTION KEY FOR THE PROD ENVIRONMENT⚠️');
}
}
$vaultToUse->seal($name, $value);
$io->success($vaultToUse->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 ($vaultToUse === $vault && null !== $localVault->reveal($name)) {
$io->comment('Note that this secret is overridden in the local vault.');
}
return 0;
}
}
......@@ -14,7 +14,6 @@ use Symfony\Component\Dotenv\Dotenv;
class Factory
{
private string $projectDir;
private string $env;
......@@ -29,7 +28,39 @@ class Factory
return new DefinitionReader($this->projectDir);
}
function createSodiumVault(): SodiumVault
public function dumpPrivateKey()
{
$file = $this->projectDir . '/config/secrets/' . $this->env . '/' . $this->env . '.decrypt.private.php';
if (!file_exists($file)) {
throw new \RuntimeException("Key file {$file} does not exist!");
}
$key = include($file);
return base64_encode(rawurldecode(str_replace('\x', '%', $key)));
}
public function savePrivateKey(string $key)
{
$file = $this->projectDir . '/config/secrets/' . $this->env . '/' . $this->env . '.decrypt.private.php';
$dirname = dirname($file);
if (file_exists($file)) {
throw new \RuntimeException("Key file {$file} already exists!");
}
if (!file_exists($dirname)) {
throw new \RuntimeException("Directory {$dirname} does not exist!");
}
$data = str_replace('%', '\x', rawurlencode(base64_decode($key)));
$data = sprintf("<?php // %s on %s\n\nreturn \"%s\";\n", basename($file), date('r'), $data);
if (false === file_put_contents($file, $data, \LOCK_EX)) {
$e = error_get_last();
throw new \ErrorException($e['message'] ?? 'Failed to write secrets data.', 0, $e['type'] ?? \E_USER_WARNING);
}
}
public function createSodiumVault(): SodiumVault
{
return $this->createSodiumVaultInternal($this->projectDir . '/config/secrets/' . $this->env);
}
......@@ -39,16 +70,21 @@ class Factory
return new SodiumVault($secretsDir, $decryptionKey);
}
private function createDotenvVault(string $dotenvFile): DotenvVault
public function createDotenvVault(): DotenvVault
{
return $this->createDotenvVaultInternal();
}
private function createDotenvVaultInternal(): DotenvVault
{
return new DotenvVault($dotenvFile);
return new DotenvVault($this->projectDir . '/.env.' . $this->env . '.local');
}
private function createSymfonyVaultSecretProvider(): SymfonyVaultSecretProvider
{
return new SymfonyVaultSecretProvider(
$this->createSodiumVault(),
$this->createDotenvVault($this->projectDir . '/.env')
$this->createDotenvVault()
);
}
......@@ -67,7 +103,7 @@ class Factory
return new ValidatorFactory();
}
function createGenerator(): Generator
public function createGenerator(): Generator
{
return new Generator(
$this->createDefinitionReader(),
......@@ -76,7 +112,7 @@ class Factory
);
}
function bootEnv()
public function bootEnv()
{
if (is_array($env = @include $this->projectDir . '/.env.local.php') && (!isset($env['APP_ENV']) || ($_SERVER['APP_ENV'] ?? $_ENV['APP_ENV'] ?? $env['APP_ENV']) === $env['APP_ENV'])) {
(new Dotenv(false))->populate($env);
......
<?php
use HHIT\ConfigGenerator\Command\DumpPrivateKeyCommand;
use HHIT\ConfigGenerator\Command\GenerateConfigsCommand;
use HHIT\ConfigGenerator\Command\GenerateKeysCommand;
use HHIT\ConfigGenerator\Command\ListSecretsCommand;
use HHIT\ConfigGenerator\Command\SavePrivateKeyCommand;
use HHIT\ConfigGenerator\Command\SetSecretsCommand;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Output\ConsoleOutput;
......@@ -16,6 +21,11 @@ unset($candidates, $autoloaderPathCandidate, $pojectDirCandidate);
$application = new Application('Configuration Generator');
$application->add(new GenerateConfigsCommand(getcwd()));
$application->add(new ListSecretsCommand(getcwd()));
$application->add(new SetSecretsCommand(getcwd()));
$application->add(new GenerateKeysCommand(getcwd()));
$application->add(new DumpPrivateKeyCommand(getcwd()));
$application->add(new SavePrivateKeyCommand(getcwd()));
$input = new ArgvInput();
if (null !== $env = $input->getParameterOption(['--env', '-e'], 'dev', true)) {
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment