diff --git a/CHANGELOG.md b/CHANGELOG.md index b92f348bf..f355a3c07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added - Support for C#'s `packages.*.config` lockfile type +- `phylum firewall log` command to browse firewall activity log ## 7.1.5 - 2024-11-26 diff --git a/Cargo.lock b/Cargo.lock index aa4d4f605..38d3254e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4516,6 +4516,7 @@ dependencies = [ "phylum_types", "predicates", "prettytable-rs", + "purl", "rand", "regex", "reqwest", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index fd8d47fe6..258ba3a35 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -48,6 +48,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" 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 95d0683f2..fe0c15da5 100644 --- a/cli/src/api/endpoints.rs +++ b/cli/src/api/endpoints.rs @@ -192,6 +192,11 @@ pub fn org_groups_delete( Ok(url) } +/// Aviary activity endpoint. +pub fn firewall_log(api_uri: &str) -> Result { + Ok(get_firewall_path(api_uri)?.join("activity")?) +} + /// GET /.well-known/openid-configuration pub fn oidc_discovery(api_uri: &str) -> Result { Ok(get_api_path(api_uri)?.join(".well-known/openid-configuration")?) @@ -242,6 +247,14 @@ fn get_locksmith_path(api_uri: &str) -> Result { Ok(parse_base_url(api_uri)?.join(LOCKSMITH_PATH)?) } +fn get_firewall_path(api_uri: &str) -> Result { + let mut api_path = parse_base_url(api_uri)?; + let host = api_path.host_str().ok_or(ParseError::EmptyHost)?; + let host = host.replacen("api.", "aviary.", 1); + api_path.set_host(Some(&host))?; + Ok(api_path) +} + #[cfg(test)] mod test { use super::*; diff --git a/cli/src/api/mod.rs b/cli/src/api/mod.rs index a32de9b5e..7c4e5df8c 100644 --- a/cli/src/api/mod.rs +++ b/cli/src/api/mod.rs @@ -1,7 +1,9 @@ +use std::borrow::Cow; use std::collections::HashSet; use std::time::Duration; use anyhow::{anyhow, Context}; +use phylum_types::types::auth::AccessToken; use phylum_types::types::common::{JobId, ProjectId}; use phylum_types::types::group::{ CreateGroupRequest, CreateGroupResponse, ListGroupMembersResponse, @@ -27,10 +29,11 @@ use crate::auth::{ use crate::config::{AuthInfo, Config}; use crate::types::{ AddOrgUserRequest, AnalysisPackageDescriptor, ApiOrgGroup, CreateProjectRequest, - GetProjectResponse, HistoryJob, ListUserGroupsResponse, OrgGroupsResponse, OrgMembersResponse, - OrgsResponse, PackageSpecifier, PackageSubmitResponse, Paginated, PingResponse, - PolicyEvaluationRequest, PolicyEvaluationResponse, PolicyEvaluationResponseRaw, - ProjectListEntry, RevokeTokenRequest, SubmitPackageRequest, UpdateProjectRequest, UserToken, + FirewallLogFilter, FirewallLogResponse, FirewallPaginated, GetProjectResponse, HistoryJob, + ListUserGroupsResponse, OrgGroupsResponse, OrgMembersResponse, OrgsResponse, PackageSpecifier, + PackageSubmitResponse, Paginated, PingResponse, PolicyEvaluationRequest, + PolicyEvaluationResponse, PolicyEvaluationResponseRaw, ProjectListEntry, RevokeTokenRequest, + SubmitPackageRequest, UpdateProjectRequest, UserToken, }; pub mod endpoints; @@ -41,6 +44,7 @@ pub struct PhylumApi { roles: HashSet, config: Config, client: Client, + access_token: AccessToken, } /// Phylum Api Error type @@ -140,7 +144,7 @@ impl PhylumApi { // Try to parse token's roles. let roles = jwt::user_roles(access_token.as_str()).unwrap_or_default(); - Ok(Self { config, client, roles }) + Ok(Self { config, client, roles, access_token }) } async fn get(&self, path: U) -> Result { @@ -562,6 +566,36 @@ impl PhylumApi { Ok(()) } + /// Get Aviary activity log. + pub async fn firewall_log( + &self, + org: Option<&str>, + group: &str, + filter: FirewallLogFilter<'_>, + ) -> Result> { + let user = match org { + Some(org) => Cow::Owned(format!("{org}/{group}")), + None => Cow::Borrowed(group), + }; + let url = endpoints::firewall_log(&self.config.connection.uri)?; + + let request = + self.client.get(url).basic_auth(user, Some(&self.access_token)).query(&filter); + + let response = request.send().await?; + let status_code = response.status(); + let body = response.text().await?; + + if !status_code.is_success() { + let err = ResponseError { details: body, code: status_code }; + return Err(err.into()); + } + + let log = serde_json::from_str(&body).map_err(|e| PhylumApiError::Other(e.into()))?; + + Ok(log) + } + /// 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 70e007eec..d147de160 100644 --- a/cli/src/app.rs +++ b/cli/src/app.rs @@ -603,6 +603,61 @@ pub fn add_subcommands(command: Command) -> Command { .subcommand( Command::new("unlink").about("Clear the configured default organization"), ), + ) + .subcommand( + Command::new("firewall") + .about("Manage the package firewall") + .arg_required_else_help(true) + .subcommand_required(true) + .subcommand( + 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)")]) + .args(&[ + Arg::new("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") + .value_parser([ + "npm", "rubygems", "pypi", "maven", "nuget", "cargo", + ]), + Arg::new("package") + .long("package") + .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") + .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), + ]), + ), ); #[cfg(feature = "extensions")] diff --git a/cli/src/bin/phylum.rs b/cli/src/bin/phylum.rs index eb18fa2ca..7333343ea 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, group, init, jobs, org, packages, parse, project, status, - CommandResult, ExitCode, + auth, 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; @@ -145,6 +145,9 @@ async fn handle_commands() -> CommandResult { "init" => init::handle_init(&Spinner::wrap(api).await?, sub_matches, config).await, "status" => status::handle_status(sub_matches).await, "org" => org::handle_org(&Spinner::wrap(api).await?, sub_matches, config).await, + "firewall" => { + firewall::handle_firewall(&Spinner::wrap(api).await?, sub_matches, config).await + }, #[cfg(feature = "selfmanage")] "uninstall" => uninstall::handle_uninstall(sub_matches), diff --git a/cli/src/commands/firewall.rs b/cli/src/commands/firewall.rs new file mode 100644 index 000000000..0e98e528a --- /dev/null +++ b/cli/src/commands/firewall.rs @@ -0,0 +1,73 @@ +//! Subcommand `phylum firewall`. + +use std::borrow::Cow; +use std::str::FromStr; + +use clap::ArgMatches; +use purl::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; + +/// Handle `phylum firewall` subcommand. +pub async fn handle_firewall( + api: &PhylumApi, + matches: &ArgMatches, + config: Config, +) -> CommandResult { + match matches.subcommand() { + Some(("log", matches)) => handle_log(api, matches, config).await, + _ => unreachable!("invalid clap configuration"), + } +} + +/// Handle `phylum firewall log` subcommand. +pub async fn handle_log(api: &PhylumApi, matches: &ArgMatches, config: Config) -> CommandResult { + let org = config.org(); + 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 before = matches.get_one::("before"); + let after = matches.get_one::("after"); + 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 { + Some(Ok(purl)) => { + let ecosystem = Cow::Owned(purl.package_type().to_string()); + (Some(ecosystem), 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), + }; + + // Construct the filter. + let filter = FirewallLogFilter { + ecosystem: ecosystem.as_ref().map(|e| e.as_str()), + namespace, + name, + version, + action: action.map(String::as_str), + before: before.map(String::as_str), + after: after.map(String::as_str), + limit: Some(*limit as i32), + }; + + let response = api.firewall_log(org, group, filter).await?; + + let pretty = !matches.get_flag("json"); + response.data.write_stdout(pretty); + + Ok(ExitCode::Ok) +} diff --git a/cli/src/commands/mod.rs b/cli/src/commands/mod.rs index ff4c7e684..99dc186f1 100644 --- a/cli/src/commands/mod.rs +++ b/cli/src/commands/mod.rs @@ -4,6 +4,7 @@ pub mod auth; #[cfg(feature = "extensions")] pub mod extensions; pub mod find_dependency_files; +pub mod firewall; pub mod group; pub mod init; pub mod jobs; diff --git a/cli/src/format.rs b/cli/src/format.rs index 7b20ed84d..c732f1174 100644 --- a/cli/src/format.rs +++ b/cli/src/format.rs @@ -4,7 +4,7 @@ use std::io::{self, Write}; use std::{cmp, str}; use chrono::{DateTime, Local, Utc}; -use console::style; +use console::{style, Color}; use phylum_types::types::group::{GroupMember, ListGroupMembersResponse}; use phylum_types::types::job::{AllJobsStatusResponse, JobDescriptor}; use phylum_types::types::package::{PackageStatus, PackageStatusExtended}; @@ -19,8 +19,9 @@ use crate::commands::group::ListGroupsEntry; use crate::commands::status::PhylumStatus; use crate::print::{self, table_format}; use crate::types::{ - GetProjectResponse, HistoryJob, Issue, OrgMember, OrgMembersResponse, OrgsResponse, Package, - PolicyEvaluationResponse, PolicyEvaluationResponseRaw, ProjectListEntry, RiskLevel, UserToken, + FirewallAction, FirewallLogResponse, GetProjectResponse, HistoryJob, Issue, OrgMember, + OrgMembersResponse, OrgsResponse, Package, PolicyEvaluationResponse, + PolicyEvaluationResponseRaw, ProjectListEntry, RiskLevel, UserToken, }; // Maximum length of email column. @@ -415,6 +416,31 @@ impl Format for OrgMembersResponse { } } +impl Format for Vec { + fn pretty(&self, writer: &mut W) { + fn color_action(action: FirewallAction) -> (String, Option) { + match action { + FirewallAction::Download => ("Download".into(), Some(Color::Blue)), + FirewallAction::AnalysisSuccess => ("Analysis Success".into(), Some(Color::Green)), + FirewallAction::AnalysisWarning => ("Analysis Warning".into(), Some(Color::Yellow)), + FirewallAction::AnalysisFailure => ("Analysis Failure".into(), Some(Color::Red)), + } + } + + let table = + format_table:: (String, Option), _>(self, &[ + ("Action", |log| color_action(log.action)), + ("Package", |log| { + let purl = + log.package.purl().map_or_else(|_| String::new(), |purl| purl.to_string()); + (purl, None) + }), + ("Timestamp", |log| (format_datetime_precise(log.timestamp), None)), + ]); + let _ = writeln!(writer, "{table}"); + } +} + #[cfg(feature = "vulnreach")] impl Format for Vulnerability { fn pretty(&self, writer: &mut W) { @@ -483,16 +509,38 @@ impl Scored for PackageStatusExtended { } } +/// Table cell formatting. +trait TableCell { + /// Get cell text and color. + /// + /// The text itself must not contain any escape sequences, to avoid issues + /// with width calculation. + fn content(&self, data: &T) -> (String, Option); +} + +impl TableCell for fn(&T) -> String { + fn content(&self, data: &T) -> (String, Option) { + (self(data), None) + } +} + +impl TableCell for fn(&T) -> (String, Option) { + fn content(&self, data: &T) -> (String, Option) { + self(data) + } +} + /// Format any slice into a table. fn format_table(data: &[T], columns: &[(&str, F)]) -> String where - F: Fn(&T) -> String, + F: TableCell, { // Whitespace between the columns. const COLUMN_SPACING: usize = 2; let mut header = String::new(); let mut rows = vec![String::new(); data.len()]; + let mut last_column_widths = vec![0; data.len()]; let mut last_column_width = 0; for column in columns { @@ -502,11 +550,23 @@ where header.push_str(column.0); for i in 0..data.len() { - let cell = column.1(&data[i]); - column_width = cmp::max(column_width, cell.width()); + let (text, color) = column.1.content(&data[i]); + let text_width = text.width(); + column_width = cmp::max(column_width, text_width); + + let formatted = match color { + Some(color) => style(text).fg(color).to_string(), + None => text, + }; - rows[i] = leftpad(&rows[i], last_column_width); - rows[i].push_str(&cell); + // Start next column after the widest element from the last one. + if let Some(required_padding) = last_column_width.checked_sub(last_column_widths[i]) { + rows[i].push_str(&str::repeat(" ", required_padding)); + last_column_widths[i] += required_padding; + } + + rows[i].push_str(&formatted); + last_column_widths[i] += text_width; } last_column_width += column_width + COLUMN_SPACING; @@ -559,3 +619,10 @@ fn format_datetime(timestamp: DateTime) -> String { local.format("%F %R").to_string() } + +/// Convert a UTC timestamp in the local timezone, including seconds. +fn format_datetime_precise(timestamp: DateTime) -> String { + let local: DateTime = timestamp.into(); + + local.format("%F %T.%f").to_string() +} diff --git a/cli/src/types.rs b/cli/src/types.rs index bc3f5ab89..d3ee7a93a 100644 --- a/cli/src/types.rs +++ b/cli/src/types.rs @@ -8,6 +8,7 @@ use phylum_types::types::package::{ PackageDescriptor, PackageDescriptorAndLockfile, PackageType, RiskDomain as PTRiskDomain, RiskLevel as PTRiskLevel, }; +use purl::{PackageError, Purl}; use serde::{Deserialize, Serialize}; #[derive(Debug, Serialize, Deserialize)] @@ -522,3 +523,61 @@ pub struct OrgGroupsResponse { pub struct ApiOrgGroup { pub name: String, } + +/// Aviary pagination system. +#[derive(PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Debug, Serialize, Deserialize)] +pub struct FirewallPaginated { + pub data: Vec, + pub last_index: I, +} + +/// Aviary GET /activity response. +#[derive(PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Debug, Serialize, Deserialize)] +pub struct FirewallLogResponse { + pub action: FirewallAction, + pub package: FirewallPackage, + pub timestamp: DateTime, + pub failure_cause: Option, +} + +/// Aviary log action. +#[derive(PartialEq, Eq, PartialOrd, Ord, Hash, Copy, Clone, Debug, Serialize, Deserialize)] +pub enum FirewallAction { + Download, + AnalysisSuccess, + AnalysisFailure, + AnalysisWarning, +} + +/// 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, +} + +impl FirewallPackage { + /// Get the PURL for this package. + pub fn purl(&self) -> Result { + let ecosystem = purl::PackageType::from_str(&self.ecosystem)?; + Purl::builder(ecosystem, &self.name) + .with_namespace(&self.namespace) + .with_version(&self.version) + .build() + } +} + +/// Aviary log filter query. +#[derive(Serialize, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Debug, Default)] +pub struct FirewallLogFilter<'a> { + pub ecosystem: Option<&'a str>, + pub namespace: Option<&'a str>, + pub name: Option<&'a str>, + pub version: Option<&'a str>, + pub action: Option<&'a str>, + pub before: Option<&'a str>, + pub after: Option<&'a str>, + pub limit: Option, +} diff --git a/doc_templates/phylum_firewall_log.md b/doc_templates/phylum_firewall_log.md new file mode 100644 index 000000000..571328124 --- /dev/null +++ b/doc_templates/phylum_firewall_log.md @@ -0,0 +1,16 @@ +{PH-HEADER} + +{PH-MARKDOWN} + +## Examples + +```sh +# Show logs for packages which failed analysis for the group `demo`. +$ phylum firewall log demo --action AnalysisFailure + +# Show logs which were created after 2024 for the group `demo`. +$ phylum firewall log demo --after 2024-01-01T00:00:0.0Z + +# Show logs for libc regardless of its version for the group `demo`. +$ phylum firewall log demo --package pkg:cargo/libc +``` diff --git a/docs/commands/phylum.md b/docs/commands/phylum.md index 1040404e3..f6eff34a9 100644 --- a/docs/commands/phylum.md +++ b/docs/commands/phylum.md @@ -40,6 +40,7 @@ Usage: phylum [OPTIONS] [COMMAND] * [phylum analyze](./phylum_analyze.md) * [phylum auth](./phylum_auth.md) * [phylum extension](./phylum_extension.md) +* [phylum firewall](./phylum_firewall.md) * [phylum group](./phylum_group.md) * [phylum history](./phylum_history.md) * [phylum init](./phylum_init.md) diff --git a/docs/commands/phylum_firewall.md b/docs/commands/phylum_firewall.md new file mode 100644 index 000000000..e0038f406 --- /dev/null +++ b/docs/commands/phylum_firewall.md @@ -0,0 +1,25 @@ +# phylum firewall + +Manage the package firewall + +```sh +Usage: phylum firewall [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 firewall log](./phylum_firewall_log.md) diff --git a/docs/commands/phylum_firewall_log.md b/docs/commands/phylum_firewall_log.md new file mode 100644 index 000000000..83e00e81d --- /dev/null +++ b/docs/commands/phylum_firewall_log.md @@ -0,0 +1,62 @@ +# phylum firewall log + +Show firewall activity log + +```sh +Usage: phylum firewall log [OPTIONS] +``` + +## Arguments + +`` +  Specify a group to use for analysis + +## 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` `` +  Only show logs matching this PURL + +`--action` `` +  Only show logs matching this log action +  Accepted values: `Download`, `AnalysisSuccess`, `AnalysisFailure`, `AnalysisWarning` + +`--before` `` +  Only show logs created before this timestamp (RFC3339 format) + +`--after` `` +  Only show logs created after this timestamp (RFC3339 format) + +`--limit` `` +  Maximum number of log entries to show + +`-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 + +## Examples + +```sh +# Show logs for packages which failed analysis for the group `demo`. +$ phylum firewall log demo --action AnalysisFailure + +# Show logs which were created after 2024 for the group `demo`. +$ phylum firewall log demo --after 2024-01-01T00:00:0.0Z + +# Show logs for libc regardless of its version for the group `demo`. +$ phylum firewall log demo --package pkg:cargo/libc +```