diff --git a/.gitignore b/.gitignore index 1da713f6d3..8bae542958 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ target *.changes *.sig *.bundle +*.csv .homebrew tests/server_public_key.txt geiger-report.txt diff --git a/Cargo.lock b/Cargo.lock index 9717004679..465a20db18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5524,6 +5524,7 @@ dependencies = [ "async-recursion", "axum-server", "clap", + "csv-async", "futures", "http", "human_bytes", diff --git a/Cargo.toml b/Cargo.toml index 765a00ffa4..d3d98e385d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,11 @@ mem-fs = ["sos-net/mem-fs"] keyring = ["sos-net/keyring"] test-utils = ["sos-net/test-utils"] +[workspace.dependencies] +csv-async = { version = "1", features = ["tokio", "with_serde"] } + [dependencies] +csv-async.workspace = true thiserror = "1" tracing = "0.1" tracing-subscriber = { version = "0.3.16", features = ["env-filter"] } diff --git a/src/cli/sos.rs b/src/cli/sos.rs index ba10931c9f..112da36adf 100644 --- a/src/cli/sos.rs +++ b/src/cli/sos.rs @@ -10,7 +10,7 @@ use std::path::PathBuf; use crate::{ commands::{ account, audit, changes, check, device, folder, generate_keypair, - security_report, + security_report::{self, SecurityReportFormat}, secret, shell, AccountCommand, AuditCommand, CheckCommand, DeviceCommand, FolderCommand, SecretCommand, }, @@ -86,9 +86,20 @@ pub enum Command { }, /// Generate a security report. SecurityReport { + /// Force overwrite if the file exists. + #[clap(short, long)] + force: bool, + /// Account name or address. #[clap(short, long)] account: Option, + + /// Output format: csv or json. + #[clap(short, long, default_value = "csv")] + output_format: SecurityReportFormat, + + /// Write report to this file. + file: PathBuf, }, /// Create, edit and delete secrets. Secret { @@ -155,7 +166,10 @@ pub async fn run() -> Result<()> { } => generate_keypair::run(file, force, public_key).await?, Command::SecurityReport { account, - } => security_report::run(account, Default::default(), factory).await?, + force, + output_format, + file, + } => security_report::run(account, force, output_format, file, factory).await?, Command::Secret { cmd } => secret::run(cmd, factory).await?, Command::Audit { cmd } => audit::run(cmd).await?, Command::Changes { diff --git a/src/commands/security_report.rs b/src/commands/security_report.rs index 6034406a18..7cdd1bb3c6 100644 --- a/src/commands/security_report.rs +++ b/src/commands/security_report.rs @@ -1,5 +1,6 @@ +use std::{fmt, str::FromStr, path::PathBuf}; use sos_net::{ - client::{provider::ProviderFactory, user::SecurityReportOptions, hashcheck}, + client::{provider::ProviderFactory, user::{SecurityReportOptions, SecurityReportRow}, hashcheck}, sdk::account::AccountRef, }; @@ -7,21 +8,51 @@ use crate::{ helpers::{ account::resolve_user, }, - Result, + Error, Result, }; /// Formats for writing reports. -#[derive(Default)] +#[derive(Default, Debug, Clone)] pub enum SecurityReportFormat { + /// CSV output format. #[default] + Csv, + /// JSON output format. Json, } +impl fmt::Display for SecurityReportFormat { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", match self { + Self::Csv => "csv", + Self::Json => "json", + }) + } +} + +impl FromStr for SecurityReportFormat { + type Err = Error; + + fn from_str(s: &str) -> Result { + match s { + "csv" => Ok(Self::Csv), + "json" => Ok(Self::Json), + _ => Err(Error::UnknownReportFormat(s.to_owned())), + } + } +} + pub async fn run( account: Option, + force: bool, format: SecurityReportFormat, + path: PathBuf, factory: ProviderFactory, ) -> Result<()> { + if tokio::fs::try_exists(&path).await? && !force { + return Err(Error::FileExistsUseForce(path)); + } + let user = resolve_user(account.as_ref(), factory, false).await?; let mut owner = user.write().await; @@ -42,14 +73,25 @@ pub async fn run( report_options, ) .await?; - - let contents = match format { + + let rows: Vec> = report.into(); + + match format { + SecurityReportFormat::Csv => { + let mut out = csv_async::AsyncSerializer::from_writer( + tokio::fs::File::create(&path).await? + ); + for row in rows { + out.serialize(&row).await?; + } + } SecurityReportFormat::Json => { - serde_json::to_string_pretty(&report)? + let mut out = std::fs::File::create(&path)?; + serde_json::to_writer_pretty(&mut out, &rows)?; } - }; + } - println!("{}", contents); + tracing::info!(path = ?path, "wrote security report"); Ok(()) } diff --git a/src/error.rs b/src/error.rs index 80fac76d63..6077719f8a 100644 --- a/src/error.rs +++ b/src/error.rs @@ -109,6 +109,9 @@ pub enum Error { #[error("file {0} already exists, use --force to overwrite")] FileExistsUseForce(PathBuf), + #[error("unknown report format '{0}'")] + UnknownReportFormat(String), + /// Error generated converting to fixed length slice. #[error(transparent)] TryFromSlice(#[from] std::array::TryFromSliceError), @@ -165,6 +168,9 @@ pub enum Error { #[error(transparent)] Hex(#[from] sos_net::sdk::hex::FromHexError), + + #[error(transparent)] + Csv(#[from] csv_async::Error), } impl Error { diff --git a/workspace/migrate/Cargo.toml b/workspace/migrate/Cargo.toml index 4cc6ca42b6..62ca6b5678 100644 --- a/workspace/migrate/Cargo.toml +++ b/workspace/migrate/Cargo.toml @@ -8,6 +8,7 @@ license = "GPL-3.0" repository = "https://github.com/saveoursecrets/sdk" [dependencies] +csv-async.workspace = true thiserror = "1" secrecy = { version = "0.8", features = ["serde"] } serde = { version = "1", features = ["derive"] } @@ -24,7 +25,6 @@ async_zip = { version = "0.0.15", default-features = false, features = ["deflate tokio-util = { version = "0.7", default-features = false, features = ["compat"] } futures-util = "0.3" futures = "0.3" -csv-async = { version = "1", features = ["tokio", "with_serde"] } [dependencies.sos-sdk] version = "0.5.19" diff --git a/workspace/net/src/client/user/mod.rs b/workspace/net/src/client/user/mod.rs index 661e91988f..65032253e6 100644 --- a/workspace/net/src/client/user/mod.rs +++ b/workspace/net/src/client/user/mod.rs @@ -24,7 +24,7 @@ pub use search_index::{ArchiveFilter, DocumentView, QueryFilter, UserIndex}; #[cfg(feature = "security-report")] pub use security_report::{ PasswordReport, SecurityReport, SecurityReportOptions, - SecurityReportRecord, + SecurityReportRecord, SecurityReportRow, }; pub use user_storage::{ AccountData, SecretOptions, UserStatistics, UserStorage, diff --git a/workspace/net/src/client/user/security_report.rs b/workspace/net/src/client/user/security_report.rs index 6f0ae95636..2ca76e9871 100644 --- a/workspace/net/src/client/user/security_report.rs +++ b/workspace/net/src/client/user/security_report.rs @@ -27,6 +27,8 @@ where #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SecurityReportRow { + /// Folder name. + pub folder_name: String, /// Folder identifier. pub folder_id: VaultId, /// Secret identifier. @@ -35,10 +37,16 @@ pub struct SecurityReportRow { pub owner_id: Option, /// Field index (when custom field). pub field_index: Option, - /// Password security report. - #[serde(flatten)] - pub report: PasswordReport, + /// The entropy score. + pub score: u8, + /// The estimated number of guesses needed to crack the password. + pub guesses: u64, + /// The order of magnitude of guesses. + pub guesses_log10: f64, + /// Determines if the password is empty. + pub is_empty: bool, /// Result of a database check. + #[serde(rename = "breached")] pub database_check: T, } @@ -60,11 +68,15 @@ impl From> for Vec> { { out.push( SecurityReportRow { + folder_name: record.folder.name().to_owned(), folder_id: *record.folder.id(), secret_id: record.secret_id, owner_id: record.owner.map(|(id, _)| id), field_index: record.owner.map(|(_, index)| index), - report: record.report, + score: record.report.score, + guesses: record.report.guesses, + guesses_log10: record.report.guesses_log10, + is_empty: record.report.is_empty, database_check, } );