Skip to content

Commit

Permalink
Add phylum firewall log subcommand (#1551)
Browse files Browse the repository at this point in the history
This patch adds the new `phylum firewall log` to allow checking the
Aviary log from the CLI.

While the UI gives a more user-friendly overview, this should be
complementary by giving access to the data in a structure closer to the
original layout.

The CLI arguments allow applying most filters exposed by the API, but
combine the individual package components into a PURL to avoid an
excessive number of arguments.

Currently neither the `--json` output nor the pretty-printer have a way
to paginate through the logs. While this could be done either
automatically or by manually specifying the pagination offset, it seems
to me like the limit of 10_000 entries per page should be sufficient for
all current usecases.

Contrary to our existing timestamp columns, the timestamp in the output
table uses nanosecond precision. The additional precision seems
worthwhile in this case since this automatically groups versions
together if they were submitted for analysis as a batch.
  • Loading branch information
cd-work authored Dec 10, 2024
1 parent 715f00b commit 0341dd8
Show file tree
Hide file tree
Showing 15 changed files with 427 additions and 15 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
13 changes: 13 additions & 0 deletions cli/src/api/endpoints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,11 @@ pub fn org_groups_delete(
Ok(url)
}

/// Aviary activity endpoint.
pub fn firewall_log(api_uri: &str) -> Result<Url, BaseUriError> {
Ok(get_firewall_path(api_uri)?.join("activity")?)
}

/// GET /.well-known/openid-configuration
pub fn oidc_discovery(api_uri: &str) -> Result<Url, BaseUriError> {
Ok(get_api_path(api_uri)?.join(".well-known/openid-configuration")?)
Expand Down Expand Up @@ -242,6 +247,14 @@ fn get_locksmith_path(api_uri: &str) -> Result<Url, BaseUriError> {
Ok(parse_base_url(api_uri)?.join(LOCKSMITH_PATH)?)
}

fn get_firewall_path(api_uri: &str) -> Result<Url, BaseUriError> {
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::*;
Expand Down
44 changes: 39 additions & 5 deletions cli/src/api/mod.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;
Expand All @@ -41,6 +44,7 @@ pub struct PhylumApi {
roles: HashSet<RealmRole>,
config: Config,
client: Client,
access_token: AccessToken,
}

/// Phylum Api Error type
Expand Down Expand Up @@ -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<T: DeserializeOwned, U: IntoUrl>(&self, path: U) -> Result<T> {
Expand Down Expand Up @@ -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<FirewallPaginated<FirewallLogResponse>> {
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<Vec<Vulnerability>> {
Expand Down
55 changes: 55 additions & 0 deletions cli/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
7 changes: 5 additions & 2 deletions cli/src/bin/phylum.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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),
Expand Down
73 changes: 73 additions & 0 deletions cli/src/commands/firewall.rs
Original file line number Diff line number Diff line change
@@ -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::<String>("group").unwrap();

// Get log filter args.
let ecosystem = matches.get_one::<String>("ecosystem");
let purl = matches.get_one::<String>("package");
let action = matches.get_one::<String>("action");
let before = matches.get_one::<String>("before");
let after = matches.get_one::<String>("after");
let limit = matches.get_one::<i64>("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)
}
1 change: 1 addition & 0 deletions cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading

0 comments on commit 0341dd8

Please sign in to comment.