kingfisher/src/access_map/aws.rs
2026-04-17 16:53:21 -07:00

1064 lines
39 KiB
Rust

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<AccessMapResult> {
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<String> {
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<AccessMapResult> {
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<AccessMapResult> {
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<RoleBinding> {
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<RoleBinding>,
risk_notes: &mut Vec<String>,
) -> Result<PermissionSummary> {
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<String>,
) -> Result<Vec<String>> {
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<String>,
) -> Result<Vec<String>> {
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<String>,
risk_notes: &mut Vec<String>,
) -> 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<String>) -> Result<()> {
let decoded = percent_decode_str(doc).decode_utf8()?.into_owned();
let decoded = if decoded.starts_with('"') {
serde_json::from_str::<String>(&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<String>) {
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<String>) {
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<String>,
) -> Result<Vec<ResourceExposure>> {
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<SdkConfig> {
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<Credentials>) -> Result<SdkConfig> {
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<Credentials> {
let raw = std::fs::read_to_string(path).context("Failed to read AWS credential file")?;
if let Ok(value) = serde_json::from_str::<Value>(&raw) {
return credentials_from_json(&value);
}
credentials_from_kv(&raw)
}
fn credentials_from_json(value: &Value) -> Result<Credentials> {
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<String, Value>, keys: &[&str]) -> Option<String> {
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<Credentials> {
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<E: std::error::Error + Send + Sync + 'static + std::fmt::Display>(
service: &str,
err: &SdkError<E>,
risk_notes: &mut Vec<String>,
) -> 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<E: std::error::Error + Send + Sync + 'static + std::fmt::Display>(
err: SdkError<E>,
risk_notes: &mut Vec<String>,
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}")
}