fluidattacks-core-git 0.63.0

Fluid Attacks Core Git
Documentation
use aws_sdk_sts::error::{DisplayErrorContext, ProvideErrorMetadata, SdkError};
use aws_sdk_sts::operation::assume_role::{AssumeRoleError, AssumeRoleOutput};
use regex::Regex;
use uuid::Uuid;

use crate::errors::StsError;

pub struct AwsTempCreds {
    pub access_key: String,
    pub secret_key: String,
    pub session_token: String,
    pub region: String,
}

struct Hop<'a> {
    role_arn: &'a str,
    external_id: Option<&'a str>,
    policy: Option<&'static str>,
}

/// The intermediate session may only assume a role, so a chained caller never
/// holds the intermediate role's other permissions. The resource stays open
/// because a policy would match the client's ARN as typed, case included.
const ONLY_ASSUME_ROLE: &str = r#"{
    "Version": "2012-10-17",
    "Statement": [{
        "Effect": "Allow",
        "Action": "sts:AssumeRole",
        "Resource": "*"
    }]
}"#;

/// Chained when the caller is not the principal the client's trust policy pins.
const fn chain<'a>(
    arn: &'a str,
    external_id: &'a str,
    via: Option<&'a str>,
) -> (Hop<'a>, Option<Hop<'a>>) {
    let client = Hop {
        role_arn: arn,
        external_id: Some(external_id),
        policy: None,
    };

    match via {
        Some(intermediate) => (
            Hop {
                role_arn: intermediate,
                external_id: None,
                policy: Some(ONLY_ASSUME_ROLE),
            },
            Some(client),
        ),
        None => (client, None),
    }
}

pub async fn assume_role(
    arn: &str,
    external_id: &str,
    url: &str,
    via: Option<&str>,
) -> Result<AwsTempCreds, StsError> {
    let region = extract_region(url);
    let (first, then) = chain(arn, external_id, via);

    let mut creds = assume(&first, &region, None).await?;
    if let Some(hop) = then {
        creds = assume(&hop, &region, Some(&creds)).await?;
    }

    Ok(creds)
}

async fn assume(
    hop: &Hop<'_>,
    region: &str,
    from: Option<&AwsTempCreds>,
) -> Result<AwsTempCreds, StsError> {
    let mut config = aws_config::defaults(aws_config::BehaviorVersion::latest())
        .region(aws_config::Region::new(region.to_owned()));

    if let Some(session) = from {
        config = config.credentials_provider(aws_sdk_sts::config::Credentials::new(
            session.access_key.clone(),
            session.secret_key.clone(),
            Some(session.session_token.clone()),
            None,
            "fluidattacks-core-chain",
        ));
    }

    let sts = aws_sdk_sts::Client::new(&config.load().await);

    let mut request = sts
        .assume_role()
        .role_arn(hop.role_arn)
        .role_session_name(format!("session-{}", Uuid::new_v4()));

    if let Some(external_id) = hop.external_id {
        request = request.external_id(external_id);
    }

    if let Some(policy) = hop.policy {
        request = request.policy(policy);
    }

    let resp = request
        .send()
        .await
        .map_err(|error| refusal(hop.role_arn, error))?;

    session(hop.role_arn, &resp, region)
}

// botocore says "Unknown" for a code or message STS left out.
fn refusal<R: std::fmt::Debug>(arn: &str, error: SdkError<AssumeRoleError, R>) -> StsError {
    match error {
        SdkError::ServiceError(refused) => StsError::Refused {
            arn: arn.to_owned(),
            code: refused.err().code().unwrap_or("Unknown").to_owned(),
            message: refused.err().message().unwrap_or("Unknown").to_owned(),
        },
        unanswered => StsError::Unreachable {
            arn: arn.to_owned(),
            reason: DisplayErrorContext(&unanswered).to_string(),
        },
    }
}

fn session(arn: &str, resp: &AssumeRoleOutput, region: &str) -> Result<AwsTempCreds, StsError> {
    let no_credentials = || StsError::NoCredentials {
        arn: arn.to_owned(),
    };
    let creds = resp.credentials().ok_or_else(no_credentials)?;

    let access_key = creds.access_key_id().to_owned();
    let secret_key = creds.secret_access_key().to_owned();
    let session_token = creds.session_token().to_owned();

    if access_key.is_empty() || secret_key.is_empty() {
        return Err(no_credentials());
    }

    Ok(AwsTempCreds {
        access_key,
        secret_key,
        session_token,
        region: region.to_owned(),
    })
}

