airs-cli 0.6.0

Native Rust CLI for Prisma AIRS (executable airs-cli-rs until 1.0), built on the scm-sdk crate
Documentation
//! Shared red team rules (DESIGN ยง15.3): the argument checks with the TypeScript texts,
//! the number and filter rules, and the list flags. Every check here is pure and runs
//! during planning, before the tenant is read (D141).

pub mod args;
pub mod backup;
pub mod secrets;
pub mod views;
pub mod walk;

use crate::error::CliError;
use crate::pagination;

/// `2^53 - 1`, the largest `--offset` (a JavaScript safe integer).
const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
/// The SDK filter bound (bytes).
const FILTER_LIMIT: usize = 4096;
/// The SDK bound on repeated `status` values of `channels list`.
pub const MAX_STATUS_VALUES: usize = 252;

/// `AISEC_USER_REQUEST_PAYLOAD_ERROR:Invalid <field>: <value>` (the TypeScript SDK's
/// `assertUuid` text) unless `value` has the UUID shape.
pub fn check_uuid(field: &str, value: &str) -> Result<(), CliError> {
    if pagination::is_uuid(value) {
        Ok(())
    } else {
        Err(CliError::usage(format!(
            "AISEC_USER_REQUEST_PAYLOAD_ERROR:Invalid {field}: {value}"
        )))
    }
}

/// The SDK path-segment rule (`rs:config.rs:121-151`): non-empty, at most 4,096 bytes,
/// no `\`, no control character and no `.`/`..` part, also once percent-decoded (up to
/// eight times). A valid value is sent as one encoded segment. Failure:
/// `Invalid <field>: <value>`.
pub fn check_segment(field: &str, value: &str) -> Result<(), CliError> {
    if segment_ok(value) {
        Ok(())
    } else {
        Err(CliError::usage(format!("Invalid {field}: {value}")))
    }
}

fn segment_ok(value: &str) -> bool {
    if value.is_empty() || value.len() > FILTER_LIMIT || value.chars().any(char::is_control) {
        return false;
    }
    let hex = |byte: u8| {
        (byte as char)
            .to_digit(16)
            .and_then(|d| u8::try_from(d).ok())
    };
    let mut decoded = value.as_bytes().to_vec();
    for _ in 0..=8 {
        if decoded.iter().any(|b| b.is_ascii_control() || *b == b'\\')
            || decoded
                .split(|b| *b == b'/')
                .any(|part| part == b"." || part == b"..")
        {
            return false;
        }
        let mut next = Vec::with_capacity(decoded.len());
        let mut index = 0;
        while index < decoded.len() {
            if decoded[index] == b'%'
                && index + 2 < decoded.len()
                && let (Some(high), Some(low)) = (hex(decoded[index + 1]), hex(decoded[index + 2]))
            {
                next.push(high * 16 + low);
                index += 3;
            } else {
                next.push(decoded[index]);
                index += 1;
            }
        }
        if next == decoded {
            return true;
        }
        decoded = next;
    }
    false
}

/// A filter value (`--status`, `--type`, `--target`, `--search`, โ€ฆ): at most 4,096 bytes
/// and no control character (the SDK `filter` rule), else
/// `<flag> must be at most 4096 bytes and contain no control characters`.
pub fn check_filter(flag: &str, value: &str) -> Result<(), CliError> {
    if value.len() <= FILTER_LIMIT && !value.chars().any(char::is_control) {
        Ok(())
    } else {
        Err(CliError::usage(format!(
            "{flag} must be at most {FILTER_LIMIT} bytes and contain no control characters"
        )))
    }
}

/// `--serial-numbers` names at least one serial number (D158): an empty value, or one
/// made only of commas and whitespace, is refused rather than sent as an empty list. A
/// list that names one is sent as given (TS).
pub fn check_serial_numbers(value: &str) -> Result<(), CliError> {
    if value.split(',').any(|serial| !serial.trim().is_empty()) {
        Ok(())
    } else {
        Err(CliError::usage(
            "--serial-numbers needs at least one serial number",
        ))
    }
}

/// An optional filter value, checked.
pub fn filter(flag: &str, value: Option<&str>) -> Result<Option<String>, CliError> {
    match value {
        Some(value) => {
            check_filter(flag, value)?;
            Ok(Some(value.to_owned()))
        }
        None => Ok(None),
    }
}

