use std::collections::BTreeSet; use std::path::Path; use anyhow::{Context, Result, anyhow}; use aws_config::{BehaviorVersion, SdkConfig}; use aws_credential_types::Credentials; use aws_sdk_dynamodb::Client as DynamoClient; use aws_sdk_ec2::Client as Ec2Client; use aws_sdk_ecr::Client as EcrClient; use aws_sdk_iam::{Client as IamClient, error::SdkError}; use aws_sdk_kms::Client as KmsClient; use aws_sdk_lambda::Client as LambdaClient; use aws_sdk_rds::Client as RdsClient; use aws_sdk_s3::Client as S3Client; use aws_sdk_secretsmanager::Client as SecretsManagerClient; use aws_sdk_sns::Client as SnsClient; use aws_sdk_sqs::Client as SqsClient; use aws_sdk_ssm::Client as SsmClient; use aws_sdk_sts::Client as StsClient; use percent_encoding::percent_decode_str; use serde_json::Value; use tracing::warn; use crate::cli::commands::access_map::AccessMapArgs; use super::{ AccessMapResult, AccessSummary, AccessTokenDetails, PermissionSummary, ResourceExposure, RoleBinding, Severity, build_default_account_resource, build_recommendations, }; pub async fn map_access(args: &AccessMapArgs) -> Result { let config = load_config_from_path(args.credential_path.as_deref()).await?; map_access_with_config(config).await } fn permissions_for_prefix(summary: &PermissionSummary, prefix: &str) -> Vec { let mut matches = BTreeSet::new(); for perm in summary .admin .iter() .chain(&summary.privilege_escalation) .chain(&summary.risky) .chain(&summary.read_only) { if perm == "*" || perm.starts_with(prefix) { matches.insert(perm.clone()); } } matches.into_iter().collect() } pub async fn map_access_with_credentials( access_key: &str, secret_key: &str, session_token: Option<&str>, ) -> Result { let credentials = match session_token { Some(token) => { Credentials::new(access_key, secret_key, Some(token.to_string()), None, "access_map") } None => Credentials::new(access_key, secret_key, None, None, "access_map"), }; let config = load_config(Some(credentials)).await?; map_access_with_config(config).await } async fn map_access_with_config(config: SdkConfig) -> Result { let sts = StsClient::new(&config); let iam = IamClient::new(&config); let caller = sts.get_caller_identity().send().await.context("Failed to call sts:GetCallerIdentity")?; let arn = caller .arn() .ok_or_else(|| anyhow!("AWS GetCallerIdentity response missing ARN"))? .to_string(); let account_id = caller.account().map(|s| s.to_string()); let identity = AccessSummary { id: arn.clone(), access_type: classify_identity(&arn).into(), project: None, tenant: None, account_id: account_id.clone(), }; let mut roles = derive_roles_from_arn(&arn); let mut risk_notes = Vec::new(); let permissions = expand_permissions(&iam, &arn, &mut roles, &mut risk_notes).await.unwrap_or_else(|err| { warn!("AWS access-map: failed to enumerate IAM permissions: {err}"); risk_notes.push(format!("IAM enumeration failed: {err}")); PermissionSummary::default() }); let mut resources = enumerate_resources(&config, &permissions, account_id.as_deref(), &mut risk_notes) .await .unwrap_or_else(|err| { warn!("AWS access-map: resource enumeration failed: {err}"); risk_notes.push(format!("AWS enumeration failed: {err}")); Vec::new() }); let severity = derive_severity(&permissions, !resources.is_empty()); if roles.is_empty() { roles.push(RoleBinding { name: identity.access_type.clone(), source: "sts".into(), permissions: Vec::new(), }); } if resources.is_empty() { resources.push(build_default_account_resource(account_id.as_deref(), severity)); } if arn.contains(":assumed-role/") { risk_notes.push( "Credential represents an assumed role session; review the role trust policy and session duration".into(), ); } if permissions.admin.is_empty() && permissions.privilege_escalation.is_empty() && permissions.risky.is_empty() && permissions.read_only.is_empty() { risk_notes.push("IAM permissions could not be enumerated for this identity.".into()); } let recommendations = build_recommendations(severity); Ok(AccessMapResult { cloud: "aws".into(), identity, roles, permissions, resources, severity, recommendations, risk_notes, token_details: Some(AccessTokenDetails { name: account_id.clone(), username: None, account_type: None, company: None, location: None, email: None, url: None, token_type: Some("access_key".into()), created_at: None, last_used_at: None, expires_at: None, user_id: Some(arn.clone()), scopes: Vec::new(), }), provider_metadata: None, fingerprint: None, }) } fn classify_identity(arn: &str) -> &'static str { if arn.contains(":assumed-role/") { "assumed_role" } else if arn.contains(":role/") { "role" } else if arn.contains(":user/") { "user" } else if arn.contains(":root") { "root" } else { "unknown" } } fn derive_roles_from_arn(arn: &str) -> Vec { let resource = arn.split(':').nth(5).unwrap_or_default(); let mut parts = resource.split('/'); let kind = parts.next().unwrap_or_default(); let name = parts.next().unwrap_or_default(); let role_name = match kind { "assumed-role" | "role" => Some(name.to_string()), _ => None, }; if let Some(name) = role_name { vec![RoleBinding { name, source: "iam".into(), permissions: Vec::new() }] } else { Vec::new() } } async fn expand_permissions( iam: &IamClient, arn: &str, roles: &mut Vec, risk_notes: &mut Vec, ) -> Result { let access_type = classify_identity(arn); let resource = arn.split(':').nth(5).unwrap_or_default(); let mut parts = resource.split('/'); let _kind = parts.next(); let name = parts.next().unwrap_or_default(); if arn.contains(":assumed-role/AWSReservedSSO_") { risk_notes.push( "This is an AWS IAM Identity Center session. These sessions cannot enumerate role policies. IAM permission mapping skipped.".into(), ); return Ok(PermissionSummary::default()); } let mut actions = match access_type { "role" | "assumed_role" => collect_role_actions(iam, name, risk_notes).await, "user" => collect_user_actions(iam, name, risk_notes).await, _ => Ok(Vec::new()), } .unwrap_or_else(|err| { if err.to_string().contains("AccessDenied") { risk_notes.push( "IAM policy enumeration blocked: the caller does not have iam:Get* or iam:List* permissions. Permissions incomplete.".into(), ); } risk_notes.push(format!("IAM enumeration failed: {err}")); warn!("AWS access-map: IAM enumeration failed: {err}"); Vec::new() }); actions.sort(); actions.dedup(); for role in roles.iter_mut() { if role.permissions.is_empty() { role.permissions = actions.clone(); } } Ok(classify_permissions(&actions)) } async fn collect_role_actions( iam: &IamClient, role_name: &str, risk_notes: &mut Vec, ) -> Result> { let mut actions = Vec::new(); let attached = iam.list_attached_role_policies().role_name(role_name).send().await.map_err(|err| { map_iam_error( err, risk_notes, &format!("list_attached_role_policies failed for role {role_name}"), ) })?; for policy in attached.attached_policies() { if let Some(arn) = policy.policy_arn() { collect_managed_policy_actions(iam, arn, &mut actions, risk_notes).await?; } } let inline = iam.list_role_policies().role_name(role_name).send().await.map_err(|err| { map_iam_error(err, risk_notes, &format!("list_role_policies failed for role {role_name}")) })?; for name in inline.policy_names() { let policy = iam.get_role_policy().role_name(role_name).policy_name(name).send().await.map_err( |err| { map_iam_error( err, risk_notes, &format!("get_role_policy failed for role {role_name} policy {name}"), ) }, )?; extract_actions_from_document(policy.policy_document(), &mut actions)?; } Ok(actions) } async fn collect_user_actions( iam: &IamClient, user_name: &str, risk_notes: &mut Vec, ) -> Result> { let mut actions = Vec::new(); let attached = iam.list_attached_user_policies().user_name(user_name).send().await.map_err(|err| { map_iam_error( err, risk_notes, &format!("list_attached_user_policies failed for user {user_name}"), ) })?; for policy in attached.attached_policies() { if let Some(arn) = policy.policy_arn() { collect_managed_policy_actions(iam, arn, &mut actions, risk_notes).await?; } } let inline = iam.list_user_policies().user_name(user_name).send().await.map_err(|err| { map_iam_error(err, risk_notes, &format!("list_user_policies failed for user {user_name}")) })?; for name in inline.policy_names() { let policy = iam.get_user_policy().user_name(user_name).policy_name(name).send().await.map_err( |err| { map_iam_error( err, risk_notes, &format!("get_user_policy failed for user {user_name} policy {name}"), ) }, )?; extract_actions_from_document(policy.policy_document(), &mut actions)?; } Ok(actions) } async fn collect_managed_policy_actions( iam: &IamClient, policy_arn: &str, actions: &mut Vec, risk_notes: &mut Vec, ) -> Result<()> { let policy = iam.get_policy().policy_arn(policy_arn).send().await.map_err(|err| { map_iam_error(err, risk_notes, &format!("get_policy failed for {policy_arn}")) })?; let version = policy .policy() .and_then(|p| p.default_version_id()) .ok_or_else(|| anyhow!("Managed policy {policy_arn} missing default version"))?; let document = iam.get_policy_version().policy_arn(policy_arn).version_id(version).send().await.map_err( |err| { map_iam_error( err, risk_notes, &format!("get_policy_version failed for {policy_arn} version {version}"), ) }, )?; if let Some(doc) = document.policy_version().and_then(|v| v.document()) { extract_actions_from_document(doc, actions)?; } Ok(()) } fn extract_actions_from_document(doc: &str, actions: &mut Vec) -> Result<()> { let decoded = percent_decode_str(doc).decode_utf8()?.into_owned(); let decoded = if decoded.starts_with('"') { serde_json::from_str::(&decoded).unwrap_or(decoded) } else { decoded }; let json: Value = serde_json::from_str(&decoded) .map_err(|err| anyhow!("Failed to parse IAM policy document: {err}"))?; if let Some(statements) = json.get("Statement") { if let Some(array) = statements.as_array() { for stmt in array { collect_actions_from_statement(stmt, actions); } } else { collect_actions_from_statement(statements, actions); } } Ok(()) } fn collect_actions_from_statement(statement: &Value, actions: &mut Vec) { if statement.get("Effect").and_then(|e| e.as_str()) == Some("Deny") { return; } if let Some(action) = statement.get("Action") { collect_action_values(action, actions); } if let Some(not_action) = statement.get("NotAction") { collect_action_values(not_action, actions); } } fn collect_action_values(value: &Value, actions: &mut Vec) { match value { Value::String(s) => actions.push(s.to_lowercase().replace(':', ".")), Value::Array(arr) => { for v in arr { if let Some(s) = v.as_str() { actions.push(s.to_lowercase().replace(':', ".")); } } } _ => {} } } fn classify_permissions(actions: &[String]) -> PermissionSummary { let mut admin = Vec::new(); let mut privilege_escalation = Vec::new(); let mut risky = Vec::new(); let mut read_only = Vec::new(); for action in actions { let a = action.to_lowercase(); if a == "*" || a.ends_with(".*") { admin.push(action.clone()); continue; } if a.contains("iam.passrole") || a.contains("iam.create") || a.contains("iam.putrolepolicy") || a.contains("iam.updaterolepolicy") || a.contains("iam.updaterole") || a.contains("sts.assumerole") || a.contains("organizations.attachpolicy") { privilege_escalation.push(action.clone()); continue; } if a.contains(".get") || a.contains(".list") || a.contains(".describe") { read_only.push(action.clone()); continue; } risky.push(action.clone()); } PermissionSummary { admin, privilege_escalation, risky, read_only } } fn derive_severity(permissions: &PermissionSummary, has_resources: bool) -> Severity { if !permissions.admin.is_empty() || !permissions.privilege_escalation.is_empty() { Severity::Critical } else if !permissions.risky.is_empty() { Severity::High } else if !permissions.read_only.is_empty() || has_resources { Severity::Medium } else { Severity::Low } } fn can_read(permissions: &PermissionSummary, service_prefix: &str) -> bool { let prefix = service_prefix.to_lowercase(); permissions .admin .iter() .chain(&permissions.privilege_escalation) .chain(&permissions.risky) .chain(&permissions.read_only) .any(|action| action == "*" || action.starts_with(&prefix)) } async fn enumerate_resources( config: &SdkConfig, permissions: &PermissionSummary, account_id: Option<&str>, risk_notes: &mut Vec, ) -> Result> { let mut resources = Vec::new(); let no_permissions = permissions.admin.is_empty() && permissions.privilege_escalation.is_empty() && permissions.risky.is_empty() && permissions.read_only.is_empty(); if no_permissions { risk_notes.push( "IAM permissions unavailable; attempting best-effort resource discovery without permission gating.".into(), ); } if no_permissions || can_read(permissions, "s3.") { let client = S3Client::new(config); match client.list_buckets().send().await { Ok(resp) => { for bucket in resp.buckets() { if let Some(name) = bucket.name() { resources.push(ResourceExposure { resource_type: "s3_bucket".into(), name: format!("arn:aws:s3:::{name}"), permissions: permissions_for_prefix(permissions, "s3."), risk: "medium".into(), reason: "S3 bucket visible to the identity".into(), }); } } } Err(err) => { if !handle_access_denied("s3", &err, risk_notes) { warn!("AWS access-map: failed to enumerate s3 buckets: {err}"); risk_notes.push(format!("AWS enumeration failed for s3: {err}")); } } } } if no_permissions || can_read(permissions, "ec2.") { let ec2 = Ec2Client::new(config); match ec2.describe_instances().send().await { Ok(resp) => { let region = config .region() .map(|r| r.as_ref().to_string()) .unwrap_or_else(|| "unknown-region".into()); let account = account_id.unwrap_or("unknown-account"); for reservation in resp.reservations() { for instance in reservation.instances() { if let Some(id) = instance.instance_id() { resources.push(ResourceExposure { resource_type: "ec2_instance".into(), name: format!("arn:aws:ec2:{}:{}:instance/{}", region, account, id), permissions: permissions_for_prefix(permissions, "ec2."), risk: "medium".into(), reason: "EC2 instance readable by the identity".into(), }); } } } } Err(err) => { if !handle_access_denied("ec2", &err, risk_notes) { warn!("AWS access-map: failed to enumerate ec2 instances: {err}"); risk_notes.push(format!("AWS enumeration failed for ec2: {err}")); } } } } if no_permissions || can_read(permissions, "iam.") { let iam = IamClient::new(config); match iam.list_roles().send().await { Ok(resp) => { for role in resp.roles() { let arn = role.arn(); resources.push(ResourceExposure { resource_type: "iam_role".into(), name: arn.to_string(), permissions: permissions_for_prefix(permissions, "iam."), risk: "high".into(), reason: "Identity can view IAM roles; may indicate privilege escalation potential".into(), }); } } Err(err) => { if !handle_access_denied("iam", &err, risk_notes) { warn!("AWS access-map: failed to enumerate iam roles: {err}"); risk_notes.push(format!("AWS enumeration failed for iam: {err}")); } } } } if no_permissions || can_read(permissions, "lambda.") { let lambda = LambdaClient::new(config); match lambda.list_functions().send().await { Ok(resp) => { for function in resp.functions() { if let Some(arn) = function.function_arn() { resources.push(ResourceExposure { resource_type: "lambda_function".into(), name: arn.to_string(), permissions: permissions_for_prefix(permissions, "lambda."), risk: "medium".into(), reason: "Lambda visible; may imply code execution pathways".into(), }); } } } Err(err) => { if !handle_access_denied("lambda", &err, risk_notes) { warn!("AWS access-map: failed to enumerate lambda functions: {err}"); risk_notes.push(format!("AWS enumeration failed for lambda: {err}")); } } } } if no_permissions || can_read(permissions, "dynamodb.") { let dynamo = DynamoClient::new(config); match dynamo.list_tables().send().await { Ok(resp) => { for table in resp.table_names() { resources.push(ResourceExposure { resource_type: "dynamodb_table".into(), name: table.to_string(), permissions: permissions_for_prefix(permissions, "dynamodb."), risk: "medium".into(), reason: "DynamoDB table visible to the identity".into(), }); } } Err(err) => { if !handle_access_denied("dynamodb", &err, risk_notes) { warn!("AWS access-map: failed to enumerate dynamodb tables: {err}"); risk_notes.push(format!("AWS enumeration failed for dynamodb: {err}")); } } } } if no_permissions || can_read(permissions, "kms.") { let kms = KmsClient::new(config); match kms.list_keys().send().await { Ok(resp) => { let region = config.region().map(|r| r.as_ref().to_string()); let account = account_id.unwrap_or(""); for key in resp.keys() { if let Some(id) = key.key_id() { let arn = region .as_ref() .filter(|r| !r.is_empty()) .and_then(|r| { if account.is_empty() { None } else { Some(format!("arn:aws:kms:{r}:{account}:key/{id}")) } }) .unwrap_or_else(|| id.to_string()); resources.push(ResourceExposure { resource_type: "kms_key".into(), name: arn, permissions: permissions_for_prefix(permissions, "kms."), risk: "high".into(), reason: "Identity can view KMS keys; possible cryptographic privilege paths" .into(), }); } } } Err(err) => { if !handle_access_denied("kms", &err, risk_notes) { warn!("AWS access-map: failed to enumerate kms keys: {err}"); risk_notes.push(format!("AWS enumeration failed for kms: {err}")); } } } } if no_permissions || can_read(permissions, "secretsmanager.") { let sm = SecretsManagerClient::new(config); match sm.list_secrets().send().await { Ok(resp) => { for secret in resp.secret_list() { if let Some(arn) = secret.arn() { resources.push(ResourceExposure { resource_type: "secret".into(), name: arn.to_string(), permissions: permissions_for_prefix(permissions, "secretsmanager."), risk: "high".into(), reason: "Secret visible to the identity".into(), }); } } } Err(err) => { if !handle_access_denied("secretsmanager", &err, risk_notes) { warn!("AWS access-map: failed to enumerate secretsmanager secrets: {err}"); risk_notes.push(format!("AWS enumeration failed for secretsmanager: {err}")); } } } } if no_permissions || can_read(permissions, "sqs.") { let sqs = SqsClient::new(config); match sqs.list_queues().send().await { Ok(resp) => { let can_send = permissions .admin .iter() .chain(&permissions.privilege_escalation) .chain(&permissions.risky) .any(|perm| { perm == "*" || perm.starts_with("sqs.sendmessage") || perm.starts_with("sqs.purgequeue") || perm.starts_with("sqs.deletequeue") || perm.starts_with("sqs.createqueue") }); for queue_url in resp.queue_urls() { resources.push(ResourceExposure { resource_type: "sqs_queue".into(), name: queue_url.to_string(), permissions: permissions_for_prefix(permissions, "sqs."), risk: if can_send { "high".into() } else { "medium".into() }, reason: if can_send { "SQS queue visible and queue messages may be writable or destructive" .into() } else { "SQS queue visible to the identity".into() }, }); } } Err(err) => { if !handle_access_denied("sqs", &err, risk_notes) { warn!("AWS access-map: failed to enumerate sqs queues: {err}"); risk_notes.push(format!("AWS enumeration failed for sqs: {err}")); } } } } if no_permissions || can_read(permissions, "sns.") { let sns = SnsClient::new(config); match sns.list_topics().send().await { Ok(resp) => { let can_publish = permissions .admin .iter() .chain(&permissions.privilege_escalation) .chain(&permissions.risky) .any(|perm| { perm == "*" || perm.starts_with("sns.publish") || perm.starts_with("sns.createtopic") || perm.starts_with("sns.deletetopic") || perm.starts_with("sns.settopicattributes") }); for topic in resp.topics() { if let Some(arn) = topic.topic_arn() { resources.push(ResourceExposure { resource_type: "sns_topic".into(), name: arn.to_string(), permissions: permissions_for_prefix(permissions, "sns."), risk: if can_publish { "high".into() } else { "medium".into() }, reason: if can_publish { "SNS topic visible and publish or topic-management actions appear available" .into() } else { "SNS topic visible to the identity".into() }, }); } } } Err(err) => { if !handle_access_denied("sns", &err, risk_notes) { warn!("AWS access-map: failed to enumerate sns topics: {err}"); risk_notes.push(format!("AWS enumeration failed for sns: {err}")); } } } } if no_permissions || can_read(permissions, "rds.") { let rds = RdsClient::new(config); match rds.describe_db_instances().send().await { Ok(resp) => { let can_modify = permissions .admin .iter() .chain(&permissions.privilege_escalation) .chain(&permissions.risky) .any(|perm| { perm == "*" || perm.starts_with("rds.modifydbinstance") || perm.starts_with("rds.createdbinstance") || perm.starts_with("rds.deletedbinstance") || perm.starts_with("rds.restoredbinstance") }); for db in resp.db_instances() { let name = db .db_instance_arn() .map(ToString::to_string) .or_else(|| db.db_instance_identifier().map(ToString::to_string)); if let Some(name) = name { resources.push(ResourceExposure { resource_type: "rds_instance".into(), name, permissions: permissions_for_prefix(permissions, "rds."), risk: if can_modify { "high".into() } else { "medium".into() }, reason: if can_modify { "RDS instance visible and instance lifecycle changes appear possible" .into() } else { "RDS instance visible to the identity".into() }, }); } } } Err(err) => { if !handle_access_denied("rds", &err, risk_notes) { warn!("AWS access-map: failed to enumerate rds instances: {err}"); risk_notes.push(format!("AWS enumeration failed for rds: {err}")); } } } } if no_permissions || can_read(permissions, "ecr.") { let ecr = EcrClient::new(config); match ecr.describe_repositories().send().await { Ok(resp) => { let can_push = permissions .admin .iter() .chain(&permissions.privilege_escalation) .chain(&permissions.risky) .any(|perm| { perm == "*" || perm.starts_with("ecr.putimage") || perm.starts_with("ecr.batchdeleteimage") || perm.starts_with("ecr.setrepositorypolicy") || perm.starts_with("ecr.deleterepository") || perm.starts_with("ecr.createrepository") }); for repo in resp.repositories() { let name = repo .repository_arn() .map(ToString::to_string) .or_else(|| repo.repository_name().map(ToString::to_string)); if let Some(name) = name { resources.push(ResourceExposure { resource_type: "ecr_repository".into(), name, permissions: permissions_for_prefix(permissions, "ecr."), risk: if can_push { "high".into() } else { "medium".into() }, reason: if can_push { "ECR repository visible and image push or policy changes appear possible" .into() } else { "ECR repository visible to the identity".into() }, }); } } } Err(err) => { if !handle_access_denied("ecr", &err, risk_notes) { warn!("AWS access-map: failed to enumerate ecr repositories: {err}"); risk_notes.push(format!("AWS enumeration failed for ecr: {err}")); } } } } if no_permissions || can_read(permissions, "ssm.") { let ssm = SsmClient::new(config); match ssm.describe_parameters().send().await { Ok(resp) => { let can_read_values = permissions .admin .iter() .chain(&permissions.privilege_escalation) .chain(&permissions.risky) .chain(&permissions.read_only) .any(|perm| { perm == "*" || perm.starts_with("ssm.getparameter") || perm.starts_with("ssm.getparameters") || perm.starts_with("ssm.getparametersbypath") }); let can_modify = permissions .admin .iter() .chain(&permissions.privilege_escalation) .chain(&permissions.risky) .any(|perm| { perm == "*" || perm.starts_with("ssm.putparameter") || perm.starts_with("ssm.deleteparameter") || perm.starts_with("ssm.labelparameterversion") }); for parameter in resp.parameters() { if let Some(name) = parameter.name() { let reason = if can_modify && can_read_values { "SSM parameter visible and parameter values may be readable and writable" } else if can_modify { "SSM parameter visible and parameter metadata suggests write access" } else if can_read_values { "SSM parameter visible and parameter values may be readable" } else { "SSM parameter visible to the identity" }; resources.push(ResourceExposure { resource_type: "ssm_parameter".into(), name: name.to_string(), permissions: permissions_for_prefix(permissions, "ssm."), risk: if can_modify || can_read_values { "high".into() } else { "medium".into() }, reason: reason.into(), }); } } } Err(err) => { if !handle_access_denied("ssm", &err, risk_notes) { warn!("AWS access-map: failed to enumerate ssm parameters: {err}"); risk_notes.push(format!("AWS enumeration failed for ssm: {err}")); } } } } Ok(resources) } async fn load_config_from_path(path: Option<&Path>) -> Result { if let Some(path) = path { let creds = load_credentials_from_file(path)?; load_config(Some(creds)).await } else { load_config(None).await } } async fn load_config(credentials: Option) -> Result { let mut loader = aws_config::defaults(BehaviorVersion::latest()); if let Some(creds) = credentials { loader = loader.credentials_provider(creds); } Ok(loader.load().await) } fn load_credentials_from_file(path: &Path) -> Result { let raw = std::fs::read_to_string(path).context("Failed to read AWS credential file")?; if let Ok(value) = serde_json::from_str::(&raw) { return credentials_from_json(&value); } credentials_from_kv(&raw) } fn credentials_from_json(value: &Value) -> Result { let map = value.as_object().ok_or_else(|| anyhow!("Credential JSON must be an object"))?; let access_key = get_case_insensitive( map, &["access_key_id", "accessKeyId", "aws_access_key_id", "AccessKeyId"], ) .ok_or_else(|| anyhow!("Missing access_key_id in credential JSON"))?; let secret_key = get_case_insensitive( map, &["secret_access_key", "secretAccessKey", "aws_secret_access_key", "SecretAccessKey"], ) .ok_or_else(|| anyhow!("Missing secret_access_key in credential JSON"))?; let session_token = get_case_insensitive( map, &["session_token", "sessionToken", "aws_session_token", "SessionToken"], ); Ok(match session_token { Some(token) => Credentials::new(&access_key, &secret_key, Some(token), None, "access_map"), None => Credentials::new(&access_key, &secret_key, None, None, "access_map"), }) } fn get_case_insensitive(map: &serde_json::Map, keys: &[&str]) -> Option { keys.iter().find_map(|key| { map.iter() .find(|(existing, _)| existing.eq_ignore_ascii_case(key)) .and_then(|(_, v)| v.as_str().map(|s| s.to_string())) }) } fn credentials_from_kv(raw: &str) -> Result { let mut access_key = None; let mut secret_key = None; let mut session_token = None; for line in raw.lines() { let trimmed = line.trim(); if trimmed.starts_with('#') || trimmed.is_empty() { continue; } if let Some((key, value)) = trimmed.split_once('=') { let key_lower = key.trim().to_ascii_lowercase(); let val = value.trim().to_string(); match key_lower.as_str() { "aws_access_key_id" | "access_key_id" => access_key = Some(val), "aws_secret_access_key" | "secret_access_key" => secret_key = Some(val), "aws_session_token" | "session_token" => session_token = Some(val), _ => {} } } } let access_key = access_key.ok_or_else(|| anyhow!("Missing aws_access_key_id in credential file"))?; let secret_key = secret_key.ok_or_else(|| anyhow!("Missing aws_secret_access_key in credential file"))?; Ok(match session_token { Some(token) => Credentials::new(&access_key, &secret_key, Some(token), None, "access_map"), None => Credentials::new(&access_key, &secret_key, None, None, "access_map"), }) } fn handle_access_denied( service: &str, err: &SdkError, risk_notes: &mut Vec, ) -> bool { let message = err.to_string(); if is_access_denied(&message) { warn!("AWS access-map: access denied while enumerating {service}: {message}"); risk_notes.push(format!("AWS enumeration incomplete: AccessDenied for {service}")); return true; } false } fn is_access_denied(message: &str) -> bool { message.contains("AccessDenied") || message.contains("AccessDeniedException") } fn map_iam_error( err: SdkError, risk_notes: &mut Vec, context: &str, ) -> anyhow::Error { let message = err.to_string(); if err.as_service_error().is_some() && is_access_denied(&message) { risk_notes.push( "IAM policy enumeration blocked: the caller does not have iam:Get* or iam:List* permissions. Permissions incomplete.".into(), ); } warn!("AWS access-map IAM error: {context}: {message}"); anyhow!("{context}: {message}") }