fn extract_region(url: &str) -> String {
    [
        r"codecommit::([a-z0-9-]+)://",
        r"git-codecommit\.([a-z0-9-]+)\.amazonaws\.com",
    ]
    .into_iter()
    .find_map(|pattern| {
        let region = Regex::new(pattern).ok()?.captures(url)?.get(1)?;
        Some(region.as_str().to_owned())
    })
    .unwrap_or_else(|| "us-east-1".to_owned())
}

#[cfg(test)]
mod tests {
    use aws_sdk_sts::error::{ErrorMetadata, SdkError};
    use aws_sdk_sts::operation::assume_role::{AssumeRoleError, AssumeRoleOutput};
    use aws_sdk_sts::primitives::DateTime;
    use aws_sdk_sts::types::Credentials;

    use super::{chain, extract_region, refusal, session};
    use crate::errors::StsError;

    const ROLE: &str = "arn:aws:iam::1:role/client";

    #[test]
    fn a_refusal_carries_the_code_and_message_sts_answered() {
        let refused = AssumeRoleError::generic(
            ErrorMetadata::builder()
                .code("AccessDenied")
                .message("not authorized to perform sts:AssumeRole")
                .build(),
        );

        let error = refusal(ROLE, SdkError::service_error(refused, ()));

        assert_eq!(
            error.to_string(),
            "STS refused the role arn:aws:iam::1:role/client: AccessDenied: not authorized to perform sts:AssumeRole"
        );
    }

    #[test]
    fn an_sts_that_never_answered_is_not_a_refusal() {
        let error = refusal(
            ROLE,
            SdkError::<AssumeRoleError, ()>::timeout_error("timed out"),
        );

        assert!(matches!(error, StsError::Unreachable { .. }), "{error:?}");
    }

    #[test]
    fn an_answer_without_a_usable_key_is_no_session() {
        let keys = |access: &str, secret: &str| {
            Credentials::builder()
                .access_key_id(access)
                .secret_access_key(secret)
                .session_token("a-token")
                .expiration(DateTime::from_secs(0))
                .build()
                .unwrap()
        };

        for answer in [
            AssumeRoleOutput::builder().build(),
            AssumeRoleOutput::builder()
                .credentials(keys("", "a-secret-key"))
                .build(),
            AssumeRoleOutput::builder()
                .credentials(keys("an-access-key", ""))
                .build(),
        ] {
            let outcome = session(ROLE, &answer, "us-east-1");

            assert!(
                matches!(outcome, Err(StsError::NoCredentials { .. })),
                "{answer:?}"
            );
        }
    }

    #[test]
    fn the_region_comes_from_the_url_or_falls_back_to_us_east_1() {
        for (url, region) in [
            ("codecommit::eu-west-1://a-repo", "eu-west-1"),
            (
                "https://git-codecommit.sa-east-1.amazonaws.com/v1/repos/a-repo",
                "sa-east-1",
            ),
            ("codecommit://a-repo", "us-east-1"),
        ] {
            assert_eq!(extract_region(url), region);
        }
    }

    #[test]
    fn one_call_takes_the_client_role_with_its_external_id() {
        let (first, then) = chain("arn:aws:iam::1:role/client", "an-external-id", None);

        assert_eq!(first.role_arn, "arn:aws:iam::1:role/client");
        assert_eq!(first.external_id, Some("an-external-id"));
        assert_eq!(first.policy, None);
        assert!(then.is_none());
    }

    #[test]
    fn a_chain_takes_the_intermediate_role_first_and_without_the_external_id() {
        let (first, then) = chain(
            "arn:aws:iam::1:role/client",
            "an-external-id",
            Some("arn:aws:iam::2:role/intermediate"),
        );

        assert_eq!(first.role_arn, "arn:aws:iam::2:role/intermediate");
        assert_eq!(first.external_id, None);

        let client = then.expect("the client role is still taken");
        assert_eq!(client.role_arn, "arn:aws:iam::1:role/client");
        assert_eq!(client.external_id, Some("an-external-id"));
        assert_eq!(client.policy, None);
    }

    #[test]
    fn a_chain_lets_the_intermediate_session_only_assume_a_role() {
        let (first, _) = chain(
            "arn:aws:iam::1:role/client",
            "an-external-id",
            Some("arn:aws:iam::2:role/intermediate"),
        );

        let policy: serde_json::Value =
            serde_json::from_str(first.policy.expect("the intermediate session is narrowed"))
                .expect("the policy is json");
        assert_eq!(
            policy.get("Statement"),
            Some(&serde_json::json!([{
                "Effect": "Allow",
                "Action": "sts:AssumeRole",
                "Resource": "*",
            }]))
        );
    }
}