Fix logstash-keystore multiple keys operations with command flags (#15737) (#15739)

This commit fixes how the keystore tool handle the command's options, including validation for unknown options, and adding the --stdin flag to the add command.

(cherry picked from commit 41ec183f09)

Co-authored-by: Edmo Vamerlatti Costa <11836452+edmocosta@users.noreply.github.com>
This commit is contained in:
github-actions[bot] 2024-01-03 18:36:05 +00:00 committed by GitHub
parent e9213e952e
commit 25a381b253
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 246 additions and 44 deletions

View file

@ -29,7 +29,6 @@ import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.stream.Collectors;
import static java.util.Objects.nonNull;
import static org.logstash.secret.store.SecretStoreFactory.LOGSTASH_MARKER;
/**
@ -42,18 +41,31 @@ public class SecretStoreCli {
private final Terminal terminal;
private final SecretStoreFactory secretStoreFactory;
enum Command {
CREATE("create"), LIST("list"), ADD("add"), REMOVE("remove"), HELP("--help");
enum CommandOptions {
HELP("help"),
/**
* Common flag used to change the read source to the standard input. It has no effect as
* this tool already reads values from the stdin by default, but still necessary (BC) to
* not consider this flag as an actual value.
*/
STDIN("stdin");
private static final String PREFIX = "--";
private final String option;
Command(String option) {
this.option = option;
CommandOptions(final String option) {
this.option = String.format("%s%s", PREFIX, option);
}
static Optional<Command> fromString(final String input) {
Optional<Command> command = EnumSet.allOf(Command.class).stream().filter(c -> c.option.equals(input)).findFirst();
return command;
static Optional<CommandOptions> fromString(final String option) {
if (option == null || !option.startsWith(PREFIX)) {
return Optional.empty();
}
return Arrays.stream(values())
.filter(p -> p.option.equals(option))
.findFirst();
}
String getOption() {
@ -61,6 +73,111 @@ public class SecretStoreCli {
}
}
static class CommandLine {
private final Command command;
private final List<String> arguments;
private final Set<CommandOptions> options;
CommandLine(final Command command, final String[] arguments) {
this.command = command;
this.arguments = parseCommandArguments(command, arguments);
this.options = parseCommandOptions(command, arguments);
}
private static List<String> parseCommandArguments(final Command command, final String[] arguments) {
if (arguments == null || arguments.length == 0) {
return List.of();
}
final Set<String> commandValidOptions = command.getValidOptions()
.stream()
.map(CommandOptions::getOption)
.collect(Collectors.toSet());
// Includes all arguments values until it reaches the end or found a command option/flag
String[] filteredArguments = arguments;
for (int i = 0; i < arguments.length; i++) {
if (commandValidOptions.contains(arguments[i])) {
filteredArguments = Arrays.copyOfRange(arguments, 0, i);
break;
}
}
return Arrays.asList(filteredArguments);
}
private static Set<CommandOptions> parseCommandOptions(final Command command, final String[] arguments) {
final Set<CommandOptions> providedOptions = EnumSet.noneOf(CommandOptions.class);
if (arguments == null) {
return providedOptions;
}
for (final String argument : arguments) {
if (argument.startsWith(CommandOptions.PREFIX)) {
final Optional<CommandOptions> option = CommandOptions.fromString(argument);
if (option.isEmpty() || !command.validOptions.contains(option.get())) {
throw new InvalidCommandException(String.format("Unrecognized option '%s' for command '%s'", argument, command.command));
}
providedOptions.add(option.get());
}
}
return providedOptions;
}
boolean hasOption(final CommandOptions option) {
return options.contains(option);
}
Command getCommand() {
return command;
}
List<String> getArguments() {
return Collections.unmodifiableList(arguments);
}
}
static class InvalidCommandException extends RuntimeException {
private static final long serialVersionUID = 8402825920204022022L;
public InvalidCommandException(final String message) {
super(message);
}
}
enum Command {
CREATE("create"),
LIST("list"),
ADD("add", List.of(CommandOptions.STDIN)),
REMOVE("remove");
private final Set<CommandOptions> validOptions = EnumSet.of(CommandOptions.HELP);
private final String command;
Command(final String command) {
this.command = command;
}
Command(final String command, final Collection<CommandOptions> validOptions) {
this.command = command;
this.validOptions.addAll(validOptions);
}
static Optional<CommandLine> parse(final String command, final String[] arguments) {
final Optional<Command> foundCommand = Arrays.stream(values())
.filter(c -> c.command.equals(command))
.findFirst();
return foundCommand.map(value -> new CommandLine(value, arguments));
}
Set<CommandOptions> getValidOptions() {
return Collections.unmodifiableSet(validOptions);
}
}
public SecretStoreCli(Terminal terminal){
this(terminal, SecretStoreFactory.fromEnvironment());
}
@ -74,16 +191,28 @@ public class SecretStoreCli {
* Entry point to issue a command line command.
* @param primaryCommand The string representation of a {@link SecretStoreCli.Command}, if the String does not map to a {@link SecretStoreCli.Command}, then it will show the help menu.
* @param config The configuration needed to work a secret store. May be null for help.
* @param arguments This can be either identifiers for a secret, or a sub command like --help. May be null.
* @param allArguments This can be either identifiers for a secret, or a sub command like --help. May be null.
*/
public void command(String primaryCommand, SecureConfig config, String... arguments) {
public void command(String primaryCommand, SecureConfig config, String... allArguments) {
terminal.writeLine("");
final Command command = Command.fromString(primaryCommand).orElse(Command.HELP);
boolean help = nonNull(arguments) && Arrays.asList(arguments).contains(Command.HELP.getOption());
switch (command) {
final Optional<CommandLine> commandParseResult;
try {
commandParseResult = Command.parse(primaryCommand, allArguments);
} catch (InvalidCommandException e) {
terminal.writeLine(String.format("ERROR: %s", e.getMessage()));
return;
}
if (commandParseResult.isEmpty()) {
printHelp();
return;
}
final CommandLine commandLine = commandParseResult.get();
switch (commandLine.getCommand()) {
case CREATE: {
if (help){
if (commandLine.hasOption(CommandOptions.HELP)){
terminal.writeLine("Creates a new keystore. For example: 'bin/logstash-keystore create'");
return;
}
@ -98,7 +227,7 @@ public class SecretStoreCli {
break;
}
case LIST: {
if (help){
if (commandLine.hasOption(CommandOptions.HELP)){
terminal.writeLine("List all secret identifiers from the keystore. For example: " +
"`bin/logstash-keystore list`. Note - only the identifiers will be listed, not the secrets.");
return;
@ -110,18 +239,18 @@ public class SecretStoreCli {
break;
}
case ADD: {
if (help){
if (commandLine.hasOption(CommandOptions.HELP)){
terminal.writeLine("Add secrets to the keystore. For example: " +
"`bin/logstash-keystore add my-secret`, at the prompt enter your secret. You will use the identifier ${my-secret} in your Logstash configuration.");
return;
}
if (arguments == null || arguments.length == 0) {
if (commandLine.getArguments().isEmpty()) {
terminal.writeLine("ERROR: You must supply an identifier to add. (e.g. bin/logstash-keystore add my-secret)");
return;
}
if (secretStoreFactory.exists(config.clone())) {
final SecretStore secretStore = secretStoreFactory.load(config);
for (String argument : arguments) {
for (String argument : commandLine.getArguments()) {
final SecretIdentifier id = new SecretIdentifier(argument);
final byte[] existingValue = secretStore.retrieveSecret(id);
if (existingValue != null) {
@ -159,18 +288,18 @@ public class SecretStoreCli {
break;
}
case REMOVE: {
if (help){
if (commandLine.hasOption(CommandOptions.HELP)){
terminal.writeLine("Remove secrets from the keystore. For example: " +
"`bin/logstash-keystore remove my-secret`");
return;
}
if (arguments == null || arguments.length == 0) {
if (commandLine.getArguments().isEmpty()) {
terminal.writeLine("ERROR: You must supply a value to remove. (e.g. bin/logstash-keystore remove my-secret)");
return;
}
final SecretStore secretStore = secretStoreFactory.load(config);
for (String argument : arguments) {
for (String argument : commandLine.getArguments()) {
SecretIdentifier id = new SecretIdentifier(argument);
if (secretStore.containsSecret(id)) {
secretStore.purgeSecret(id);
@ -182,32 +311,32 @@ public class SecretStoreCli {
break;
}
case HELP: {
terminal.writeLine("Usage:");
terminal.writeLine("--------");
terminal.writeLine("bin/logstash-keystore [option] command [argument]");
terminal.writeLine("");
terminal.writeLine("Commands:");
terminal.writeLine("--------");
terminal.writeLine("create - Creates a new Logstash keystore (e.g. bin/logstash-keystore create)");
terminal.writeLine("list - List entries in the keystore (e.g. bin/logstash-keystore list)");
terminal.writeLine("add - Add values to the keystore (e.g. bin/logstash-keystore add secret-one secret-two)");
terminal.writeLine("remove - Remove values from the keystore (e.g. bin/logstash-keystore remove secret-one secret-two)");
terminal.writeLine("");
terminal.writeLine("Argument:");
terminal.writeLine("--------");
terminal.writeLine("--help - Display command specific help (e.g. bin/logstash-keystore add --help)");
terminal.writeLine("");
terminal.writeLine("Options:");
terminal.writeLine("--------");
terminal.writeLine("--path.settings - Set the directory for the keystore. This is should be the same directory as the logstash.yml settings file. " +
"The default is the config directory under Logstash home. (e.g. bin/logstash-keystore --path.settings /tmp/foo create)");
terminal.writeLine("");
break;
}
}
}
private void printHelp(){
terminal.writeLine("Usage:");
terminal.writeLine("--------");
terminal.writeLine("bin/logstash-keystore [option] command [argument]");
terminal.writeLine("");
terminal.writeLine("Commands:");
terminal.writeLine("--------");
terminal.writeLine("create - Creates a new Logstash keystore (e.g. bin/logstash-keystore create)");
terminal.writeLine("list - List entries in the keystore (e.g. bin/logstash-keystore list)");
terminal.writeLine("add - Add values to the keystore (e.g. bin/logstash-keystore add secret-one secret-two)");
terminal.writeLine("remove - Remove values from the keystore (e.g. bin/logstash-keystore remove secret-one secret-two)");
terminal.writeLine("");
terminal.writeLine("Argument:");
terminal.writeLine("--------");
terminal.writeLine("--help - Display command specific help (e.g. bin/logstash-keystore add --help)");
terminal.writeLine("");
terminal.writeLine("Options:");
terminal.writeLine("--------");
terminal.writeLine("--path.settings - Set the directory for the keystore. This is should be the same directory as the logstash.yml settings file. " +
"The default is the config directory under Logstash home. (e.g. bin/logstash-keystore --path.settings /tmp/foo create)");
terminal.writeLine("");
}
private void add(SecretStore secretStore, SecretIdentifier id, byte[] secret) {
secretStore.persistSecret(id, secret);
terminal.writeLine(String.format("Added '%s' to the Logstash keystore.", id.getKey()));

View file

@ -33,6 +33,7 @@ import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.Map;
import java.util.Optional;
import java.util.Queue;
import java.util.UUID;
@ -214,6 +215,22 @@ public class SecretStoreCliTest {
assertThat(terminal.out).containsIgnoringCase("ERROR: Logstash keystore not found. Use 'create' command to create one.");
}
@Test
public void testAddWithStdinOption() {
createKeyStore();
terminal.in.add(UUID.randomUUID().toString()); // sets the value
terminal.in.add(UUID.randomUUID().toString()); // sets the value
String id = UUID.randomUUID().toString();
cli.command("add", newStoreConfig.clone(), id, SecretStoreCli.CommandOptions.STDIN.getOption());
terminal.reset();
cli.command("list", newStoreConfig);
assertListed(id);
assertNotListed(SecretStoreCli.CommandOptions.STDIN.getOption());
}
@Test
public void testRemove() {
createKeyStore();
@ -294,6 +311,58 @@ public class SecretStoreCliTest {
assertThat(terminal.out).containsIgnoringCase(expectedMessage);
}
@Test
public void testCommandWithUnrecognizedOption() {
createKeyStore();
terminal.in.add("foo");
final String invalidOption = "--invalid-option";
cli.command("add", newStoreConfig.clone(), UUID.randomUUID().toString(), invalidOption);
assertThat(terminal.out).contains(String.format("Unrecognized option '%s' for command 'add'", invalidOption));
terminal.reset();
cli.command("list", newStoreConfig);
assertNotListed(invalidOption);
}
@Test
public void testCommandParseWithValidCommand() {
final String[] args = new String[]{
"FOO",
"BAR",
"--stdin",
"ANYTHING"
};
final Optional<SecretStoreCli.CommandLine> commandLineParseResult = SecretStoreCli.Command
.parse("add", args);
assertThat(commandLineParseResult).isPresent();
final SecretStoreCli.CommandLine commandLine = commandLineParseResult.get();
assertThat(commandLine.getCommand()).isEqualTo(SecretStoreCli.Command.ADD);
assertThat(commandLine.getArguments()).containsExactly("FOO", "BAR");
assertThat(commandLine.hasOption(SecretStoreCli.CommandOptions.STDIN)).isTrue();
}
@Test
public void testCommandParseWithInvalidCommand() {
final Optional<SecretStoreCli.CommandLine> commandLineParseResult = SecretStoreCli.Command
.parse("non-existing-command", new String[0]);
assertThat(commandLineParseResult).isEmpty();
}
@Test
public void tesCommandsAllowHelpOption() {
for (final SecretStoreCli.Command value : SecretStoreCli.Command.values()) {
assertThat(value.getValidOptions())
.withFailMessage("Command '%s' must support the '--help' option", value.name())
.contains(SecretStoreCli.CommandOptions.HELP);
}
}
private void createKeyStore() {
terminal.reset();
terminal.in.add("y");
@ -314,6 +383,10 @@ public class SecretStoreCliTest {
assertTrue(Arrays.stream(expected).allMatch(terminal.out::contains));
}
private void assertNotListed(String... expected) {
assertTrue(Arrays.stream(expected).noneMatch(terminal.out::contains));
}
private void assertPrimaryHelped() {
assertThat(terminal.out).
containsIgnoringCase("Commands").