fluidattacks-core-git 0.39.0

Fluid Attacks Core Git
Documentation
use anyhow::{Context, Result};
use base64::Engine;
use std::time::Duration;
use tokio::process::Command;

use super::aws;
use super::types::{CredentialKind, Credentials, LsRemoteResult};
use crate::clone::https::{embed_credentials_in_url, embed_token_in_url};
use crate::clone::reason::hide;
use crate::clone::ssh::{normalize_ssh_url, setup_ssh_env};

const LS_REMOTE_TIMEOUT_SECS: u64 = 20;

/// The message `roots/validations.py` matches on to raise `GitTimeoutError`.
const LS_REMOTE_TIMEOUT_MESSAGE: &str = "git ls-remote time out";

struct AwsRole {
    arn: String,
    external_id: String,
    via: Option<String>,
}

/// What reaching a repo over https takes: the url to ask, the config that
/// carries a credential outside it, and the secrets to keep out of errors.
struct HttpsAccess {
    url: String,
    extra_config: Vec<String>,
    secrets: Vec<String>,
}

fn redirect_config(follow_redirects: bool) -> [String; 2] {
    [
        "-c".to_owned(),
        format!("http.followRedirects={follow_redirects}"),
    ]
}

async fn run_ls_remote(command: &mut Command, secrets: &[&str]) -> Result<LsRemoteResult> {
    let Ok(output) = tokio::time::timeout(
        Duration::from_secs(LS_REMOTE_TIMEOUT_SECS),
        command.kill_on_drop(true).output(),
    )
    .await
    else {
        return Ok(LsRemoteResult {
            commit: None,
            error: Some(LS_REMOTE_TIMEOUT_MESSAGE.to_owned()),
        });
    };

    Ok(parse_ls_remote_output(
        &output.context("running git ls-remote")?,
        secrets,
    ))
}

pub async fn ls_remote(
    url: &str,
    branch: &str,
    credentials: Option<&Credentials>,
    follow_redirects: bool,
) -> Result<LsRemoteResult> {
    match credentials.map(|c| &c.kind) {
        Some(CredentialKind::Ssh { key }) => ls_remote_ssh(url, branch, key).await,
        Some(CredentialKind::AwsRole {
            arn,
            external_id,
            via,
        }) => {
            let role = AwsRole {
                arn: arn.clone(),
                external_id: external_id.clone(),
                via: via.clone(),
            };
            ls_remote_codecommit(url, branch, &role, follow_redirects).await
        }
        kind => {
            let access = https_access(url, kind)?;
            ls_remote_https(&access, branch, follow_redirects).await
        }
    }
}

fn https_access(url: &str, kind: Option<&CredentialKind>) -> Result<HttpsAccess> {
    match kind {
        None => Ok(HttpsAccess {
            url: url.to_owned(),
            extra_config: Vec::new(),
            secrets: Vec::new(),
        }),
        Some(CredentialKind::Https { user, password }) => {
            let auth = embed_credentials_in_url(url, user, password)?;
            Ok(HttpsAccess {
                url: auth.url,
                extra_config: Vec::new(),
                secrets: auth.secrets,
            })
        }
        Some(CredentialKind::Token {
            token,
            is_pat: true,
            ..
        }) => {
            let encoded = base64::engine::general_purpose::STANDARD.encode(format!(":{token}"));
            Ok(HttpsAccess {
                url: url.to_owned(),
                extra_config: vec![
                    "-c".to_owned(),
                    format!("http.extraHeader=Authorization: Basic {encoded}"),
                ],
                secrets: vec![token.clone(), encoded],
            })
        }
        Some(CredentialKind::Token {
            token, oauth_type, ..
        }) => {
            let auth = embed_token_in_url(url, token, oauth_type)?;
            Ok(HttpsAccess {
                url: auth.url,
                extra_config: Vec::new(),
                secrets: auth.secrets,
            })
        }
        Some(CredentialKind::Ssh { .. } | CredentialKind::AwsRole { .. }) => {
            anyhow::bail!("expected an https credential")
        }
    }
}

async fn ls_remote_ssh(url: &str, branch: &str, key_b64: &str) -> Result<LsRemoteResult> {
    let key_data = base64::engine::general_purpose::STANDARD
        .decode(key_b64)
        .context("decoding SSH key")?;

    let key_file = tempfile::Builder::new()
        .prefix("melts-ssh-key-")
        .tempfile()
        .context("creating SSH key file")?;

    std::fs::write(key_file.path(), &key_data).context("writing SSH key")?;

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(key_file.path(), std::fs::Permissions::from_mode(0o400))
            .context("setting SSH key permissions")?;
    }

    let git_ssh_command = setup_ssh_env(key_file.path());
    let normalized_url = normalize_ssh_url(url);

    let args = [
        "ls-remote".to_owned(),
        "--".to_owned(),
        normalized_url,
        branch.to_owned(),
    ];

    let mut command = Command::new("git");
    command.args(&args).env("GIT_SSH_COMMAND", &git_ssh_command);
    run_ls_remote(&mut command, &[]).await
}

async fn ls_remote_https(
    access: &HttpsAccess,
    branch: &str,
    follow_redirects: bool,
) -> Result<LsRemoteResult> {
    let mut args = vec!["-c".to_owned(), "http.sslVerify=false".to_owned()];
    args.extend(redirect_config(follow_redirects));
    args.extend(access.extra_config.iter().cloned());
    args.extend([
        "ls-remote".to_owned(),
        "--".to_owned(),
        access.url.clone(),
        branch.to_owned(),
    ]);

    let secrets: Vec<&str> = access.secrets.iter().map(String::as_str).collect();
    let mut command = Command::new("git");
    command.args(&args);
    run_ls_remote(&mut command, &secrets).await
}

