use super::Invocation;
use super::redteam::{read, value};
use crate::clients::WALK_BUDGET;
use crate::error::{CliError, InterruptContext};
use crate::json::OValue;
use crate::output::Format;
use crate::redteam::views;
use crate::report::{self, ReadError};
use scm::airs::red_team::{
AdapterListOptions, AttackListOptions, CustomAttacksReportListOptions, RedTeamClient,
RedTeamScanListOptions, TargetListOptions,
};
use std::cell::RefCell;
use std::path::{Path, PathBuf};
fn total_items(answer: &OValue) -> Option<OValue> {
answer
.get("pagination")
.and_then(|pagination| pagination.get("total_items"))
.filter(|total| views::present(Some(total)))
.cloned()
}
pub async fn job_report(
invocation: &mut Invocation,
job_id: &str,
attacks: bool,
severity: Option<&str>,
limit: u32,
format: Format,
) -> Result<(), CliError> {
let connected = invocation.connect_red_team(None).await?;
let client = &connected.client;
let answer = read(invocation, client.scans().get(job_id, None)).await?;
let mut job = views::job(&value(&answer)?);
views::withhold(&mut job, &invocation.secrets);
let kind = views::text(&job, "jobType").unwrap_or_default().to_owned();
let (mut report, mut list) = match kind.as_str() {
"CUSTOM" => {
let answer = read(
invocation,
client.custom_attack_reports().get_report(job_id, None),
)
.await?;
let report = views::custom_report(&value(&answer)?);
let list = if attacks {
let mut options = CustomAttacksReportListOptions::default();
options.limit = Some(limit);
let answer = read(
invocation,
client
.custom_attack_reports()
.list_custom_attacks(job_id, &options, None),
)
.await?;
let answer = value(&answer)?;
Some((
attack_rows(&answer, views::custom_attack),
total_items(&answer),
))
} else {
None
};
(report, list)
}
"DYNAMIC" => {
let answer = read(
invocation,
client.reports().get_dynamic_report(job_id, None),
)
.await?;
(views::dynamic_report(&value(&answer)?), None)
}
_ => {
let answer = read(invocation, client.reports().get_static_report(job_id, None)).await?;
let report = views::static_report(&value(&answer)?);
let list = if attacks {
let mut options = AttackListOptions::default();
options.limit = Some(limit);
options.severity = severity.map(str::to_owned);
let answer = read(
invocation,
client.reports().list_attacks(job_id, &options, None),
)
.await?;
let answer = value(&answer)?;
Some((attack_rows(&answer, views::attack), total_items(&answer)))
} else {
None
};
(report, list)
}
};
views::withhold(&mut report, &invocation.secrets);
if let Some((rows, _)) = &mut list {
for row in rows.iter_mut() {
views::withhold(row, &invocation.secrets);
}
}
let out = &mut invocation.output;
match format {
Format::Json | Format::Table => {
let mut doc = vec![
("job".to_owned(), job.clone()),
("report".to_owned(), report.clone()),
];
if let Some((rows, total)) = &list {
let mut attacks = vec![("items".to_owned(), OValue::Array(rows.clone()))];
if let Some(total) = total {
attacks.push(("totalItems".to_owned(), total.clone()));
}
doc.push(("attacks".to_owned(), OValue::Object(attacks)));
}
let doc = OValue::Object(doc);
if format == Format::Json {
out.json(&doc)
} else {
views::detail_table(out, &doc);
Ok(())
}
}
Format::Pretty => {
views::banner(out);
views::scan_status_layout(out, &job);
match kind.as_str() {
"CUSTOM" => {
views::custom_report_layout(out, &report);
if let Some((rows, _)) = &list {
views::custom_attack_list_layout(out, rows);
}
}
"DYNAMIC" => views::dynamic_report_layout(out, &report),
_ => {
views::static_report_layout(out, &report);
if let Some((rows, total)) = &list {
let total = total.as_ref().and_then(|total| match total {
OValue::Number(number) => number.as_f64(),
_ => None,
});
let footnote = views::attack_footnote(severity, total, &report);
views::attack_list_layout(out, rows, footnote.as_deref());
}
}
}
Ok(())
}
}
}
fn attack_rows(answer: &OValue, normalise: fn(&OValue) -> OValue) -> Vec<OValue> {
answer
.get("data")
.and_then(OValue::as_array)
.unwrap_or_default()
.iter()
.map(normalise)
.collect()
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ReportDestination {
Stdout,
File(PathBuf),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EnvReportPlan {
pub destination: ReportDestination,
pub title: String,
pub max_pages: u32,
pub strict: bool,
}
pub fn default_name(now: std::time::SystemTime) -> String {
format!(
"airs-redteam-report-{}-{}.html",
crate::debug::iso_timestamp(now).replace(':', "-"),
&uuid::Uuid::new_v4().simple().to_string()[..8]
)
}
pub const GENERATION_FAILED: &str = "✗ Report generation failed. Verify Management API configuration and a writable, new output path. Existing files are never overwritten.";
fn status_path(path: &Path) -> String {
path.display()
.to_string()
.chars()
.map(|c| {
if report::is_control_or_format(c) {
'_'
} else {
c
}
})
.collect()
}
struct SdkReader<'a> {
client: &'a RedTeamClient,
budget: RefCell<Option<(&'static str, scm::RequestOptions)>>,
}
impl SdkReader<'_> {
fn options(&self, source: &'static str) -> Result<scm::RequestOptions, ReadError> {
let mut budget = self.budget.borrow_mut();
if let Some((name, options)) = budget.as_ref()
&& *name == source
{
return Ok(options.clone());
}
let options = scm::RequestOptions::with_timeout(WALK_BUDGET)
.and_then(|options| options.with_retries(0))
.map_err(|_| ReadError::Failed)?;
*budget = Some((source, options.clone()));
Ok(options)
}
async fn read<T, F, Fut>(&self, source: &'static str, mut call: F) -> Result<OValue, ReadError>
where
T: serde::Serialize,
F: FnMut(scm::RequestOptions) -> Fut,
Fut: std::future::Future<Output = scm::Result<T>>,
{
let options = self.options(source)?;
let answer = match call(options.clone()).await {
Err(error)
if error.kind() == scm::ErrorKind::Http
&& matches!(error.status(), Some(401 | 403))
&& error.remote_outcome() == scm::RemoteOutcome::Rejected =>
{
call(options).await
}
other => other,
};
match answer {
Ok(answer) => crate::json::to_ordered(&answer).map_err(|_| ReadError::Failed),
Err(error) if error.kind() == scm::ErrorKind::Cancelled => Err(ReadError::Cancelled),
Err(error) => Err(match error.status() {
Some(status) => ReadError::Status(status),
None => ReadError::Failed,
}),
}
}
}
impl report::Reader for SdkReader<'_> {
async fn overview(&self) -> Result<OValue, ReadError> {
self.read("overview", async |options| {
self.client.get_dashboard_overview(Some(&options)).await
})
.await
}
async fn statistics(&self) -> Result<OValue, ReadError> {
self.read("statistics", async |options| {
self.client
.get_scan_statistics(&Default::default(), Some(&options))
.await
})
.await
}
async fn targets(&self, skip: u64) -> Result<OValue, ReadError> {
let mut page = TargetListOptions::default();
page.skip = Some(skip);
page.limit = Some(report::PAGE_SIZE);
self.read("targets", async |options| {
self.client.targets().list(&page, Some(&options)).await
})
.await
}
async fn quota(&self) -> Result<OValue, ReadError> {
self.read("quota", async |options| {
self.client.get_quota_summary(Some(&options)).await
})
.await
}
async fn scans(&self, skip: u64) -> Result<OValue, ReadError> {
let mut page = RedTeamScanListOptions::default();
page.skip = Some(skip);
page.limit = Some(report::PAGE_SIZE);
self.read("scans", async |options| {
self.client.scans().list(&page, Some(&options)).await
})
.await
}
async fn channel_stats(&self) -> Result<OValue, ReadError> {
self.read("broker", async |options| {
self.client
.network_broker()
.get_channel_stats(Some(&options))
.await
})
.await
}
async fn adapters(&self, skip: u64) -> Result<OValue, ReadError> {
let mut page = AdapterListOptions::default();
page.skip = Some(skip);
page.limit = Some(report::PAGE_SIZE);
page.include_target_count = Some(true);
self.read("adapters", async |options| {
self.client.adapters().list(&page, Some(&options)).await
})
.await
}
}
fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |elapsed| {
i64::try_from(elapsed.as_millis()).unwrap_or(i64::MAX)
})
}
pub async fn environment_report(
invocation: &mut Invocation,
plan: &EnvReportPlan,
) -> Result<(), CliError> {
match generate(invocation, plan).await {
Ok(0) => Ok(()),
Ok(code) => {
let _ = invocation.output.commit();
Err(CliError::Reported(code))
}
Err(CliError::Interrupted(context)) => Err(CliError::Interrupted(context)),
Err(_) => {
invocation.output.discard();
let line = invocation
.output
.paint_err(crate::render::style::Style::Red, GENERATION_FAILED);
invocation.output.write_stderr(&format!(" {line}\n"));
Err(CliError::Reported(crate::error::EXIT_OPERATIONAL))
}
}
}
const NO_REPORT: &str = "no report was written";
async fn generate(invocation: &mut Invocation, plan: &EnvReportPlan) -> Result<i32, CliError> {
let failed = || CliError::operational("report generation failed");
let path = match &plan.destination {
ReportDestination::Stdout => None,
ReportDestination::File(path) => {
let path = std::path::absolute(path).map_err(|_| failed())?;
match std::fs::symlink_metadata(&path) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
_ => return Err(failed()),
}
Some(path)
}
};
let prepared = invocation
.red_team_unauthenticated()
.map_err(|error| match error {
CliError::Interrupted(_) => {
CliError::Interrupted(InterruptContext::Context(NO_REPORT.to_owned()))
}
other => other,
})?;
invocation
.output
.status("Collecting Red Team statistics and current configuration (read-only)...");
let reader = SdkReader {
client: prepared.client(),
budget: RefCell::new(None),
};
let options = report::Options {
title: &plan.title,
max_pages: plan.max_pages,
now: &now_ms,
};
let collected = report::collect(&reader, &options)
.await
.map_err(|_| CliError::Interrupted(InterruptContext::Context(NO_REPORT.to_owned())))?;
if invocation.output.pending_signal().is_some() {
return Err(CliError::Interrupted(InterruptContext::Context(
NO_REPORT.to_owned(),
)));
}
let html = report::html::render(&collected);
match &path {
Some(path) => {
let published = crate::files::publish_new(path, html.as_bytes(), "--output-file")
.map_err(|_| failed())?;
let shown = status_path(path);
invocation.interrupt = Some(InterruptContext::Context(format!(
"the report was saved in {shown}"
)));
invocation.committed = false;
if published == crate::files::Published::DirectoryNotSynced {
invocation
.output
.warning(&crate::store::atomic::dir_sync_warning(path));
}
invocation
.output
.status(&format!("Report → {shown} (mode 0600)"));
}
None => {
invocation.output.data(&html);
if invocation.output.commit().is_err() {
if invocation.output.pending_signal().is_some() {
return Err(CliError::Interrupted(InterruptContext::OutputIncomplete));
}
return Err(failed());
}
}
}
let incomplete = collected.incomplete();
if incomplete > 0 {
invocation.output.status(&format!(
"{incomplete} source(s) incomplete; review the evidence section."
));
}
Ok(
if collected.health == report::Health::Unknown || (plan.strict && incomplete > 0) {
crate::error::EXIT_OPERATIONAL
} else {
0
},
)
}