diff --git a/CHANGELOG.md b/CHANGELOG.md index 21495f8ed..7a8d45586 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## Unreleased +### Added + +- `phylum exception` subcommand for managing suppressions + ## 7.2.0 - 2024-12-10 ### Added diff --git a/Cargo.lock b/Cargo.lock index 5b0256763..77e2294c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4506,6 +4506,7 @@ dependencies = [ "git2", "home", "ignore", + "indexmap", "lazy_static", "libc", "log", @@ -4948,13 +4949,14 @@ dependencies = [ [[package]] name = "purl" -version = "0.1.3" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c14fe28c8495f7eaf77a6e6106966f95211c0a2404b9da50d248fc32af3a3f14" +checksum = "f112b0e2a9bca03924c39166775b74fec9a831f7d4d8fa539dee0e565f403a0e" dependencies = [ "hex", "percent-encoding", "phf", + "serde", "smartstring", "thiserror 1.0.69", "unicase", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index f994ae056..800588222 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -4,7 +4,7 @@ version = "7.2.0" authors = ["Phylum, Inc. "] license = "GPL-3.0-or-later" edition = "2021" -rust-version = "1.80.0" +rust-version = "1.82.0" autotests = false [[test]] @@ -38,6 +38,7 @@ git2 = { version = "0.19.0", default-features = false } git-version = "0.3.5" home = "0.5.3" ignore = { version = "0.4.20", optional = true } +indexmap = "2.7.0" lazy_static = "1.4.0" libc = "0.2.135" log = "0.4.6" @@ -48,7 +49,7 @@ phylum_lockfile = { path = "../lockfile", features = ["generator"] } phylum_project = { path = "../phylum_project" } phylum_types = { git = "https://github.com/phylum-dev/phylum-types", branch = "development" } prettytable-rs = "0.10.0" -purl = "0.1.1" +purl = { version = "0.1.5", features = ["serde"] } rand = "0.8.4" regex = "1.5.5" reqwest = { version = "0.12.7", features = ["blocking", "json", "rustls-tls", "rustls-tls-native-roots", "rustls-tls-webpki-roots"], default-features = false } diff --git a/cli/src/api/endpoints.rs b/cli/src/api/endpoints.rs index fe0c15da5..5680149ab 100644 --- a/cli/src/api/endpoints.rs +++ b/cli/src/api/endpoints.rs @@ -197,6 +197,119 @@ pub fn firewall_log(api_uri: &str) -> Result { Ok(get_firewall_path(api_uri)?.join("activity")?) } +/// GET /organizations//groups//preferences. +pub fn org_group_preferences( + api_uri: &str, + org_name: &str, + group_name: &str, +) -> Result { + let mut url = get_api_path(api_uri)?; + url.path_segments_mut().unwrap().pop_if_empty().extend([ + "organizations", + org_name, + "groups", + group_name, + "preferences", + ]); + Ok(url) +} + +/// GET /preferences/group/ +pub fn group_preferences(api_uri: &str, group_name: &str) -> Result { + let mut url = get_api_path(api_uri)?; + url.path_segments_mut().unwrap().pop_if_empty().extend(["preferences", "group", group_name]); + Ok(url) +} + +/// GET /preferences/project/ +pub fn project_preferences(api_uri: &str, project_id: &str) -> Result { + let mut url = get_api_path(api_uri)?; + url.path_segments_mut().unwrap().pop_if_empty().extend(["preferences", "project", project_id]); + Ok(url) +} + +/// POST /organizations//groups//suppress. +pub fn org_group_suppress( + api_uri: &str, + org_name: &str, + group_name: &str, +) -> Result { + let mut url = get_api_path(api_uri)?; + url.path_segments_mut().unwrap().pop_if_empty().extend([ + "organizations", + org_name, + "groups", + group_name, + "suppress", + ]); + Ok(url) +} + +/// POST /preferences/group//suppress. +pub fn group_suppress(api_uri: &str, group_name: &str) -> Result { + let mut url = get_api_path(api_uri)?; + url.path_segments_mut().unwrap().pop_if_empty().extend([ + "preferences", + "group", + group_name, + "suppress", + ]); + Ok(url) +} + +/// POST /preferences/project//suppress. +pub fn project_suppress(api_uri: &str, project_id: &str) -> Result { + let mut url = get_api_path(api_uri)?; + url.path_segments_mut().unwrap().pop_if_empty().extend([ + "preferences", + "project", + project_id, + "suppress", + ]); + Ok(url) +} + +/// POST /organizations//groups//unsuppress. +pub fn org_group_unsuppress( + api_uri: &str, + org_name: &str, + group_name: &str, +) -> Result { + let mut url = get_api_path(api_uri)?; + url.path_segments_mut().unwrap().pop_if_empty().extend([ + "organizations", + org_name, + "groups", + group_name, + "unsuppress", + ]); + Ok(url) +} + +/// POST /preferences/group//unsuppress. +pub fn group_unsuppress(api_uri: &str, group_name: &str) -> Result { + let mut url = get_api_path(api_uri)?; + url.path_segments_mut().unwrap().pop_if_empty().extend([ + "preferences", + "group", + group_name, + "unsuppress", + ]); + Ok(url) +} + +/// POST /preferences/project//unsuppress. +pub fn project_unsuppress(api_uri: &str, project_id: &str) -> Result { + let mut url = get_api_path(api_uri)?; + url.path_segments_mut().unwrap().pop_if_empty().extend([ + "preferences", + "project", + project_id, + "unsuppress", + ]); + Ok(url) +} + /// GET /.well-known/openid-configuration pub fn oidc_discovery(api_uri: &str) -> Result { Ok(get_api_path(api_uri)?.join(".well-known/openid-configuration")?) diff --git a/cli/src/api/mod.rs b/cli/src/api/mod.rs index 7c4e5df8c..1a5c3b763 100644 --- a/cli/src/api/mod.rs +++ b/cli/src/api/mod.rs @@ -32,8 +32,8 @@ use crate::types::{ FirewallLogFilter, FirewallLogResponse, FirewallPaginated, GetProjectResponse, HistoryJob, ListUserGroupsResponse, OrgGroupsResponse, OrgMembersResponse, OrgsResponse, PackageSpecifier, PackageSubmitResponse, Paginated, PingResponse, PolicyEvaluationRequest, - PolicyEvaluationResponse, PolicyEvaluationResponseRaw, ProjectListEntry, RevokeTokenRequest, - SubmitPackageRequest, UpdateProjectRequest, UserToken, + PolicyEvaluationResponse, PolicyEvaluationResponseRaw, Preferences, ProjectListEntry, + RevokeTokenRequest, SubmitPackageRequest, Suppression, UpdateProjectRequest, UserToken, }; pub mod endpoints; @@ -596,6 +596,93 @@ impl PhylumApi { Ok(log) } + /// Get group preferences. + pub async fn group_preferences( + &self, + org: Option<&str>, + group: &str, + ) -> Result> { + match org { + Some(org) => { + let url = + endpoints::org_group_preferences(&self.config.connection.uri, org, group)?; + self.get(url).await + }, + None => { + #[derive(Deserialize)] + struct Response<'a> { + preferences: Preferences<'a>, + } + + let url = endpoints::group_preferences(&self.config.connection.uri, group)?; + Ok(self.get::(url).await?.preferences) + }, + } + } + + /// Get project preferences. + pub async fn project_preferences(&self, project_id: &str) -> Result> { + #[derive(Deserialize)] + struct Response<'a> { + preferences: Preferences<'a>, + } + + let url = endpoints::project_preferences(&self.config.connection.uri, project_id)?; + Ok(self.get::(url).await?.preferences) + } + + /// Add group suppression. + pub async fn group_suppress( + &self, + org: Option<&str>, + group: &str, + suppressions: &[Suppression<'_>], + ) -> Result<()> { + let url = match org { + Some(org) => endpoints::org_group_suppress(&self.config.connection.uri, org, group)?, + None => endpoints::group_suppress(&self.config.connection.uri, group)?, + }; + self.send_request_raw(Method::POST, url, Some(suppressions)).await?; + Ok(()) + } + + /// Get project suppression. + pub async fn project_suppress( + &self, + project_id: &str, + suppressions: &[Suppression<'_>], + ) -> Result<()> { + let url = endpoints::project_suppress(&self.config.connection.uri, project_id)?; + self.send_request_raw(Method::POST, url, Some(suppressions)).await?; + Ok(()) + } + + /// Remove group suppression. + pub async fn group_unsuppress( + &self, + org: Option<&str>, + group: &str, + unsuppressions: &[Suppression<'_>], + ) -> Result<()> { + let url = match org { + Some(org) => endpoints::org_group_unsuppress(&self.config.connection.uri, org, group)?, + None => endpoints::group_unsuppress(&self.config.connection.uri, group)?, + }; + self.send_request_raw(Method::POST, url, Some(unsuppressions)).await?; + Ok(()) + } + + /// Remove project suppression. + pub async fn project_unsuppress( + &self, + project_id: &str, + unsuppressions: &[Suppression<'_>], + ) -> Result<()> { + let url = endpoints::project_unsuppress(&self.config.connection.uri, project_id)?; + self.send_request_raw(Method::POST, url, Some(unsuppressions)).await?; + Ok(()) + } + /// Get reachable vulnerabilities. #[cfg(feature = "vulnreach")] pub async fn vulnerabilities(&self, job: Job) -> Result> { diff --git a/cli/src/app.rs b/cli/src/app.rs index d147de160..14ce580fb 100644 --- a/cli/src/app.rs +++ b/cli/src/app.rs @@ -1,5 +1,5 @@ use clap::builder::PossibleValuesParser; -use clap::{Arg, ArgAction, Command, ValueHint}; +use clap::{Arg, ArgAction, ArgGroup, Command, ValueHint}; use git_version::git_version; use lazy_static::lazy_static; @@ -610,52 +610,183 @@ pub fn add_subcommands(command: Command) -> Command { .arg_required_else_help(true) .subcommand_required(true) .subcommand( - Command::new("log") - .about("Show firewall activity log") - .args(&[Arg::new("json") + Command::new("log").about("Show firewall activity log").args(&[ + Arg::new("json") .action(ArgAction::SetTrue) .short('j') .long("json") - .help("Produce output in json format (default: false)")]) + .help("Produce output in json format (default: false)"), + Arg::new("group") + .value_name("GROUP_NAME") + .help("Firewall group to list log activity for") + .required(true), + Arg::new("package-type") + .long("package-type") + .value_name("PACKAGE_TYPE") + .help("Only show logs matching this package type") + .value_parser(["npm", "gem", "pypi", "maven", "nuget", "cargo"]), + Arg::new("purl") + .long("purl") + .value_name("PURL") + .help("Only show logs matching this PURL") + .conflicts_with("package-type"), + Arg::new("action") + .long("action") + .value_name("ACTION") + .help("Only show logs matching this log action") + .value_parser([ + "Download", + "AnalysisSuccess", + "AnalysisFailure", + "AnalysisWarning", + ]), + Arg::new("before") + .long("before") + .value_name("TIMESTAMP") + .help("Only show logs created before this timestamp (RFC3339 format)"), + Arg::new("after") + .long("after") + .value_name("TIMESTAMP") + .help("Only show logs created after this timestamp (RFC3339 format)"), + Arg::new("limit") + .long("limit") + .value_name("COUNT") + .help("Maximum number of log entries to show") + .default_value("10") + .value_parser(1..=10_000), + ]), + ), + ) + .subcommand( + Command::new("exception") + .about("Manage analysis exceptions") + .arg_required_else_help(true) + .subcommand_required(true) + .subcommand( + Command::new("list") + .about("List active analysis exceptions") + .group(ArgGroup::new("subject").args(["group", "project"]).required(true)) + .args(&[ + Arg::new("json") + .action(ArgAction::SetTrue) + .short('j') + .long("json") + .help("Produce output in json format (default: false)"), + Arg::new("group") + .short('g') + .long("group") + .value_name("GROUP_NAME") + .help("Group to list exceptions for"), + Arg::new("project") + .short('p') + .long("project") + .value_name("PROJECT_NAME") + .help("Project to list exceptions for"), + ]), + ) + .subcommand( + Command::new("add") + .about("Add a new analysis exception") + .group(ArgGroup::new("subject").args(["group", "project"]).required(true)) .args(&[ Arg::new("group") + .short('g') + .long("group") .value_name("GROUP_NAME") - .help("Specify a group to use for analysis") - .required(true), - Arg::new("ecosystem") - .long("ecosystem") - .value_name("ECOSYSTEM") - .help("Only show logs matching this ecosystem") + .help("Group to add exception to"), + Arg::new("project") + .short('p') + .long("project") + .value_name("PROJECT_NAME") + .help("Project to add exceptions to"), + Arg::new("package-type") + .long("package-type") + .value_name("PACKAGE_TYPE") + .help("Package type of the package to add an exception for") .value_parser([ - "npm", "rubygems", "pypi", "maven", "nuget", "cargo", + "npm", "gem", "pypi", "maven", "nuget", "golang", "cargo", ]), - Arg::new("package") - .long("package") + Arg::new("name") + .short('n') + .long("name") + .value_name("PACKAGE_NAME") + .help( + "Fully qualified name of the package to add an exception for", + ), + Arg::new("version") + .long("version") + .value_name("VERSION") + .help("Version of the package to add an exception for"), + Arg::new("purl") + .long("purl") .value_name("PURL") - .help("Only show logs matching this PURL") - .conflicts_with("ecosystem"), - Arg::new("action") - .long("action") - .value_name("ACTION") - .help("Only show logs matching this log action") + .help("Package in PURL format") + .conflicts_with_all(["package-type", "name", "version"]), + Arg::new("reason") + .short('r') + .long("reason") + .value_name("REASON") + .help("Reason for adding this exception"), + Arg::new("no-suggestions") + .short('s') + .long("no-suggestions") + .action(ArgAction::SetTrue) + .help("Do not query package firewall to make suggestions"), + ]), + ) + .subcommand( + Command::new("remove") + .about("Remove an existing analysis exception") + .group(ArgGroup::new("subject").args(["group", "project"]).required(true)) + .group( + ArgGroup::new("package") + .args(["package-type", "name", "version", "purl"]) + .conflicts_with("issue"), + ) + .group(ArgGroup::new("issue").args(["id", "tag"])) + .args(&[ + Arg::new("group") + .short('g') + .long("group") + .value_name("GROUP_NAME") + .help("Group to remove exception from"), + Arg::new("project") + .short('p') + .long("project") + .value_name("PROJECT_NAME") + .help("Project to remove exceptions from"), + Arg::new("package-type") + .long("package-type") + .value_name("PACKAGE_TYPE") + .help("Package type of the exception which should be removed") .value_parser([ - "Download", - "AnalysisSuccess", - "AnalysisFailure", - "AnalysisWarning", + "npm", "gem", "pypi", "maven", "nuget", "golang", "cargo", ]), - Arg::new("before").long("before").value_name("TIMESTAMP").help( - "Only show logs created before this timestamp (RFC3339 format)", - ), - Arg::new("after").long("after").value_name("TIMESTAMP").help( - "Only show logs created after this timestamp (RFC3339 format)", - ), - Arg::new("limit") - .long("limit") - .value_name("COUNT") - .help("Maximum number of log entries to show") - .default_value("10") - .value_parser(1..=10_000), + Arg::new("name") + .short('n') + .long("name") + .value_name("PACKAGE_NAME") + .help( + "Fully qualified package name of the exception which should \ + be removed", + ), + Arg::new("version") + .long("version") + .value_name("VERSION") + .help("Package version of the exception which should be removed"), + Arg::new("purl") + .long("purl") + .value_name("PURL") + .help("Package in PURL format") + .conflicts_with_all(["package-type", "name", "version"]), + Arg::new("id") + .long("id") + .value_name("ISSUE_ID") + .help("Issue ID of the exception which should be removed"), + Arg::new("tag") + .long("tag") + .value_name("ISSUE_TAG") + .help("Issue tag of the exception which should be removed"), ]), ), ); diff --git a/cli/src/bin/phylum.rs b/cli/src/bin/phylum.rs index 7333343ea..0a9edbae4 100644 --- a/cli/src/bin/phylum.rs +++ b/cli/src/bin/phylum.rs @@ -14,8 +14,8 @@ use phylum_cli::commands::sandbox; #[cfg(feature = "selfmanage")] use phylum_cli::commands::uninstall; use phylum_cli::commands::{ - auth, find_dependency_files, firewall, group, init, jobs, org, packages, parse, project, - status, CommandResult, ExitCode, + auth, exception, find_dependency_files, firewall, group, init, jobs, org, packages, parse, + project, status, CommandResult, ExitCode, }; use phylum_cli::config::{self, Config}; use phylum_cli::spinner::Spinner; @@ -148,6 +148,9 @@ async fn handle_commands() -> CommandResult { "firewall" => { firewall::handle_firewall(&Spinner::wrap(api).await?, sub_matches, config).await }, + "exception" => { + exception::handle_exception(&Spinner::wrap(api).await?, sub_matches, config).await + }, #[cfg(feature = "selfmanage")] "uninstall" => uninstall::handle_uninstall(sub_matches), diff --git a/cli/src/commands/exception.rs b/cli/src/commands/exception.rs new file mode 100644 index 000000000..ca2919eed --- /dev/null +++ b/cli/src/commands/exception.rs @@ -0,0 +1,365 @@ +//! Subcommand `phylum exception`. + +use std::borrow::Cow; +use std::str::FromStr; + +use clap::ArgMatches; +use console::Term; +use dialoguer::{FuzzySelect, Input}; +use indexmap::IndexSet; +use purl::{PackageType, Purl}; + +use crate::api::PhylumApi; +use crate::commands::{CommandResult, ExitCode}; +use crate::config::Config; +use crate::format::Format; +use crate::spinner::Spinner; +use crate::types::{ + FirewallAction, FirewallLogFilter, IgnoredIssue, IgnoredPackage, Preferences, Suppression, +}; +use crate::{print_user_success, print_user_warning}; + +/// Maximum number of package names or versions proposed for exceptions. +const MAX_SUGGESTIONS: usize = 25; + +/// Handle `phylum exception` subcommand. +pub async fn handle_exception( + api: &PhylumApi, + matches: &ArgMatches, + config: Config, +) -> CommandResult { + match matches.subcommand() { + Some(("list", matches)) => handle_list(api, matches, config).await, + Some(("add", matches)) => handle_add(api, matches, config).await, + Some(("remove", matches)) => handle_remove(api, matches, config).await, + _ => unreachable!("invalid clap configuration"), + } +} + +/// Handle `phylum exception list` subcommand. +pub async fn handle_list(api: &PhylumApi, matches: &ArgMatches, config: Config) -> CommandResult { + let group = matches.get_one::("group"); + let org = config.org(); + + let exceptions = match matches.get_one::("project") { + Some(project_name) => { + let group = group.map(String::as_str); + let project_id = api.get_project_id(project_name, org, group).await?.to_string(); + api.project_preferences(&project_id).await? + }, + None => api.group_preferences(config.org(), group.unwrap()).await?, + }; + + let pretty = !matches.get_flag("json"); + exceptions.write_stdout(pretty); + + Ok(ExitCode::Ok) +} + +/// Handle `phylum exception add` subcommand. +pub async fn handle_add(api: &PhylumApi, matches: &ArgMatches, config: Config) -> CommandResult { + let no_suggestions = matches.get_flag("no-suggestions"); + let package_type = matches.get_one::("package-type"); + let project = matches.get_one::("project"); + let version = matches.get_one::("version"); + let reason = matches.get_one::("reason"); + let group = matches.get_one::("group"); + let name = matches.get_one::("name"); + let purl = matches.get_one::("purl"); + let org = config.org(); + + // Parse PURL argument or assemble it from its components. + let mut purl = match purl { + Some(purl) => Purl::from_str(purl)?, + None => purl_from_components(api, package_type, name, group, org, !no_suggestions).await?, + }; + + // Get suggested versions from Aviary if no argument was supplied. + let version = purl.version().or(version.map(String::as_str)); + let mut suggested_versions = IndexSet::new(); + if let Some(group) = group.filter(|_| !no_suggestions && version.is_none()) { + let spinner = Spinner::new(); + + let filter = FirewallLogFilter { + ecosystem: Some(*purl.package_type()), + action: Some(FirewallAction::AnalysisFailure), + namespace: purl.namespace(), + name: Some(purl.name()), + ..Default::default() + }; + if let Ok(logs) = api.firewall_log(org, group, filter).await { + suggested_versions = logs.data.into_iter().map(|log| log.package.version).collect(); + } + + spinner.stop().await; + } + + // Prompt for version if it wasn't supplied as an argument. + let version = match version { + Some(version) => version.to_string().into(), + None => prompt_version(&suggested_versions)?, + }; + purl = purl.into_builder().with_version(version).build()?; + + // Prompt for exception reason. if it wasn't supplied as an argument. + let reason = match reason { + Some(reason) => reason.into(), + None => prompt_reason()?, + }; + + // Build suppression API object. + let suppressions = [Suppression::Package(IgnoredPackage { + purl: Cow::Owned(purl.to_string()), + reason: Cow::Borrowed(&reason), + })]; + + match project { + Some(project_name) => { + let group = group.map(String::as_str); + let project_id = api.get_project_id(project_name, org, group).await?.to_string(); + api.project_suppress(&project_id, &suppressions).await?; + }, + None => api.group_suppress(org, group.unwrap(), &suppressions).await?, + } + + print_user_success!("Successfully added suppression for {}", purl); + + Ok(ExitCode::Ok) +} + +/// Handle `phylum exception remove` subcommand. +pub async fn handle_remove(api: &PhylumApi, matches: &ArgMatches, config: Config) -> CommandResult { + let package_type = matches.get_one::("package-type"); + let project = matches.get_one::("project"); + let version = matches.get_one::("version"); + let group = matches.get_one::("group"); + let name = matches.get_one::("name"); + let purl = matches.get_one::("purl"); + let tag = matches.get_one::("tag"); + let id = matches.get_one::("id"); + let org = config.org(); + + let mut exceptions = match matches.get_one::("project") { + Some(project_name) => { + let group = group.map(String::as_str); + let project_id = api.get_project_id(project_name, org, group).await?.to_string(); + api.project_preferences(&project_id).await? + }, + None => api.group_preferences(config.org(), group.unwrap()).await?, + }; + + // Filter issue suppressions with CLI args. + if tag.is_some() || id.is_some() { + exceptions.ignored_issues.retain(|issue| { + id.is_none_or(|id| id == &issue.id) && tag.is_none_or(|tag| tag == &issue.tag) + }); + } + + // Filter package suppressions with CLI args. + if package_type.is_some() || name.is_some() || version.is_some() || purl.is_some() { + let purl = purl.map(|purl| Purl::from_str(purl)); + let (package_type, combined_name, version) = match purl { + Some(Ok(ref purl)) => { + (Some(*purl.package_type()), Some(purl.combined_name()), purl.version()) + }, + Some(Err(err)) => return Err(err.into()), + None => { + let package_type = match package_type { + Some(package_type) => Some(PackageType::from_str(package_type)?), + None => None, + }; + let name = name.map(|name| Cow::Borrowed(name.as_str())); + (package_type, name, version.map(String::as_str)) + }, + }; + + exceptions.ignored_packages.retain(|pkg| { + let purl = match Purl::from_str(&pkg.purl) { + Ok(purl) => purl, + Err(_) => return false, + }; + + version.is_none_or(|version| Some(version) == purl.version()) + && combined_name.as_ref().is_none_or(|name| *name == purl.combined_name()) + && package_type.is_none_or(|pt| pt == *purl.package_type()) + }); + } + + // Abort if no matching exceptions were found. + if exceptions.ignored_packages.is_empty() && exceptions.ignored_issues.is_empty() { + print_user_warning!("No existing exception matches the active filter."); + return Ok(ExitCode::Ok); + } + + let unsuppressions = [prompt_removal(&exceptions)?]; + + match project { + Some(project_name) => { + let group = group.map(String::as_str); + let project_id = api.get_project_id(project_name, org, group).await?.to_string(); + api.project_unsuppress(&project_id, &unsuppressions).await?; + }, + None => api.group_unsuppress(org, group.unwrap(), &unsuppressions).await?, + } + + match &unsuppressions[0] { + Suppression::Package(IgnoredPackage { purl, .. }) => { + print_user_success!("Successfully removed suppression for package {purl}"); + }, + Suppression::Issue(IgnoredIssue { id, tag, .. }) => { + print_user_success!("Successfully removed suppression for issue {tag:?} [{id}]"); + }, + } + + Ok(ExitCode::Ok) +} + +/// Creat a PURL from its individual components. +async fn purl_from_components( + api: &PhylumApi, + cli_package_type: Option<&String>, + cli_name: Option<&String>, + group: Option<&String>, + org: Option<&str>, + suggestions: bool, +) -> anyhow::Result { + // Prompt for package type if it wasn't supplied as an argument. + let package_type = match cli_package_type { + Some(package_type) => PackageType::from_str(package_type)?, + None => prompt_package_type()?, + }; + + // Get suggested names from Aviary if no argument was supplied. + let mut suggested_names: IndexSet = IndexSet::new(); + if let Some(group) = group.filter(|_| suggestions && cli_name.is_none()) { + let spinner = Spinner::new(); + + let filter = FirewallLogFilter { + ecosystem: Some(package_type), + action: Some(FirewallAction::AnalysisFailure), + ..Default::default() + }; + if let Ok(logs) = api.firewall_log(org, group, filter).await { + for log in logs.data { + let purl = Purl::builder(package_type, log.package.name) + .with_namespace(log.package.namespace) + .build()?; + suggested_names.insert(purl); + } + } + + spinner.stop().await; + } + + // Prompt for name if it wasn't supplied as an argument. + let purl = match cli_name { + Some(name) => Purl::builder_with_combined_name(package_type, name).build()?, + None => prompt_name(package_type, &suggested_names)?, + }; + + Ok(purl) +} + +/// Ask for a package type. +fn prompt_package_type() -> dialoguer::Result { + let package_types = ["cargo", "gem", "golang", "maven", "npm", "nuget", "pypi"]; + + let prompt = "[ENTER] Select and Confirm\nSelect package type"; + let index = FuzzySelect::new().with_prompt(prompt).items(&package_types).interact()?; + + println!(); + + Ok(PackageType::from_str(package_types[index]).unwrap()) +} + +/// Ask for a package name. +fn prompt_name(package_type: PackageType, suggestions: &'_ IndexSet) -> anyhow::Result { + // Get space available for suggestions. + let term_size = Term::stdout().size_checked().unwrap_or((u16::MAX, u16::MAX)); + let max_suggestions = (term_size.0 as usize - 3).min(MAX_SUGGESTIONS); + + let mut prompt = "[ENTER] Confirm\nSpecify package name"; + + // Suggest possible names. + if !suggestions.is_empty() { + prompt = "[ENTER] Confirm\nEnter number or specify package name"; + + for (i, suggestion) in suggestions.iter().take(max_suggestions).enumerate().rev() { + println!("({i}) {}", suggestion.combined_name()); + } + println!(); + } + + let input: String = Input::new().with_prompt(prompt).interact_text()?; + + let purl = match usize::from_str(&input) { + Ok(index) if index < suggestions.len() && index < MAX_SUGGESTIONS => { + suggestions[index].clone() + }, + _ => Purl::builder_with_combined_name(package_type, &input).build()?, + }; + + println!("Using package {}\n", purl.combined_name()); + + Ok(purl) +} + +/// Ask for a package version. +fn prompt_version(suggestions: &'_ IndexSet) -> dialoguer::Result> { + // Get space available for suggestions. + let term_size = Term::stdout().size_checked().unwrap_or((u16::MAX, u16::MAX)); + let max_suggestions = (term_size.0 as usize - 3).min(MAX_SUGGESTIONS); + + let mut prompt = "[ENTER] Confirm\nSpecify package version"; + + // Suggest possible names. + if !suggestions.is_empty() { + prompt = "[ENTER] Confirm\nEnter number or specify package name"; + + for (i, suggestion) in suggestions.iter().take(max_suggestions).enumerate().rev() { + println!("({i}) {suggestion}"); + } + println!(); + } + + let input: String = Input::new().with_prompt(prompt).interact_text()?; + + let version: Cow<'_, str> = match usize::from_str(&input) { + Ok(index) if index < suggestions.len() && index < MAX_SUGGESTIONS => { + Cow::Borrowed(&suggestions[index]) + }, + _ => Cow::Owned(input), + }; + + println!("Using version {version:?}\n"); + + Ok(version) +} + +/// Ask for suppression reason. +fn prompt_reason() -> dialoguer::Result { + let prompt = "[ENTER] Confirm\nEnter reason for this exception"; + let reason: String = Input::new().with_prompt(prompt).interact_text()?; + println!("Using reason {reason:?}\n"); + Ok(reason) +} + +/// Ask for suppression reason. +fn prompt_removal<'a>(preferences: &'a Preferences<'a>) -> dialoguer::Result> { + let ignored_packages = preferences.ignored_packages.iter().map(|pkg| Cow::Borrowed(&*pkg.purl)); + let ignored_issues = preferences + .ignored_issues + .iter() + .map(|issue| Cow::Owned(format!("[{}] {}", issue.tag, issue.id))); + let exceptions: Vec<_> = ignored_packages.chain(ignored_issues).collect(); + + let prompt = "[ENTER] Select and Confirm\nSelect exception"; + let index = FuzzySelect::new().with_prompt(prompt).items(&exceptions).interact()?; + + println!(); + + match index.checked_sub(preferences.ignored_packages.len()) { + Some(index) => Ok(Suppression::from(&preferences.ignored_issues[index])), + None => Ok(Suppression::from(&preferences.ignored_packages[index])), + } +} diff --git a/cli/src/commands/firewall.rs b/cli/src/commands/firewall.rs index 0e98e528a..db9cd8e96 100644 --- a/cli/src/commands/firewall.rs +++ b/cli/src/commands/firewall.rs @@ -1,17 +1,16 @@ //! Subcommand `phylum firewall`. -use std::borrow::Cow; use std::str::FromStr; use clap::ArgMatches; -use purl::Purl; +use purl::{PackageType, Purl}; use crate::api::PhylumApi; use crate::commands::{CommandResult, ExitCode}; use crate::config::Config; use crate::format::Format; use crate::print_user_failure; -use crate::types::FirewallLogFilter; +use crate::types::{FirewallAction, FirewallLogFilter}; /// Handle `phylum firewall` subcommand. pub async fn handle_firewall( @@ -31,37 +30,39 @@ pub async fn handle_log(api: &PhylumApi, matches: &ArgMatches, config: Config) - let group = matches.get_one::("group").unwrap(); // Get log filter args. - let ecosystem = matches.get_one::("ecosystem"); - let purl = matches.get_one::("package"); - let action = matches.get_one::("action"); + let package_type = matches.get_one::("package-type"); + let action = matches.get_one::("action"); let before = matches.get_one::("before"); let after = matches.get_one::("after"); + let purl = matches.get_one::("purl"); let limit = matches.get_one::("limit").unwrap(); // Parse PURL filter. let parsed_purl = purl.map(|purl| Purl::from_str(purl)); - let (ecosystem, namespace, name, version) = match &parsed_purl { + let (package_type, namespace, name, version) = match &parsed_purl { Some(Ok(purl)) => { - let ecosystem = Cow::Owned(purl.package_type().to_string()); - (Some(ecosystem), purl.namespace(), Some(purl.name()), purl.version()) + (Some(*purl.package_type()), purl.namespace(), Some(purl.name()), purl.version()) }, Some(Err(err)) => { print_user_failure!("Could not parse purl {purl:?}: {err}"); return Ok(ExitCode::Generic); }, - None => (ecosystem.map(Cow::Borrowed), None, None, None), + None => { + let package_type = package_type.and_then(|pt| PackageType::from_str(pt).ok()); + (package_type, None, None, None) + }, }; // Construct the filter. let filter = FirewallLogFilter { - ecosystem: ecosystem.as_ref().map(|e| e.as_str()), namespace, - name, version, - action: action.map(String::as_str), + name, before: before.map(String::as_str), after: after.map(String::as_str), limit: Some(*limit as i32), + ecosystem: package_type, + action: action.copied(), }; let response = api.firewall_log(org, group, filter).await?; diff --git a/cli/src/commands/mod.rs b/cli/src/commands/mod.rs index 99dc186f1..5d35582e7 100644 --- a/cli/src/commands/mod.rs +++ b/cli/src/commands/mod.rs @@ -1,6 +1,7 @@ use std::process; pub mod auth; +pub mod exception; #[cfg(feature = "extensions")] pub mod extensions; pub mod find_dependency_files; diff --git a/cli/src/format.rs b/cli/src/format.rs index c732f1174..00b310032 100644 --- a/cli/src/format.rs +++ b/cli/src/format.rs @@ -21,7 +21,7 @@ use crate::print::{self, table_format}; use crate::types::{ FirewallAction, FirewallLogResponse, GetProjectResponse, HistoryJob, Issue, OrgMember, OrgMembersResponse, OrgsResponse, Package, PolicyEvaluationResponse, - PolicyEvaluationResponseRaw, ProjectListEntry, RiskLevel, UserToken, + PolicyEvaluationResponseRaw, Preferences, ProjectListEntry, RiskLevel, UserToken, }; // Maximum length of email column. @@ -441,6 +441,29 @@ impl Format for Vec { } } +impl Format for Preferences<'_> { + fn pretty(&self, writer: &mut W) { + let issue_exceptions = self + .ignored_issues + .iter() + .map(|issue| (format!("[{}] {}", issue.tag, issue.id), issue.reason.to_string())); + let package_exceptions = + self.ignored_packages.iter().map(|pkg| (pkg.purl.to_string(), pkg.reason.to_string())); + let exceptions: Vec<_> = issue_exceptions.chain(package_exceptions).collect(); + + if exceptions.is_empty() { + println!("No exceptions present."); + return; + } + + let table = format_table:: String, _>(&exceptions, &[ + ("Subject", |(subject, _)| subject.into()), + ("Reason", |(_, reason)| reason.to_string()), + ]); + let _ = writeln!(writer, "{table}"); + } +} + #[cfg(feature = "vulnreach")] impl Format for Vulnerability { fn pretty(&self, writer: &mut W) { diff --git a/cli/src/types.rs b/cli/src/types.rs index d3ee7a93a..9d7461b37 100644 --- a/cli/src/types.rs +++ b/cli/src/types.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use std::fmt::{self, Display, Formatter}; use std::str::FromStr; @@ -549,13 +550,27 @@ pub enum FirewallAction { AnalysisWarning, } +impl FromStr for FirewallAction { + type Err = (); + + fn from_str(s: &str) -> Result { + match s { + "Download" => Ok(Self::Download), + "AnalysisSuccess" => Ok(Self::AnalysisSuccess), + "AnalysisFailure" => Ok(Self::AnalysisFailure), + "AnalysisWarning" => Ok(Self::AnalysisWarning), + _ => Err(()), + } + } +} + /// Aviary log package. #[derive(PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Debug, Serialize, Deserialize)] pub struct FirewallPackage { pub ecosystem: String, pub name: String, - pub namespace: String, pub version: String, + pub namespace: String, } impl FirewallPackage { @@ -572,12 +587,62 @@ impl FirewallPackage { /// Aviary log filter query. #[derive(Serialize, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Debug, Default)] pub struct FirewallLogFilter<'a> { - pub ecosystem: Option<&'a str>, + pub ecosystem: Option, pub namespace: Option<&'a str>, pub name: Option<&'a str>, pub version: Option<&'a str>, - pub action: Option<&'a str>, + pub action: Option, pub before: Option<&'a str>, pub after: Option<&'a str>, pub limit: Option, } + +/// Project/Group preferences. +#[derive(Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Debug, Default)] +pub struct Preferences<'a> { + #[serde(rename = "ignoredIssues", default)] + pub ignored_issues: Vec>, + #[serde(rename = "ignoredPackages", default)] + pub ignored_packages: Vec>, +} + +/// Project/Group preferences ignored issues. +#[derive(Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Debug, Default)] +pub struct IgnoredIssue<'a> { + pub id: Cow<'a, str>, + pub tag: Cow<'a, str>, + pub reason: Cow<'a, str>, +} + +/// Project/Group preferences ignored packages. +#[derive(Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Debug, Default)] +pub struct IgnoredPackage<'a> { + pub purl: Cow<'a, str>, + pub reason: Cow<'a, str>, +} + +/// Suppression request variants. +#[derive(Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Debug)] +pub enum Suppression<'a> { + Package(IgnoredPackage<'a>), + Issue(IgnoredIssue<'a>), +} + +impl<'a> From<&'a IgnoredIssue<'a>> for Suppression<'a> { + fn from(issue: &'a IgnoredIssue<'a>) -> Self { + Self::Issue(IgnoredIssue { + id: Cow::Borrowed(&issue.id), + tag: Cow::Borrowed(&issue.tag), + reason: Cow::Borrowed(&issue.reason), + }) + } +} + +impl<'a> From<&'a IgnoredPackage<'a>> for Suppression<'a> { + fn from(package: &'a IgnoredPackage<'a>) -> Self { + Self::Package(IgnoredPackage { + purl: Cow::Borrowed(&package.purl), + reason: Cow::Borrowed(&package.reason), + }) + } +} diff --git a/docs/commands/phylum.md b/docs/commands/phylum.md index f6eff34a9..0b6543f69 100644 --- a/docs/commands/phylum.md +++ b/docs/commands/phylum.md @@ -39,6 +39,7 @@ Usage: phylum [OPTIONS] [COMMAND] * [phylum analyze](./phylum_analyze.md) * [phylum auth](./phylum_auth.md) +* [phylum exception](./phylum_exception.md) * [phylum extension](./phylum_extension.md) * [phylum firewall](./phylum_firewall.md) * [phylum group](./phylum_group.md) diff --git a/docs/commands/phylum_exception.md b/docs/commands/phylum_exception.md new file mode 100644 index 000000000..adc4e67a9 --- /dev/null +++ b/docs/commands/phylum_exception.md @@ -0,0 +1,27 @@ +# phylum exception + +Manage analysis exceptions + +```sh +Usage: phylum exception [OPTIONS] +``` + +## Options + +`-o`, `--org` `` +  Phylum organization + +`-v`, `--verbose`... +  Increase the level of verbosity (the maximum is -vvv) + +`-q`, `--quiet`... +  Reduce the level of verbosity (the maximum is -qq) + +`-h`, `--help` +  Print help + +## Commands + +* [phylum exception add](./phylum_exception_add.md) +* [phylum exception list](./phylum_exception_list.md) +* [phylum exception remove](./phylum_exception_remove.md) diff --git a/docs/commands/phylum_exception_add.md b/docs/commands/phylum_exception_add.md new file mode 100644 index 000000000..2fa1ed215 --- /dev/null +++ b/docs/commands/phylum_exception_add.md @@ -0,0 +1,46 @@ +# phylum exception add + +Add a new analysis exception + +```sh +Usage: phylum exception add [OPTIONS] <--group |--project > +``` + +## Options + +`-g`, `--group` `` +  Group to add exception to + +`-p`, `--project` `` +  Project to add exceptions to + +`--package-type` `` +  Package type of the package to add an exception for +  Accepted values: `npm`, `gem`, `pypi`, `maven`, `nuget`, `golang`, `cargo` + +`-n`, `--name` `` +  Fully qualified name of the package to add an exception for + +`--version` `` +  Version of the package to add an exception for + +`--purl` `` +  Package in PURL format + +`-r`, `--reason` `` +  Reason for adding this exception + +`-s`, `--no-suggestions` +  Do not query package firewall to make suggestions + +`-o`, `--org` `` +  Phylum organization + +`-v`, `--verbose`... +  Increase the level of verbosity (the maximum is -vvv) + +`-q`, `--quiet`... +  Reduce the level of verbosity (the maximum is -qq) + +`-h`, `--help` +  Print help diff --git a/docs/commands/phylum_exception_list.md b/docs/commands/phylum_exception_list.md new file mode 100644 index 000000000..2b4b3720c --- /dev/null +++ b/docs/commands/phylum_exception_list.md @@ -0,0 +1,30 @@ +# phylum exception list + +List active analysis exceptions + +```sh +Usage: phylum exception list [OPTIONS] <--group |--project > +``` + +## Options + +`-j`, `--json` +  Produce output in json format (default: false) + +`-g`, `--group` `` +  Group to list exceptions for + +`-p`, `--project` `` +  Project to list exceptions for + +`-o`, `--org` `` +  Phylum organization + +`-v`, `--verbose`... +  Increase the level of verbosity (the maximum is -vvv) + +`-q`, `--quiet`... +  Reduce the level of verbosity (the maximum is -qq) + +`-h`, `--help` +  Print help diff --git a/docs/commands/phylum_exception_remove.md b/docs/commands/phylum_exception_remove.md new file mode 100644 index 000000000..d64ec9f7e --- /dev/null +++ b/docs/commands/phylum_exception_remove.md @@ -0,0 +1,46 @@ +# phylum exception remove + +Remove an existing analysis exception + +```sh +Usage: phylum exception remove [OPTIONS] <--group |--project > +``` + +## Options + +`-g`, `--group` `` +  Group to remove exception from + +`-p`, `--project` `` +  Project to remove exceptions from + +`--package-type` `` +  Package type of the exception which should be removed +  Accepted values: `npm`, `gem`, `pypi`, `maven`, `nuget`, `golang`, `cargo` + +`-n`, `--name` `` +  Fully qualified package name of the exception which should be removed + +`--version` `` +  Package version of the exception which should be removed + +`--purl` `` +  Package in PURL format + +`--id` `` +  Issue ID of the exception which should be removed + +`--tag` `` +  Issue tag of the exception which should be removed + +`-o`, `--org` `` +  Phylum organization + +`-v`, `--verbose`... +  Increase the level of verbosity (the maximum is -vvv) + +`-q`, `--quiet`... +  Reduce the level of verbosity (the maximum is -qq) + +`-h`, `--help` +  Print help diff --git a/docs/commands/phylum_firewall_log.md b/docs/commands/phylum_firewall_log.md index 83e00e81d..496d15b0b 100644 --- a/docs/commands/phylum_firewall_log.md +++ b/docs/commands/phylum_firewall_log.md @@ -9,18 +9,18 @@ Usage: phylum firewall log [OPTIONS] ## Arguments `` -  Specify a group to use for analysis +  Firewall group to list log activity for ## Options `-j`, `--json`   Produce output in json format (default: false) -`--ecosystem` `` -  Only show logs matching this ecosystem -  Accepted values: `npm`, `rubygems`, `pypi`, `maven`, `nuget`, `cargo` +`--package-type` `` +  Only show logs matching this package type +  Accepted values: `npm`, `gem`, `pypi`, `maven`, `nuget`, `cargo` -`--package` `` +`--purl` ``   Only show logs matching this PURL `--action` ``