Skip to main content

fluidattacks_core_git/
aws.rs

1use anyhow::{bail, Context, Result};
2use regex::Regex;
3use uuid::Uuid;
4
5pub struct AwsTempCreds {
6    pub access_key: String,
7    pub secret_key: String,
8    pub session_token: String,
9    pub region: String,
10}
11
12struct Hop<'a> {
13    role_arn: &'a str,
14    external_id: Option<&'a str>,
15    policy: Option<&'static str>,
16}
17
18/// The intermediate session may only assume a role, so a chained caller never
19/// holds the intermediate role's other permissions. The resource stays open
20/// because a policy would match the client's ARN as typed, case included.
21const ONLY_ASSUME_ROLE: &str = r#"{
22    "Version": "2012-10-17",
23    "Statement": [{
24        "Effect": "Allow",
25        "Action": "sts:AssumeRole",
26        "Resource": "*"
27    }]
28}"#;
29
30/// Chained when the caller is not the principal the client's trust policy pins.
31const fn chain<'a>(
32    arn: &'a str,
33    external_id: &'a str,
34    via: Option<&'a str>,
35) -> (Hop<'a>, Option<Hop<'a>>) {
36    let client = Hop {
37        role_arn: arn,
38        external_id: Some(external_id),
39        policy: None,
40    };
41
42    match via {
43        Some(intermediate) => (
44            Hop {
45                role_arn: intermediate,
46                external_id: None,
47                policy: Some(ONLY_ASSUME_ROLE),
48            },
49            Some(client),
50        ),
51        None => (client, None),
52    }
53}
54
55pub async fn assume_role(
56    arn: &str,
57    external_id: &str,
58    url: &str,
59    via: Option<&str>,
60) -> Result<AwsTempCreds> {
61    let region = extract_region(url)?;
62    let (first, then) = chain(arn, external_id, via);
63
64    let mut creds = assume(&first, &region, None).await?;
65    if let Some(hop) = then {
66        creds = assume(&hop, &region, Some(&creds)).await?;
67    }
68
69    Ok(creds)
70}
71
72async fn assume(hop: &Hop<'_>, region: &str, from: Option<&AwsTempCreds>) -> Result<AwsTempCreds> {
73    let mut config = aws_config::defaults(aws_config::BehaviorVersion::latest())
74        .region(aws_config::Region::new(region.to_owned()));
75
76    if let Some(session) = from {
77        config = config.credentials_provider(aws_sdk_sts::config::Credentials::new(
78            session.access_key.clone(),
79            session.secret_key.clone(),
80            Some(session.session_token.clone()),
81            None,
82            "fluidattacks-core-chain",
83        ));
84    }
85
86    let sts = aws_sdk_sts::Client::new(&config.load().await);
87
88    let mut request = sts
89        .assume_role()
90        .role_arn(hop.role_arn)
91        .role_session_name(format!("session-{}", Uuid::new_v4()));
92
93    if let Some(external_id) = hop.external_id {
94        request = request.external_id(external_id);
95    }
96
97    if let Some(policy) = hop.policy {
98        request = request.policy(policy);
99    }
100
101    let resp = request
102        .send()
103        .await
104        .with_context(|| format!("STS AssumeRole failed for {}", hop.role_arn))?;
105
106    let creds = resp
107        .credentials()
108        .ok_or_else(|| anyhow::anyhow!("no credentials in AssumeRole response"))?;
109
110    let access_key = creds.access_key_id().to_owned();
111    let secret_key = creds.secret_access_key().to_owned();
112    let session_token = creds.session_token().to_owned();
113
114    if access_key.is_empty() || secret_key.is_empty() {
115        bail!("empty credentials from STS AssumeRole");
116    }
117
118    Ok(AwsTempCreds {
119        access_key,
120        secret_key,
121        session_token,
122        region: region.to_owned(),
123    })
124}
125
126fn extract_region(url: &str) -> Result<String> {
127    let re = Regex::new(r"codecommit::([a-z0-9-]+)://")?;
128    if let Some(caps) = re.captures(url) {
129        return Ok(caps[1].to_string());
130    }
131
132    // Try to find region in standard CodeCommit URLs
133    let re2 = Regex::new(r"git-codecommit\.([a-z0-9-]+)\.amazonaws\.com")?;
134    if let Some(caps) = re2.captures(url) {
135        return Ok(caps[1].to_string());
136    }
137
138    Ok("us-east-1".to_owned())
139}
140
141#[cfg(test)]
142mod tests {
143    use super::chain;
144
145    #[test]
146    fn one_call_takes_the_client_role_with_its_external_id() {
147        let (first, then) = chain("arn:aws:iam::1:role/client", "an-external-id", None);
148
149        assert_eq!(first.role_arn, "arn:aws:iam::1:role/client");
150        assert_eq!(first.external_id, Some("an-external-id"));
151        assert_eq!(first.policy, None);
152        assert!(then.is_none());
153    }
154
155    #[test]
156    fn a_chain_takes_the_intermediate_role_first_and_without_the_external_id() {
157        let (first, then) = chain(
158            "arn:aws:iam::1:role/client",
159            "an-external-id",
160            Some("arn:aws:iam::2:role/intermediate"),
161        );
162
163        assert_eq!(first.role_arn, "arn:aws:iam::2:role/intermediate");
164        assert_eq!(first.external_id, None);
165
166        let client = then.expect("the client role is still taken");
167        assert_eq!(client.role_arn, "arn:aws:iam::1:role/client");
168        assert_eq!(client.external_id, Some("an-external-id"));
169        assert_eq!(client.policy, None);
170    }
171
172    #[test]
173    fn a_chain_lets_the_intermediate_session_only_assume_a_role() {
174        let (first, _) = chain(
175            "arn:aws:iam::1:role/client",
176            "an-external-id",
177            Some("arn:aws:iam::2:role/intermediate"),
178        );
179
180        let policy: serde_json::Value =
181            serde_json::from_str(first.policy.expect("the intermediate session is narrowed"))
182                .expect("the policy is json");
183        assert_eq!(
184            policy.get("Statement"),
185            Some(&serde_json::json!([{
186                "Effect": "Allow",
187                "Action": "sts:AssumeRole",
188                "Resource": "*",
189            }]))
190        );
191    }
192}