diff --git a/Cargo.lock b/Cargo.lock index aa2d13117..70db0eb75 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2483,6 +2483,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + [[package]] name = "dirs-next" version = "2.0.0" @@ -2493,6 +2502,18 @@ dependencies = [ "dirs-sys-next", ] +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.60.2", +] + [[package]] name = "dirs-sys-next" version = "0.1.2" @@ -2500,7 +2521,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" dependencies = [ "libc", - "redox_users", + "redox_users 0.4.6", "winapi", ] @@ -4955,6 +4976,12 @@ dependencies = [ "thiserror 2.0.12", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "ordered-float" version = "2.10.1" @@ -5897,6 +5924,17 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 2.0.12", +] + [[package]] name = "ref-cast" version = "1.0.24" @@ -8302,6 +8340,22 @@ dependencies = [ "utoipa-swagger-ui", ] +[[package]] +name = "trustify-cli" +version = "0.3.5" +dependencies = [ + "anyhow", + "base64 0.22.1", + "clap", + "directories", + "log", + "regex", + "reqwest", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "trustify-common" version = "0.3.5" @@ -8894,6 +8948,7 @@ dependencies = [ "postgresql_embedded", "temp-env", "tokio", + "trustify-cli", "trustify-common", "trustify-db", "trustify-infrastructure", diff --git a/Cargo.toml b/Cargo.toml index 61303c8a6..20055a4d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "common/auth", "common/db", "common/infrastructure", + "cli", "cvss", "entity", "migration", @@ -172,6 +173,7 @@ trustify-query-derive = {path = "query/query-derive" } trustify-server = { path = "server", default-features = false } trustify-test-context = { path = "test-context" } trustify-ui = { git = "https://github.com/trustification/trustify-ui.git", branch = "publish/main" } +trustify-cli = { path = "cli" } # These dependencies are active during both the build time and the run time. So they are normal dependencies # as well as build-dependencies. However, we can't control feature flags for build dependencies the way we do diff --git a/cli/Cargo.toml b/cli/Cargo.toml new file mode 100644 index 000000000..8c172fa8c --- /dev/null +++ b/cli/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "trustify-cli" +version.workspace = true +edition.workspace = true +publish.workspace = true +license.workspace = true + +[dependencies] +anyhow = { workspace = true } +clap = { workspace = true, features = ["derive"] } +log = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +reqwest = { workspace = true, features = ["native-tls"] } +tokio = { workspace = true, features = ["full"] } +regex = { workspace = true } +base64 = { workspace = true } + +directories = "6.0.0" diff --git a/cli/src/auth.rs b/cli/src/auth.rs new file mode 100644 index 000000000..cefe2a61b --- /dev/null +++ b/cli/src/auth.rs @@ -0,0 +1,178 @@ +use anyhow::{Result, anyhow}; +use base64::{Engine, engine::general_purpose}; +use directories::ProjectDirs; +use regex::Regex; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use std::{fs, io::Write, path::PathBuf, process::ExitCode}; +use tokio::time::{Duration, sleep}; + +#[derive(clap::Parser, Debug, Clone, Eq, PartialEq)] +#[command()] +#[group(id = "login")] +pub struct Login { + #[arg(id = "oidc_client_id", long = "oidc-client-id", default_value = "cli")] + pub oidc_client_id: String, + + // If not set, we will try to discover it from trustify_server_url + #[arg(id = "oidc_server_url", long = "oidc-server-url")] + pub oidc_server_url: Option, + + // The Trustify root URL + #[arg( + id = "trustify_server_url", + long = "trustify-server-url", + default_value = "http://localhost:8080" + )] + pub trustify_server_url: String, +} + +#[derive(clap::Parser, Debug, Clone, Eq, PartialEq)] +#[command()] +#[group(id = "logout")] +pub struct Logout; + +#[derive(Debug, Clone, Eq, PartialEq)] +struct Auth { + config_dir: PathBuf, + config_file: PathBuf, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "UPPERCASE")] +struct TrustifyConfig { + oidc_server_url: String, +} + +#[derive(Deserialize, Debug)] +struct DeviceCodeResponse { + device_code: String, + user_code: String, + verification_uri: String, + expires_in: u64, + interval: u64, +} + +#[derive(Serialize, Deserialize, Debug)] +struct TokenResponse { + access_token: String, + refresh_token: Option, + expires_in: u64, +} + +impl Auth { + fn init_default() -> Result { + let proj_dirs = ProjectDirs::from("org", "guac", "trustify") + .ok_or(anyhow!("Could not determine config directory for this OS"))?; + let config_dir: PathBuf = proj_dirs.config_dir().to_path_buf(); + + // Ensure directory exists + fs::create_dir_all(&config_dir)?; + + // Define JSON file path + let config_file = config_dir.join("config.json"); + + Ok(Self { + config_dir, + config_file, + }) + } + + fn save_token_response(self, token_response: &TokenResponse) -> Result<()> { + // Serialize to JSON string + let json = serde_json::to_string_pretty(token_response)?; + + // Write JSON to fileProjectDirs + let mut file = fs::File::create(&self.config_file)?; + file.write_all(json.as_bytes())?; + + println!("Credentials saved to {:?}", &self.config_file); + Ok(()) + } +} + +impl Login { + pub async fn run(self) -> Result { + let client = Client::new(); + + // Prepare OIDC URLs + let oidc_server_url = if let Some(oidc_server_url) = self.oidc_server_url { + oidc_server_url + } else { + let index_page = client + .get(self.trustify_server_url) + .send() + .await? + .text() + .await?; + + let regex = Regex::new(r#"window\._env\s*=\s*"([^"]+)""#) + .map_err(|error| anyhow!(error.to_string()))?; + let env_base64 = regex + .captures(&index_page) + .ok_or(anyhow!("Could not match regex in index.html"))? + .get(1) + .ok_or(anyhow!("Could not extract _env value"))?; + + let decoded_bytes = general_purpose::STANDARD.decode(env_base64.as_str())?; + let decoded_str = String::from_utf8(decoded_bytes)?; + let trustify_config: TrustifyConfig = serde_json::from_str(&decoded_str)?; + trustify_config.oidc_server_url + }; + println!("oidc_server_url={oidc_server_url}"); + + // Get device code + let resp = client + .post(format!( + "{}/protocol/openid-connect/auth/device", + oidc_server_url + )) + .form(&[ + ("client_id", self.oidc_client_id.as_str()), + ("scope", "openid"), + ]) + .send() + .await? + .json::() + .await?; + + println!( + "Please visit {} and enter code: {}", + resp.verification_uri, resp.user_code + ); + println!("Code expires in {} seconds", resp.expires_in); + + // Poll token endpoint + loop { + sleep(Duration::from_secs(resp.interval)).await; + + let token_resp = client + .post(format!("{}/protocol/openid-connect/token", oidc_server_url)) + .form(&[ + ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), + ("device_code", resp.device_code.as_str()), + ("client_id", self.oidc_client_id.as_str()), + ]) + .send() + .await?; + + if token_resp.status().is_success() { + let token_response = token_resp.json::().await?; + + match Auth::init_default() { + Ok(auth) => { + auth.save_token_response(&token_response)?; + break Ok(ExitCode::SUCCESS); + } + Err(_) => break Ok(ExitCode::FAILURE), + } + } + } + } +} + +impl Logout { + pub async fn run(self) -> Result { + Ok(ExitCode::SUCCESS) + } +} diff --git a/cli/src/lib.rs b/cli/src/lib.rs new file mode 100644 index 000000000..903424aab --- /dev/null +++ b/cli/src/lib.rs @@ -0,0 +1,33 @@ +use anyhow::Result; +use std::process::ExitCode; + +use crate::{ + auth::{Login, Logout}, + scan::Scan, +}; + +mod auth; +mod scan; + +#[derive(clap::Args, Debug)] +pub struct Run { + #[command(subcommand)] + pub(crate) command: Command, +} + +#[derive(clap::Subcommand, Debug)] +pub enum Command { + Login(Login), + Logout(Logout), + Scan(Scan), +} + +impl Run { + pub async fn run(self) -> Result { + match self.command { + Command::Login(login) => login.run().await, + Command::Logout(logout) => logout.run().await, + Command::Scan(scan) => scan.run().await, + } + } +} diff --git a/cli/src/scan.rs b/cli/src/scan.rs new file mode 100644 index 000000000..f5fc194c8 --- /dev/null +++ b/cli/src/scan.rs @@ -0,0 +1,19 @@ +use std::{path::PathBuf, process::ExitCode}; + +use anyhow::Result; + +#[derive(clap::Parser, Debug, Clone, Eq, PartialEq)] +#[command()] +#[group(id = "scan")] +pub struct Scan { + #[arg(id = "input", long = "input")] + pub input: PathBuf, + #[arg(id = "output", long = "output")] + pub output: Option, +} + +impl Scan { + pub async fn run(self) -> Result { + Ok(ExitCode::SUCCESS) + } +} diff --git a/trustd/Cargo.toml b/trustd/Cargo.toml index 44622fc06..07500441b 100644 --- a/trustd/Cargo.toml +++ b/trustd/Cargo.toml @@ -14,6 +14,7 @@ trustify-common = { workspace = true } trustify-db = { workspace = true } trustify-infrastructure = { workspace = true } trustify-server = { workspace = true } +trustify-cli = { workspace = true } anyhow = { workspace = true } clap = { workspace = true, features = ["derive", "env"] } diff --git a/trustd/src/main.rs b/trustd/src/main.rs index 4a83b5510..f947d7fd7 100644 --- a/trustd/src/main.rs +++ b/trustd/src/main.rs @@ -24,6 +24,8 @@ pub enum Command { Db(db::Run), /// Access OpenAPI related information of the API server Openapi(openapi::Run), + /// CLI terminal tool + Cli(trustify_cli::Run), } #[derive(clap::Parser, Debug)] @@ -45,6 +47,7 @@ impl Trustd { Some(Command::Importer(run)) => run.run().await, Some(Command::Db(run)) => run.run().await, Some(Command::Openapi(run)) => run.run().await, + Some(Command::Cli(run)) => run.run().await, None => pm_mode().await, } }