diff --git a/.gitignore b/.gitignore index 18a01a37c..4a331c1de 100644 --- a/.gitignore +++ b/.gitignore @@ -72,6 +72,7 @@ js/node/ js/node_modules/ node_modules .npm +/cli/Cargo.lock # Cypress cypress/videos diff --git a/cli/Cargo.toml b/cli/Cargo.toml new file mode 100644 index 000000000..4e3ab5a30 --- /dev/null +++ b/cli/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "challenge-cli" +version = "0.1.0" +edition = "2021" + +[dependencies] +clap = { version = "4.2.5", features = ["derive"] } +walkdir = "2" +handlebars = "3" diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 000000000..f15c93ba3 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,50 @@ +# CLI for WrongSecrets + +## Introduction + +At the moment the CLI only serves one purpose: creating a new challenge. In the future more options can be added. + +## Usage + +```shell +./challenge-cli +``` + +will print: + +```shell +A CLI for WrongSecrets + +Usage: challenge-cli + +Commands: + challenge Create a new challenge + help Print this message or the help of the given subcommand(s) + +Options: + -h, --help Print help +``` + +## Building + +First install [Rust](https://www.rust-lang.org/tools/install). Then open a terminal and type: + +```shell +cd cli +cargo build +target/debug/challenge-cli +``` + +## Running in IntelliJ + +On `main.rs` right click and select `Run 'main'`. This will run the CLI in the terminal window of IntelliJ. +When passing command line arguments you need to add them to the run configuration. In IntelliJ go to `Run` -> `Edit Configurations...` and add the arguments to the `Command` field. You need to add `--` before the arguments. For example: + +```shell +run --package challenge-cli --bin challenge-cli -- challenge -d easy -t git ../ +``` + +## Todo + +- Fix templating (not everything is present yet) +- Add GitHub actions to build binary for the different platforms diff --git a/cli/src/challenge.rs b/cli/src/challenge.rs new file mode 100644 index 000000000..b9656230f --- /dev/null +++ b/cli/src/challenge.rs @@ -0,0 +1,110 @@ +use std::collections::BTreeMap; +use std::fs::File; +use std::io::Write; +use std::path::PathBuf; + +use handlebars::Handlebars; +use walkdir::WalkDir; + +use crate::{Difficulty, Platform, Technology}; + +#[derive(Debug)] +pub struct Challenge { + pub number: u8, + pub technology: Technology, + pub difficulty: Difficulty, + pub platform: Platform, +} + +impl Challenge { + fn create_java_sources(&self, project_directory: &PathBuf) { + let challenge_source_path = + project_directory + .join("src/main/java/org/owasp/wrongsecrets/challenges"); + + let (handlebars, data) = self.init_handlebars(project_directory); + let challenge_source_content = handlebars + .render("challenge", &data) + .expect("Unable to render challenge template"); + let mut class_file = File::create( + challenge_source_path + .join(self.platform.to_string()) + .join(format!("Challenge{}.java", &self.number.to_string())) + ) + .expect("Unable to create challenge source file"); + class_file + .write(challenge_source_content.as_bytes()) + .expect("Unable to write challenge source file"); + } + + fn init_handlebars(&self, project_directory: &PathBuf) -> (Handlebars, BTreeMap) { + const CHALLENGE_TEMPLATE: &str = "src/main/resources/challenge.hbs"; + let mut handlebars = Handlebars::new(); + handlebars + .register_template_file( + "challenge", + project_directory.join(CHALLENGE_TEMPLATE), + ) + .unwrap(); + let mut data: BTreeMap = BTreeMap::new(); + data.insert("challenge_number".to_string(), self.number.to_string()); + data.insert("platform".to_string(), self.platform.to_string().to_lowercase()); + data.insert("difficulty".to_string(), self.difficulty.to_string().to_uppercase()); + data.insert("technology".to_string(), self.technology.to_string().to_uppercase()); + + (handlebars, data) + } + + fn create_documentation(&self, project_directory: &PathBuf) { + let challenge_documentation_path = + project_directory + .join("src/main/resources/explanations/"); + create_documentation_file( + challenge_documentation_path.join(format!("challenge{}.adoc", self.number.to_string())), + ); + create_documentation_file( + challenge_documentation_path.join(format!("challenge{}_hint.adoc", self.number.to_string())), + ); + create_documentation_file( + challenge_documentation_path + .join(format!("challenge{}_explanation.adoc", self.number.to_string())), + ); + } +} + +fn create_documentation_file(filename: PathBuf) { + File::create(filename).expect("Unable to create challenge documentation file"); +} + +pub fn create_challenge(challenge: &Challenge, project_directory: &PathBuf) { + let challenge_exists = check_challenge_exists(challenge, &project_directory); + + if challenge_exists { + panic!("{:?} already exists", &challenge); + } + + println!( + "Creating {:?}", + &challenge + ); + challenge.create_java_sources(project_directory); + challenge.create_documentation(project_directory); +} + + +//File API has `create_new` but it is still experimental in the nightly build, let loop and check if it exists for now +fn check_challenge_exists(challenge: &Challenge, project_directory: &PathBuf) -> bool { + let challenges_directory = + project_directory + .join("src/main/java/org/owasp/wrongsecrets/challenges"); + let challenge_name = String::from("Challenge") + &challenge.number.to_string() + ".java"; + + let challenge_exists = WalkDir::new(challenges_directory) + .into_iter() + .filter_map(|e| e.ok()) + .any(|e| match e.file_name().to_str() { + None => false, + Some(name) => name.eq(challenge_name.as_str()), + }); + challenge_exists +} diff --git a/cli/src/enums.rs b/cli/src/enums.rs new file mode 100644 index 000000000..7256cca09 --- /dev/null +++ b/cli/src/enums.rs @@ -0,0 +1,62 @@ +// Later on we can read this from the Github repository to make it more flexible +// cache the values locally and add a flag `--force` to force reading the values again +// Other option is to include a text file attached in a zip file. This makes it a bit more +// error prone as we need to have that file in the same directory. +// Other option is to have these files as part of the source code of wrongsecrets as you need +// to pass the project folder anyway. Otherwise generating a new challenge makes no sense ;-) + +use std::fmt; + +#[derive(clap::ValueEnum, Clone, Debug)] +pub enum Technology { + Git, + Docker, + ConfigMaps, + Secrets, + Vault, + Logging, + Terraform, + CSI, + CICD, + PasswordManager, + Cryptography, + Binary, + Frontend, + IAM, + Web3, + Documentation, +} + +#[derive(clap::ValueEnum, Copy, Clone, Debug, PartialEq, Eq)] +pub enum Difficulty { + Easy, + Normal, + Hard, + Expert, + Master, +} + +#[derive(clap::ValueEnum, Copy, Clone, Debug, PartialEq, Eq)] +pub enum Platform { + Cloud, + Docker, + Kubernetes, +} + +impl fmt::Display for Difficulty { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{:?}", self) + } +} + +impl fmt::Display for Technology { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{:?}", self) + } +} + +impl fmt::Display for Platform { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{:?}", self) + } +} diff --git a/cli/src/main.rs b/cli/src/main.rs new file mode 100644 index 000000000..99058bfa5 --- /dev/null +++ b/cli/src/main.rs @@ -0,0 +1,86 @@ +use std::path::PathBuf; + +use clap::{Parser, Subcommand}; +use clap::arg; + +use crate::challenge::Challenge; +use crate::enums::{Difficulty, Platform, Technology}; + +mod challenge; +mod enums; + +#[derive(Debug, Parser)] +#[command(name = "cli")] +#[command(about = "A CLI for WrongSecrets", long_about = None)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Debug, Subcommand)] +enum Commands { + #[command( + arg_required_else_help = true, + name = "challenge", + about = "Create a new challenge" + )] + ChallengeCommand { + //We could infer this from the directory structure but another PR could already have added the challenge with this number + #[arg(long, short, value_name = "NUMBER")] + number: u8, + #[arg( + long, + short, + value_name = "DIFFICULTY", + num_args = 0..=1, + default_value_t = Difficulty::Easy, + default_missing_value = "easy", + value_enum + )] + difficulty: Difficulty, + #[arg( + long, + short, + value_name = "TECHNOLOGY", + num_args = 0..=1, + default_value_t = Technology::Git, + default_missing_value = "git", + value_enum + )] + technology: Technology, + #[arg( + long, + short, + value_name = "PLATFORM", + num_args = 0..=1, + value_enum + )] + platform: Platform, + #[arg(required = true)] + project_directory: PathBuf, + }, +} + +fn main() { + let args = Cli::parse(); + match args.command { + Commands::ChallengeCommand { + number, + difficulty, + technology, + platform, + project_directory, + } => { + project_directory + .try_exists() + .expect("Unable to find project directory"); + let challenge = Challenge { + number, + difficulty, + technology, + platform, + }; + challenge::create_challenge(&challenge, &project_directory); + } + } +} diff --git a/src/main/resources/challenge.hbs b/src/main/resources/challenge.hbs new file mode 100644 index 000000000..28772ef6a --- /dev/null +++ b/src/main/resources/challenge.hbs @@ -0,0 +1,76 @@ +package org.owasp.wrongsecrets.challenges.{{platform}}; + +import org.owasp.wrongsecrets.RuntimeEnvironment; +import org.owasp.wrongsecrets.ScoreCard; +import org.owasp.wrongsecrets.challenges.Challenge; +import org.owasp.wrongsecrets.challenges.ChallengeTechnology; +import org.owasp.wrongsecrets.challenges.Difficulty; +import org.owasp.wrongsecrets.challenges.Spoiler; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import java.util.List; + +{{ +extra_imports +}} + +@Component +@Order(0) +public class Challenge{{challenge_number}} extends Challenge { + + public Challenge{{challenge_number}}(ScoreCard scoreCard) { + super(scoreCard); + } + + /** + * {@inheritDoc} + */ + @Override + public Spoiler spoiler() { + return new Spoiler(getData()); + } + + /** + * {@inheritDoc} + */ + @Override + protected boolean answerCorrect(String answer) { + return getData().equals(answer); + } + + @Override + /** + * {@inheritDoc} + */ + public List supportedRuntimeEnvironments() { + return List.of(RuntimeEnvironment.Environment.{{environment}}); + } + + /** + * {@inheritDoc} + */ + @Override + public int difficulty() { + return Difficulty.{{difficulty}}; + } + + @Override + public String getTech() { + return ChallengeTechnology.Tech.{{technology}}.id; + } + + @Override + public boolean isLimitedWhenOnlineHosted() { + return false; + } + + @Override + public boolean canRunInCTFMode() { + return true; + } + + private String getData() { + return "<>"; + } +}