/// A query filter value, checked: an empty value sends no parameter (TypeScript skips a
/// falsy filter, so `--target ""` lists every scan rather than none).
pub fn query_filter(flag: &str, value: Option<&str>) -> Result<Option<String>, CliError> {
    Ok(filter(flag, value)?.filter(|value| !value.is_empty()))
}

/// Decimal digits only, as a `u64`.
fn decimal(value: &str) -> Option<u64> {
    if value.is_empty() || !value.bytes().all(|b| b.is_ascii_digit()) {
        return None;
    }
    value.parse::<u64>().ok()
}

/// How a command words a bad `--limit`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LimitText {
    /// `--limit must be a positive integer` (the ยง7.1 text).
    Contract,
    /// `<flag>: expected a positive integer, got "<value>"` (TypeScript
    /// `parsePositiveInt`).
    Typescript,
}

/// A positive integer from 1 to 4,294,967,295.
pub fn positive(flag: &str, value: &str, text: LimitText) -> Result<u32, CliError> {
    decimal(value)
        .filter(|n| *n >= 1)
        .and_then(|n| u32::try_from(n).ok())
        .ok_or_else(|| match text {
            LimitText::Contract => CliError::usage(format!("{flag} must be a positive integer")),
            LimitText::Typescript => CliError::usage(format!(
                "{flag}: expected a positive integer, got \"{value}\""
            )),
        })
}

/// `--offset`: 0 to 2^53 โˆ’ 1.
pub fn offset(value: &str) -> Result<u64, CliError> {
    decimal(value)
        .filter(|n| *n <= MAX_SAFE_INTEGER)
        .ok_or_else(|| CliError::usage("--offset must be a non-negative integer"))
}

/// `--max` (ยง7.1): a non-negative safe integer; `0` means no item cap.
pub fn max(value: &str) -> Result<u64, CliError> {
    decimal(value)
        .filter(|n| *n <= MAX_SAFE_INTEGER)
        .ok_or_else(|| CliError::usage("--max must be a non-negative integer"))
}

/// The validated list flags of a red team list (ยง15.3 "Numbers" and "Lists and walks").
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ListParams {
    /// `--limit` when given.
    pub limit: Option<u32>,
    /// `--offset` when given.
    pub offset: Option<u64>,
    pub all: bool,
    /// `--max` (default 10,000; `0`: no item cap).
    pub max: u64,
}

impl ListParams {
    /// A list without flags.
    #[cfg(test)]
    pub fn none() -> Self {
        Self {
            limit: None,
            offset: None,
            all: false,
            max: pagination::DEFAULT_MAX,
        }
    }
}

/// Validate `--limit`, `--offset`, `--max` and `--all` with `--offset`, in that order.
pub fn list_params(
    limit: Option<&str>,
    offset_value: Option<&str>,
    all: bool,
    max_value: Option<&str>,
    text: LimitText,
) -> Result<ListParams, CliError> {
    let limit = limit
        .map(|value| positive("--limit", value, text))
        .transpose()?;
    let offset = offset_value.map(offset).transpose()?;
    let max = max_value
        .map(max)
        .transpose()?
        .unwrap_or(pagination::DEFAULT_MAX);
    if all && offset_value.is_some() {
        return Err(CliError::usage("--all cannot be combined with --offset"));
    }
    Ok(ListParams {
        limit,
        offset,
        all,
        max,
    })
}

/// `--status` of `channels list`: one or more non-empty filter values, at most 252.
pub fn statuses(values: &[String]) -> Result<Vec<String>, CliError> {
    if values.len() > MAX_STATUS_VALUES {
        return Err(CliError::usage(format!(
            "--status accepts at most {MAX_STATUS_VALUES} values"
        )));
    }
    for value in values {
        if value.is_empty() {
            return Err(CliError::usage("--status values must not be empty"));
        }
        check_filter("--status", value)?;
    }
    Ok(values.to_vec())
}

/// The red team banner (`renderRedteamHeader`).
pub const BANNER_TITLE: &str = "Prisma AIRS โ€” AI Red Team";
pub const BANNER_SUBTITLE: &str = "Adversarial scan operations";

#[cfg(test)]
mod tests {
    use super::*;

    fn usage(result: Result<impl std::fmt::Debug, CliError>) -> String {
        match result {
            Err(CliError::Usage(message)) => message,
            other => panic!("expected a usage error, got {other:?}"),
        }
    }