async fn ls_remote_codecommit(
    url: &str,
    branch: &str,
    role: &AwsRole,
    follow_redirects: bool,
) -> Result<LsRemoteResult> {
    let creds = aws::assume_role(&role.arn, &role.external_id, url, role.via.as_deref()).await?;

    let mut args = vec!["-c".to_owned(), "http.sslVerify=false".to_owned()];
    args.extend(redirect_config(follow_redirects));
    args.extend([
        "ls-remote".to_owned(),
        "--".to_owned(),
        url.to_owned(),
        branch.to_owned(),
    ]);

    let mut command = Command::new("git");
    command
        .args(&args)
        .env("AWS_ACCESS_KEY_ID", &creds.access_key)
        .env("AWS_SECRET_ACCESS_KEY", &creds.secret_key)
        .env("AWS_SESSION_TOKEN", &creds.session_token)
        .env("AWS_DEFAULT_REGION", &creds.region);
    run_ls_remote(&mut command, &[]).await
}

fn parse_ls_remote_output(output: &std::process::Output, secrets: &[&str]) -> LsRemoteResult {
    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        return LsRemoteResult {
            commit: None,
            error: Some(hide(&stderr, secrets)),
        };
    }

    let stdout = String::from_utf8_lossy(&output.stdout);
    let commit = stdout
        .lines()
        .next()
        .and_then(|line| line.split('\t').next())
        .unwrap_or_default()
        .trim()
        .to_owned();

    LsRemoteResult {
        commit: Some(commit),
        error: None,
    }
}

#[cfg(test)]
mod tests {
    use super::{https_access, parse_ls_remote_output, redirect_config};
    use crate::types::CredentialKind;

    fn output(code: i32, stdout: &str, stderr: &str) -> std::process::Output {
        use std::os::unix::process::ExitStatusExt;
        std::process::Output {
            status: std::process::ExitStatus::from_raw(code << 8),
            stdout: stdout.as_bytes().to_vec(),
            stderr: stderr.as_bytes().to_vec(),
        }
    }

    #[test]
    fn redirects_are_forbidden_explicitly_and_not_left_to_the_git_default() {
        assert_eq!(
            redirect_config(false),
            ["-c".to_owned(), "http.followRedirects=false".to_owned()]
        );
        assert_eq!(
            redirect_config(true),
            ["-c".to_owned(), "http.followRedirects=true".to_owned()]
        );
    }

    #[test]
    fn a_branch_missing_from_the_remote_is_an_empty_commit_and_not_a_missing_one() {
        let result = parse_ls_remote_output(&output(0, "", ""), &[]);
        assert_eq!(result.commit, Some(String::new()));
        assert!(result.error.is_none());
    }

    #[test]
    fn the_first_ref_is_the_commit() {
        let result = parse_ls_remote_output(
            &output(0, "abc123\trefs/heads/main\ndef456\trefs/heads/other\n", ""),
            &[],
        );
        assert_eq!(result.commit.as_deref(), Some("abc123"));
    }

    #[test]
    fn a_failure_reports_the_stderr_and_no_commit() {
        let result = parse_ls_remote_output(&output(128, "", "fatal: repository not found"), &[]);
        assert!(result.commit.is_none());
        assert_eq!(result.error.as_deref(), Some("fatal: repository not found"));
    }

    #[test]
    fn a_failure_hides_the_credential_as_the_url_encoded_it() {
        let kind = CredentialKind::Token {
            token: "ab=c/d".to_owned(),
            is_pat: false,
            oauth_type: String::new(),
        };
        let access = https_access("https://github.com/org/repo.git", Some(&kind)).unwrap();
        let secrets: Vec<&str> = access.secrets.iter().map(String::as_str).collect();
        let said = "fatal: could not read Password for 'https://ab%3Dc%2Fd@github.com': \
                    terminal prompts disabled\n";
        let result = parse_ls_remote_output(&output(128, "", said), &secrets);
        assert_eq!(
            result.error.as_deref(),
            Some(
                "fatal: could not read Password for 'https://[REDACTED]@github.com': \
                 terminal prompts disabled\n"
            )
        );
    }

    #[test]
    fn a_failure_hides_the_credential_as_git_encoded_it() {
        let kind = CredentialKind::Token {
            token: "Zm9v+YmFy".to_owned(),
            is_pat: false,
            oauth_type: String::new(),
        };
        let access = https_access("https://github.com/org/repo.git", Some(&kind)).unwrap();
        let secrets: Vec<&str> = access.secrets.iter().map(String::as_str).collect();
        let said = "fatal: could not read Password for 'https://Zm9v%2BYmFy@github.com': \
                    terminal prompts disabled\n";
        let result = parse_ls_remote_output(&output(128, "", said), &secrets);
        assert_eq!(
            result.error.as_deref(),
            Some(
                "fatal: could not read Password for 'https://[REDACTED]@github.com': \
                 terminal prompts disabled\n"
            )
        );
    }

    // with the token as the user there is no password, so git asks for one
    // and names the user in the prompt
    #[test]
    fn a_failure_hides_the_credential_git_repeats() {
        let said = "fatal: could not read Password for 'https://tok123@github.com': \
                    terminal prompts disabled\n";
        let result = parse_ls_remote_output(&output(128, "", said), &["tok123"]);
        assert_eq!(
            result.error.as_deref(),
            Some(
                "fatal: could not read Password for 'https://[REDACTED]@github.com': \
                 terminal prompts disabled\n"
            )
        );
    }
}