    #[test]
    fn uuid_checks_use_the_typescript_text() {
        assert!(check_uuid("job id", "00000000-0000-4000-8000-000000000101").is_ok());
        assert_eq!(
            usage(check_uuid("job id", "not-a-uuid")),
            "AISEC_USER_REQUEST_PAYLOAD_ERROR:Invalid job id: not-a-uuid"
        );
        assert_eq!(
            usage(check_uuid("target uuid", "")),
            "AISEC_USER_REQUEST_PAYLOAD_ERROR:Invalid target uuid: "
        );
    }

    #[test]
    fn serial_number_lists_name_at_least_one() {
        for good in ["SN1", "SN1,SN2", " SN1 ", ",SN1,", "SN1,,SN2"] {
            assert!(check_serial_numbers(good).is_ok(), "{good:?}");
        }
        for empty in ["", " ", ",", " , ,", ",,,", "\u{a0}"] {
            assert_eq!(
                usage(check_serial_numbers(empty)),
                "--serial-numbers needs at least one serial number",
                "{empty:?}"
            );
        }
    }

    #[test]
    fn segments_refuse_traversal_at_every_decoding_depth() {
        for good in ["ten-1", "a/b", "a b", "100%", "%zz", "x.y", ".hidden"] {
            assert!(segment_ok(good), "{good}");
        }
        for bad in [
            "",
            ".",
            "..",
            "../registry-credentials",
            "a/..",
            "a\\b",
            "%2e%2e",
            "%252e%252e",
            "a%2F..",
            "%5c",
            "%00",
            "tab\there",
        ] {
            assert!(!segment_ok(bad), "{bad:?}");
        }
        assert!(!segment_ok(&"x".repeat(4097)));
        assert_eq!(
            usage(check_segment("tenant id", "../x")),
            "Invalid tenant id: ../x"
        );
    }

    #[test]
    fn filters_numbers_and_list_flags() {
        assert!(check_filter("--status", "RUNNING").is_ok());
        assert_eq!(
            usage(check_filter("--search", "a\nb")),
            "--search must be at most 4096 bytes and contain no control characters"
        );
        assert!(check_filter("--search", &"x".repeat(4096)).is_ok());
        assert!(check_filter("--search", &"x".repeat(4097)).is_err());
        for bad in ["0", "-1", "abc", "1.5", "2.5x", "", "4294967296"] {
            assert_eq!(
                usage(positive("--limit", bad, LimitText::Contract)),
                "--limit must be a positive integer",
                "{bad}"
            );
            assert_eq!(
                usage(positive("--depth", bad, LimitText::Typescript)),
                format!("--depth: expected a positive integer, got \"{bad}\""),
            );
        }
        assert_eq!(
            positive("--limit", "4294967295", LimitText::Contract).unwrap(),
            u32::MAX
        );
        assert_eq!(offset("9007199254740991").unwrap(), MAX_SAFE_INTEGER);
        assert!(offset("9007199254740992").is_err());
        assert_eq!(
            usage(list_params(
                None,
                Some("0"),
                true,
                None,
                LimitText::Contract
            )),
            "--all cannot be combined with --offset"
        );
        assert_eq!(
            usage(list_params(
                Some("x"),
                Some("y"),
                true,
                Some("z"),
                LimitText::Contract
            )),
            "--limit must be a positive integer"
        );
        assert_eq!(
            usage(list_params(
                None,
                Some("y"),
                true,
                Some("z"),
                LimitText::Contract
            )),
            "--offset must be a non-negative integer"
        );
        assert_eq!(
            usage(list_params(
                None,
                None,
                false,
                Some("z"),
                LimitText::Contract
            )),
            "--max must be a non-negative integer"
        );
        let params = list_params(Some("5"), None, true, Some("0"), LimitText::Contract).unwrap();
        assert_eq!(
            params,
            ListParams {
                limit: Some(5),
                offset: None,
                all: true,
                max: 0
            }
        );
        assert_eq!(
            usage(statuses(&["ONLINE".into(), String::new()])),
            "--status values must not be empty"
        );
        assert_eq!(
            usage(statuses(&vec!["A".to_owned(); 253])),
            "--status accepts at most 252 values"
        );
        assert_eq!(statuses(&vec!["A".to_owned(); 252]).unwrap().len(), 252);
    }
}