use std::collections::{BTreeMap, HashSet};
use std::path::Path;
use anyhow::{Context, Result, anyhow};
use futures::{StreamExt, stream};
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
use reqwest::{Client, StatusCode};
use serde_json::Value;
use tracing::warn;
macro_rules! verbose_warn {
($($arg:tt)*) => {
if tracing::level_enabled!(tracing::Level::DEBUG) {
warn!($($arg)*);
}
};
}
use crate::validation::gcp::GcpValidator;
mod api_key;
pub use api_key::map_access as map_access_from_api_key;
#[derive(Debug, Clone)]
struct Ancestor {
kind: String,
id: String,
}
const MAX_IMPERSONATION_ROLE_EXPANSIONS: usize = 64;
const MAX_IMPERSONATED_ROLE_IMPACTS: usize = 256;
const MAX_AUTHORIZATION_PATHS: usize = 256;
const MAX_SERVICE_ACCOUNT_POLICY_PROBES: usize = 128;
const SERVICE_ACCOUNT_POLICY_CONCURRENCY: usize = 8;
#[derive(Debug, Clone)]
struct GcpIamBinding {
id: String,
role: String,
members: Vec<String>,
scope: String,
kind: String,
version: Option<String>,
condition_keys: Vec<String>,
}
use super::{
AccessMapResult, AccessSummary, AccessTokenDetails, AuthorizationEvidence, AuthorizationGrant,
AuthorizationHop, AuthorizationPath, AuthorizationStatement, HierarchyScope, PermissionSummary,
PolicyEvidence, PrincipalEvidence, ProviderMetadata, ResourceExposure, RoleBinding, RoleImpact,
Severity, build_default_resource, build_recommendations,
};
pub async fn map_access(credential_path: Option<&Path>) -> Result<AccessMapResult> {
let path = credential_path.ok_or_else(|| anyhow!("GCP access-map requires a key.json path"))?;
let data = std::fs::read_to_string(path).context("Failed to read credential file")?;
map_access_from_json(&data).await
}
pub async fn map_access_from_json(data: &str) -> Result<AccessMapResult> {
let validator = GcpValidator::global()?;
let token_context = validator
.get_access_token_from_sa_json(data)
.await
.context("Failed to mint GCP access token")?;
let http_client = validator.client().clone();
let access_token = token_context.access_token;
let client_email = token_context.client_email;
let mut project_id =
if token_context.project_id.is_empty() { None } else { Some(token_context.project_id) };
let sa_metadata =
match fetch_service_account_metadata(&http_client, &access_token, &client_email).await {
Ok(meta) => meta,
Err(err) => {
verbose_warn!("GCP access-map: failed to fetch service account metadata: {err}");
ServiceAccountMetadata::default()
}
};
if project_id.is_none() {
project_id = sa_metadata.project_id.clone();
}
let mut roles = Vec::new();
let mut role_entries: Vec<(String, String)> = Vec::new();
let mut iam_bindings = Vec::new();
let mut role_permissions: BTreeMap<String, Vec<String>> = BTreeMap::new();
let mut principal_attributes = BTreeMap::new();
if let Some(unique_id) = sa_metadata.unique_id.as_ref() {
principal_attributes.insert("unique_id".into(), unique_id.clone());
}
if let Some(project) = project_id.as_ref() {
principal_attributes.insert("project_id".into(), project.clone());
}
if let Some(disabled) = sa_metadata.is_disabled {
principal_attributes.insert("disabled".into(), disabled.to_string());
}
let mut authorization_evidence = AuthorizationEvidence {
principal: Some(PrincipalEvidence {
id: client_email.clone(),
kind: "service_account".into(),
canonical_id: Some(match project_id.as_deref() {
Some(project) => format!("projects/{project}/serviceAccounts/{client_email}"),
None => format!("projects/-/serviceAccounts/{client_email}"),
}),
name: sa_metadata.display_name.clone(),
groups: Vec::new(),
tags: Default::default(),
attributes: principal_attributes,
}),
limitations: vec![
"Conditional IAM bindings and deny policies are retained as limitations rather than fully evaluated.".into(),
"Resource inventory is limited to the project associated with the credential; inherited roles may apply to additional descendant projects.".into(),
"Google group membership is not resolved, so group-based service-account grants may be absent.".into(),
"Outbound service-account paths are permission-based candidates derived from caller-readable hierarchy and target service-account policies; target role impact is limited to direct service-account members in visible hierarchy policies.".into(),
"Kingfisher does not mint credentials for discovered target service accounts or re-enumerate resources as those identities.".into(),
"Service-account inventory is limited to the first API response and at most 128 targets are considered for outbound paths.".into(),
],
..AuthorizationEvidence::default()
};
if let Some(project) = project_id.as_deref() {
authorization_evidence
.hierarchy
.push(HierarchyScope { kind: "project".into(), id: format!("projects/{project}") });
}
let policy = fetch_project_policy(&http_client, &access_token, project_id.as_deref()).await?;
if let Some(policy) = policy.as_ref() {
let scope = project_id
.as_deref()
.map(|project| format!("projects/{project}"))
.unwrap_or_else(|| "project".into());
collect_roles(
policy,
&client_email,
"project",
&scope,
&mut role_entries,
&mut authorization_evidence,
);
collect_iam_bindings(policy, &scope, "iam_binding", &mut iam_bindings);
} else {
authorization_evidence.limitations.push("The project IAM policy could not be read.".into());
}
if let Some(project) = project_id.as_deref() {
let ancestors = fetch_project_ancestry(&http_client, &access_token, project)
.await
.unwrap_or_else(|e| {
verbose_warn!("GCP access-map: failed to fetch project ancestry: {e}");
authorization_evidence
.limitations
.push("Project ancestry could not be completely enumerated.".into());
Vec::new()
});
for ancestor in ancestors {
if ancestor.kind == "project" && ancestor.id == project {
continue;
}
let scope = format!("{}s/{}", ancestor.kind, ancestor.id);
authorization_evidence
.hierarchy
.push(HierarchyScope { kind: ancestor.kind.clone(), id: scope.clone() });
if let Some(policy) =
fetch_ancestor_policy(&http_client, &access_token, &ancestor).await?
{
let source = match ancestor.kind.as_str() {
"organization" => format!("org:{}", ancestor.id),
"folder" => format!("folder:{}", ancestor.id),
_ => ancestor.kind.clone(),
};
collect_roles(
&policy,
&client_email,
&source,
&scope,
&mut role_entries,
&mut authorization_evidence,
);
collect_iam_bindings(&policy, &scope, "iam_binding", &mut iam_bindings);
} else {
authorization_evidence.limitations.push(format!(
"The {} IAM policy for {} could not be read.",
ancestor.kind, scope
));
}
}
}
let mut seen = HashSet::new();
for (role_name, source) in role_entries {
if !seen.insert((role_name.clone(), source.clone())) {
continue;
}
let (permissions, disabled) = if let Some(permissions) = role_permissions.get(&role_name) {
(permissions.clone(), false)
} else {
let permissions = fetch_role_permissions(&http_client, &access_token, &role_name)
.await
.unwrap_or_else(|e| {
verbose_warn!("Failed to expand permissions for {role_name}: {e}");
authorization_evidence.limitations.push(format!(
"Permissions for IAM role {role_name} could not be expanded."
));
RolePermissions { permissions: Vec::new(), disabled: false }
});
let disabled = permissions.disabled;
if !disabled {
role_permissions.insert(role_name.clone(), permissions.permissions.clone());
}
(permissions.permissions, disabled)
};
if disabled {
authorization_evidence
.limitations
.push(format!("IAM role {role_name} is disabled or deleted and was ignored."));
continue;
}
for policy in authorization_evidence.policies.iter_mut().filter(|policy| {
policy.kind == "iam_binding" && policy.name.as_deref() == Some(&role_name)
}) {
if let Some(statement) = policy.statements.first_mut() {
statement.actions = permissions.clone();
}
}
roles.push(RoleBinding { name: role_name, source, permissions });
}
if roles.is_empty()
&& let Some(project) = project_id.as_deref()
{
let mut tested_permissions = test_project_permissions(&http_client, &access_token, project)
.await
.unwrap_or_else(|e| {
verbose_warn!("GCP access-map: failed testIamPermissions fallback: {e}");
Vec::new()
});
let mut probe_scope = format!("projects/{project}");
let mut probe_source = "project_testIamPermissions";
if tested_permissions.is_empty() {
tested_permissions = test_service_account_permissions(
&http_client,
&access_token,
project,
&client_email,
)
.await
.unwrap_or_else(|e| {
verbose_warn!(
"GCP access-map: failed serviceAccount testIamPermissions fallback: {e}"
);
Vec::new()
});
probe_scope = format!("projects/{project}/serviceAccounts/{client_email}");
probe_source = "service_account_testIamPermissions";
}
if !tested_permissions.is_empty() {
authorization_evidence.policies.push(PolicyEvidence {
id: format!("gcp:testIamPermissions:{probe_scope}"),
name: Some("testIamPermissions".into()),
kind: "permission_probe".into(),
attached_to: client_email.clone(),
attached_via: None,
scope: Some(probe_scope.clone()),
version: None,
statements: vec![AuthorizationStatement {
id: format!("gcp:testIamPermissions:{probe_scope}#0"),
sid: None,
effect: "Observed".into(),
actions: tested_permissions.clone(),
not_actions: Vec::new(),
resources: vec![probe_scope],
not_resources: Vec::new(),
principals: vec![format!("serviceAccount:{client_email}")],
not_principals: Vec::new(),
condition_keys: Vec::new(),
}],
});
roles.push(RoleBinding {
name: "testIamPermissions".into(),
source: probe_source.into(),
permissions: tested_permissions,
});
}
}
if let Some(project) = project_id.as_deref() {
match fetch_service_account_iam_policy(&http_client, &access_token, project, &client_email)
.await
{
Ok(Some(policy)) => collect_iam_bindings(
&policy,
&format!("projects/{project}/serviceAccounts/{client_email}"),
"service_account_binding",
&mut iam_bindings,
),
Ok(None) => authorization_evidence
.limitations
.push("The credential service account IAM policy could not be read.".into()),
Err(err) => {
verbose_warn!("GCP access-map: failed to fetch service account IAM policy: {err}");
authorization_evidence
.limitations
.push("The credential service account IAM policy could not be read.".into());
}
}
}
iam_bindings.sort_by_key(|binding| binding.kind != "service_account_binding");
let impersonation_notes = add_incoming_impersonation_evidence(
&http_client,
&access_token,
&client_email,
&iam_bindings,
&mut role_permissions,
&mut authorization_evidence,
)
.await;
let direct_permissions = classify_permissions(&roles);
let mut resources = Vec::new();
if let Some(project) = project_id.as_deref() {
let mut enumerated =
enumerate_resources(&http_client, &access_token, project, &direct_permissions, &roles)
.await
.unwrap_or_else(|e| {
verbose_warn!("GCP access-map: failed resource enumeration: {e}");
Vec::new()
});
resources.append(&mut enumerated);
}
let target_bindings = fetch_visible_service_account_bindings(
&http_client,
&access_token,
&client_email,
&resources,
&roles,
&mut authorization_evidence,
)
.await;
add_impersonation_binding_policy_evidence(
&http_client,
&access_token,
&target_bindings,
&mut role_permissions,
&mut authorization_evidence,
)
.await;
iam_bindings.extend(target_bindings);
let reachable_targets =
add_outgoing_impersonation_paths(&client_email, &resources, &mut authorization_evidence);
let impersonated = build_outgoing_impersonation_impacts(
&http_client,
&access_token,
&reachable_targets,
&iam_bindings,
&mut role_permissions,
&mut authorization_evidence,
)
.await;
roles.extend(impersonated.roles);
resources.extend(impersonated.resources);
authorization_evidence.role_impacts = impersonated.impacts;
let permissions = classify_permissions(&roles);
let severity = derive_severity(&permissions);
if resources.is_empty() {
resources.push(build_default_resource(project_id.as_deref(), severity));
}
let identity = AccessSummary {
id: client_email.clone(),
access_type: "service_account".into(),
project: project_id.clone(),
tenant: None,
account_id: None,
};
let mut risk_notes = derive_risk_notes(&roles, &permissions);
risk_notes.extend(impersonation_notes);
risk_notes.extend(impersonated.notes);
if sa_metadata.is_disabled == Some(true) {
risk_notes.push("Service account is disabled but key is still valid".into());
}
let recommendations = build_recommendations(severity);
let token_details = Some(AccessTokenDetails {
name: sa_metadata.display_name,
username: Some(client_email),
account_type: Some("service_account".into()),
token_type: Some("service_account_key".into()),
user_id: sa_metadata.unique_id,
..Default::default()
});
Ok(AccessMapResult {
cloud: "gcp".into(),
identity,
roles,
permissions,
resources,
severity,
recommendations,
risk_notes,
token_details,
provider_metadata: Some(ProviderMetadata {
version: None,
enterprise: None,
authorization_evidence: Some(authorization_evidence),
}),
fingerprint: None,
})
}
async fn fetch_project_policy(
client: &Client,
token: &str,
project_id: Option<&str>,
) -> Result<Option<Value>> {
let project = project_id.ok_or_else(|| anyhow!("Missing project_id"))?;
let url =
format!("https://cloudresourcemanager.googleapis.com/v1/projects/{}:getIamPolicy", project);
let resp_v3 = client
.post(&url)
.bearer_auth(token)
.json(&serde_json::json!({ "options": { "requestedPolicyVersion": 3 } }))
.send()
.await?;
let status_v3 = resp_v3.status();
let body_v3 = resp_v3.bytes().await?;
if status_v3.is_success() {
let json = serde_json::from_slice(&body_v3)?;
return Ok(Some(json));
}
if let Some(disabled) = service_disabled_message(&body_v3)? {
verbose_warn!(
"GCP access-map: Cloud Resource Manager API disabled for project {project}: {disabled}"
);
return Ok(None);
}
if status_v3 == StatusCode::FORBIDDEN || status_v3 == StatusCode::BAD_REQUEST {
let resp_v1 =
client.post(&url).bearer_auth(token).json(&serde_json::json!({})).send().await?;
let status_v1 = resp_v1.status();
let body_v1 = resp_v1.bytes().await?;
if status_v1.is_success() {
let json = serde_json::from_slice(&body_v1)?;
return Ok(Some(json));
}
if let Some(disabled) = service_disabled_message(&body_v1)? {
verbose_warn!(
"GCP access-map: Cloud Resource Manager API disabled for project {project}: {disabled}"
);
return Ok(None);
}
if status_v1 == StatusCode::FORBIDDEN {
verbose_warn!(
"GCP access-map: insufficient permissions to read IAM policy (v1 and v3)"
);
return Ok(None);
}
return Err(anyhow!(
"Failed to fetch project IAM policy (v1): HTTP {} {}",
status_v1,
String::from_utf8_lossy(&body_v1)
));
}
Err(anyhow!(
"Failed to fetch project IAM policy (v3): HTTP {} {}",
status_v3,
String::from_utf8_lossy(&body_v3)
))
}
async fn fetch_project_ancestry(
client: &Client,
token: &str,
project_id: &str,
) -> Result<Vec<Ancestor>> {
let url = format!(
"https://cloudresourcemanager.googleapis.com/v1/projects/{}:getAncestry",
project_id
);
let resp = client.post(url).bearer_auth(token).json(&serde_json::json!({})).send().await?;
let status = resp.status();
let body = resp.bytes().await?;
if let Some(disabled) = service_disabled_message(&body)? {
verbose_warn!(
"GCP access-map: Cloud Resource Manager API disabled for project {project_id}: {disabled}"
);
return Ok(Vec::new());
}
if status == StatusCode::FORBIDDEN {
verbose_warn!("GCP access-map: ancestry lookup forbidden for project {project_id}");
return Ok(Vec::new());
}
if !status.is_success() {
return Err(anyhow!(
"Failed to fetch project ancestry: HTTP {} {}",
status,
String::from_utf8_lossy(&body)
));
}
let json: Value = serde_json::from_slice(&body)?;
let mut ancestors = Vec::new();
if let Some(arr) = json.get("ancestor").and_then(|a| a.as_array()) {
for item in arr {
if let Some(resource) = item.get("resourceId")
&& let (Some(kind), Some(id)) = (resource.get("type"), resource.get("id"))
&& let (Some(kind), Some(id)) = (kind.as_str(), id.as_str())
{
ancestors.push(Ancestor { kind: kind.to_string(), id: id.to_string() });
}
}
}
Ok(ancestors)
}
async fn fetch_ancestor_policy(
client: &Client,
token: &str,
ancestor: &Ancestor,
) -> Result<Option<Value>> {
let url = match ancestor.kind.as_str() {
"organization" => format!(
"https://cloudresourcemanager.googleapis.com/v1/organizations/{}:getIamPolicy",
ancestor.id
),
"folder" => format!(
"https://cloudresourcemanager.googleapis.com/v1/folders/{}:getIamPolicy",
ancestor.id
),
_ => return Ok(None),
};
let resp_v3 = client
.post(&url)
.bearer_auth(token)
.json(&serde_json::json!({ "options": { "requestedPolicyVersion": 3 } }))
.send()
.await?;
let status_v3 = resp_v3.status();
let body_v3 = resp_v3.bytes().await?;
if status_v3.is_success() {
let json = serde_json::from_slice(&body_v3)?;
return Ok(Some(json));
}
if let Some(disabled) = service_disabled_message(&body_v3)? {
verbose_warn!(
"GCP access-map: Cloud Resource Manager API disabled for {} {}: {disabled}",
ancestor.kind,
ancestor.id
);
return Ok(None);
}
if status_v3 == StatusCode::FORBIDDEN || status_v3 == StatusCode::BAD_REQUEST {
let resp_v1 =
client.post(&url).bearer_auth(token).json(&serde_json::json!({})).send().await?;
let status_v1 = resp_v1.status();
let body_v1 = resp_v1.bytes().await?;
if status_v1.is_success() {
let json = serde_json::from_slice(&body_v1)?;
return Ok(Some(json));
}
if let Some(disabled) = service_disabled_message(&body_v1)? {
verbose_warn!(
"GCP access-map: Cloud Resource Manager API disabled for {} {}: {disabled}",
ancestor.kind,
ancestor.id
);
return Ok(None);
}
if status_v1 == StatusCode::FORBIDDEN {
verbose_warn!(
"GCP access-map: insufficient permissions to read {} IAM policy (v1 and v3)",
ancestor.kind
);
return Ok(None);
}
return Err(anyhow!(
"Failed to fetch {} IAM policy (v1): HTTP {} {}",
ancestor.kind,
status_v1,
String::from_utf8_lossy(&body_v1)
));
}
Err(anyhow!(
"Failed to fetch {} IAM policy (v3): HTTP {} {}",
ancestor.kind,
status_v3,
String::from_utf8_lossy(&body_v3)
))
}
#[derive(Debug, Clone, Default)]
struct ServiceAccountMetadata {
project_id: Option<String>,
display_name: Option<String>,
unique_id: Option<String>,
is_disabled: Option<bool>,
}
async fn fetch_service_account_metadata(
client: &Client,
token: &str,
client_email: &str,
) -> Result<ServiceAccountMetadata> {
let encoded_email = utf8_percent_encode(client_email, NON_ALPHANUMERIC);
let url = format!("https://iam.googleapis.com/v1/projects/-/serviceAccounts/{}", encoded_email);
let resp = client.get(url).bearer_auth(token).send().await?;
let status = resp.status();
let body = resp.bytes().await?;
if let Some(disabled) = service_disabled_message(&body)? {
verbose_warn!(
"GCP access-map: IAM API disabled when fetching metadata for {client_email}: {disabled}"
);
return Ok(ServiceAccountMetadata::default());
}
if status == StatusCode::FORBIDDEN {
verbose_warn!("GCP access-map: service account metadata forbidden for {client_email}");
return Ok(ServiceAccountMetadata::default());
}
if !status.is_success() {
return Err(anyhow!(
"Failed to fetch service account metadata: HTTP {} {}",
status,
String::from_utf8_lossy(&body)
));
}
let json: Value = serde_json::from_slice(&body)?;
Ok(ServiceAccountMetadata {
project_id: json.get("projectId").and_then(|p| p.as_str()).map(|s| s.to_string()),
display_name: json.get("displayName").and_then(|d| d.as_str()).map(|s| s.to_string()),
unique_id: json.get("uniqueId").and_then(|u| u.as_str()).map(|s| s.to_string()),
is_disabled: json.get("disabled").and_then(Value::as_bool),
})
}
fn collect_iam_bindings(policy: &Value, scope: &str, kind: &str, out: &mut Vec<GcpIamBinding>) {
let Some(bindings) = policy.get("bindings").and_then(Value::as_array) else {
return;
};
for (index, binding) in bindings.iter().enumerate() {
let Some(role) = binding.get("role").and_then(Value::as_str) else {
continue;
};
let members: Vec<String> = binding
.get("members")
.and_then(Value::as_array)
.map(|members| members.iter().filter_map(Value::as_str).map(str::to_string).collect())
.unwrap_or_default();
if members.is_empty() {
continue;
}
let condition_keys = binding
.get("condition")
.and_then(Value::as_object)
.map(|condition| {
let mut keys: Vec<String> = condition.keys().cloned().collect();
keys.sort();
keys
})
.unwrap_or_default();
out.push(GcpIamBinding {
id: format!("gcp:{scope}:{role}:{index}"),
role: role.into(),
members,
scope: scope.into(),
kind: kind.into(),
version: policy.get("version").and_then(Value::as_i64).map(|v| v.to_string()),
condition_keys,
});
}
}
fn collect_roles(
policy: &Value,
client_email: &str,
source: &str,
scope: &str,
out: &mut Vec<(String, String)>,
evidence: &mut AuthorizationEvidence,
) {
let member = format!("serviceAccount:{client_email}");
if let Some(bindings) = policy.get("bindings").and_then(Value::as_array) {
for (index, binding) in bindings.iter().enumerate() {
let Some(role) = binding.get("role").and_then(Value::as_str) else {
continue;
};
let applies = binding
.get("members")
.and_then(Value::as_array)
.is_some_and(|members| members.iter().any(|value| value.as_str() == Some(&member)));
if !applies {
continue;
}
out.push((role.to_string(), source.to_string()));
let condition_keys = binding
.get("condition")
.and_then(Value::as_object)
.map(|condition| {
let mut keys: Vec<String> = condition.keys().cloned().collect();
keys.sort();
keys
})
.unwrap_or_default();
let policy_id = format!("gcp:{scope}:{role}:{index}");
evidence.policies.push(PolicyEvidence {
id: policy_id.clone(),
name: Some(role.to_string()),
kind: "iam_binding".into(),
attached_to: scope.into(),
attached_via: None,
scope: Some(scope.into()),
version: policy.get("version").and_then(Value::as_i64).map(|v| v.to_string()),
statements: vec![AuthorizationStatement {
id: format!("{policy_id}#0"),
sid: None,
effect: "Allow".into(),
actions: vec![role.to_string()],
not_actions: Vec::new(),
resources: vec![scope.into()],
not_resources: Vec::new(),
principals: vec![member.clone()],
not_principals: Vec::new(),
condition_keys,
}],
});
}
}
}
struct RolePermissions {
permissions: Vec<String>,
disabled: bool,
}
async fn fetch_role_permissions(
client: &Client,
token: &str,
role_name: &str,
) -> Result<RolePermissions> {
let url = if role_name.starts_with("roles/")
|| role_name.starts_with("projects/")
|| role_name.starts_with("organizations/")
{
format!("https://iam.googleapis.com/v1/{role_name}")
} else {
format!("https://iam.googleapis.com/v1/roles/{role_name}")
};
let resp = client.get(url).bearer_auth(token).send().await?;
let status = resp.status();
let body = resp.bytes().await?;
if let Some(disabled) = service_disabled_message(&body)? {
verbose_warn!("GCP access-map: IAM API disabled while expanding {role_name}: {disabled}");
return Ok(RolePermissions { permissions: Vec::new(), disabled: false });
}
if !status.is_success() {
return Err(anyhow!(
"Failed to expand permissions for {role_name}: HTTP {} {}",
status,
String::from_utf8_lossy(&body)
));
}
let json: Value = serde_json::from_slice(&body)?;
let permissions = json
.get("includedPermissions")
.and_then(|p| p.as_array())
.map(|arr| arr.iter().filter_map(|p| p.as_str().map(|s| s.to_string())).collect())
.unwrap_or_default();
let stage = json.get("stage").and_then(Value::as_str).unwrap_or_default();
let deleted = json.get("deleted").and_then(Value::as_bool).unwrap_or(false);
Ok(RolePermissions { permissions, disabled: deleted || stage.eq_ignore_ascii_case("DISABLED") })
}
fn classify_permissions(roles: &[RoleBinding]) -> PermissionSummary {
let mut admin = HashSet::new();
let mut privilege_escalation = HashSet::new();
let mut risky = HashSet::new();
let mut read_only = HashSet::new();
for role in roles {
let role_lower = role.name.to_lowercase();
if role_lower.contains("owner")
|| role_lower.contains("admin")
|| role.name == "roles/editor"
{
admin.insert(role.name.clone());
}
for perm in &role.permissions {
if perm.contains("*") {
risky.insert(perm.clone());
continue;
}
if perm.contains("setIamPolicy")
|| perm.contains("serviceAccountTokenCreator")
|| perm.contains("serviceAccounts.actAs")
|| perm.contains("roles.create")
|| perm.contains("roles.update")
{
privilege_escalation.insert(perm.clone());
continue;
}
if perm.contains(".get") || perm.contains(".list") {
read_only.insert(perm.clone());
continue;
}
risky.insert(perm.clone());
}
}
PermissionSummary {
admin: sorted(admin),
privilege_escalation: sorted(privilege_escalation),
risky: sorted(risky),
read_only: sorted(read_only),
}
}
fn derive_severity(permissions: &PermissionSummary) -> 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() {
Severity::Medium
} else {
Severity::Low
}
}
fn collect_permission_set(roles: &[RoleBinding]) -> HashSet<String> {
let mut perms = HashSet::new();
for role in roles {
for perm in &role.permissions {
perms.insert(perm.clone());
}
}
perms
}
fn matching_permissions(perm_set: &HashSet<String>, prefixes: &[&str]) -> Vec<String> {
let mut matches: Vec<String> = perm_set
.iter()
.filter(|perm| *perm == "*" || prefixes.iter().any(|prefix| perm.starts_with(prefix)))
.cloned()
.collect();
matches.sort();
matches
}
async fn enumerate_resources(
client: &Client,
token: &str,
project_id: &str,
permissions: &PermissionSummary,
roles: &[RoleBinding],
) -> Result<Vec<ResourceExposure>> {
let mut resources = Vec::new();
let perm_set = collect_permission_set(roles);
let mut add_storage = false;
let mut add_bigquery = false;
let mut add_secret_manager = false;
let mut add_compute = false;
let mut add_cloud_sql = false;
let mut add_pubsub = false;
let mut add_cloud_run = false;
let mut add_artifact_registry = false;
let mut add_gke = false;
let mut add_cloud_kms = false;
let mut add_cloud_functions = false;
let mut add_service_accounts = false;
let mut add_firestore = false;
let mut add_spanner = false;
for perm in permissions
.risky
.iter()
.chain(permissions.read_only.iter())
.chain(permissions.privilege_escalation.iter())
{
if perm.starts_with("storage.buckets.list") || perm.starts_with("storage.buckets.get") {
add_storage = true;
}
if perm.starts_with("bigquery.datasets.list") || perm.starts_with("bigquery.datasets.get") {
add_bigquery = true;
}
if perm.starts_with("secretmanager.secrets.list") {
add_secret_manager = true;
}
if perm.starts_with("compute.instances.list") || perm.starts_with("compute.instances.get") {
add_compute = true;
}
if perm.starts_with("cloudsql.instances.list") || perm.starts_with("sql.instances.list") {
add_cloud_sql = true;
}
if perm.starts_with("pubsub.topics.list") || perm.starts_with("pubsub.subscriptions.list") {
add_pubsub = true;
}
if perm.starts_with("run.services.list") {
add_cloud_run = true;
}
if perm.starts_with("artifactregistry.repositories.list") {
add_artifact_registry = true;
}
if perm.starts_with("container.clusters.list") {
add_gke = true;
}
if perm.starts_with("cloudkms.cryptoKeys.list")
|| perm.starts_with("cloudkms.keyRings.list")
{
add_cloud_kms = true;
}
if perm.starts_with("cloudfunctions.functions.list") {
add_cloud_functions = true;
}
if perm.starts_with("iam.serviceAccounts.list") {
add_service_accounts = true;
}
if perm.starts_with("datastore.databases.list")
|| perm.starts_with("datastore.entities.list")
{
add_firestore = true;
}
if perm.starts_with("spanner.instances.list") {
add_spanner = true;
}
}
if add_storage {
let url = format!("https://storage.googleapis.com/storage/v1/b?project={}", project_id);
let resp = client.get(&url).bearer_auth(token).send().await?;
let status = resp.status();
let body = resp.bytes().await?;
if let Some(disabled) = service_disabled_message(&body)? {
verbose_warn!(
"GCP access-map: Cloud Storage API disabled for project {project_id}: {disabled}"
);
} else if status.is_success() {
let json: Value = serde_json::from_slice(&body)?;
if let Some(items) = json.get("items").and_then(|i| i.as_array()) {
let writable = perm_set.iter().any(|p| {
p.starts_with("storage.objects.create")
|| p.starts_with("storage.objects.update")
|| p.starts_with("storage.objects.delete")
});
for bucket in items {
if let Some(name) = bucket.get("name").and_then(|n| n.as_str()) {
resources.push(ResourceExposure {
resource_type: "storage_bucket".into(),
name: format!("projects/{project_id}/buckets/{name}"),
permissions: matching_permissions(
&perm_set,
&["storage.buckets.", "storage.objects."],
),
risk: if writable { "high".into() } else { "medium".into() },
reason: if writable {
"Service account can list and write bucket objects".into()
} else {
"Service account can list bucket contents".into()
},
});
}
}
}
} else if status != StatusCode::FORBIDDEN {
verbose_warn!(
"GCP access-map: storage enumeration failed: HTTP {} {}",
status,
String::from_utf8_lossy(&body)
);
}
}
if add_compute {
let url = format!(
"https://compute.googleapis.com/compute/v1/projects/{}/aggregated/instances",
project_id
);
let resp = client.get(&url).bearer_auth(token).send().await?;
let status = resp.status();
let body = resp.bytes().await?;
if let Some(disabled) = service_disabled_message(&body)? {
verbose_warn!(
"GCP access-map: Compute Engine API disabled for project {project_id}: {disabled}"
);
} else if status.is_success() {
let json: Value = serde_json::from_slice(&body)?;
if let Some(items) = json.get("items").and_then(|i| i.as_object()) {
let writable = perm_set.iter().any(|p| {
p.starts_with("compute.instances.insert")
|| p.starts_with("compute.instances.update")
|| p.starts_with("compute.instances.delete")
});
for zone in items.values() {
if let Some(instances) = zone.get("instances").and_then(|i| i.as_array()) {
for instance in instances {
if let Some(name) = instance.get("name").and_then(|n| n.as_str()) {
resources.push(ResourceExposure {
resource_type: "compute_instance".into(),
name: format!("projects/{project_id}/instances/{name}"),
permissions: matching_permissions(
&perm_set,
&["compute.instances."],
),
risk: if writable { "high".into() } else { "medium".into() },
reason: if writable {
"Service account can manage Compute Engine instances".into()
} else {
"Service account can list Compute Engine instances".into()
},
});
}
}
}
}
}
} else if status != StatusCode::FORBIDDEN {
verbose_warn!(
"GCP access-map: Compute Engine enumeration failed: HTTP {} {}",
status,
String::from_utf8_lossy(&body)
);
}
}
if add_cloud_sql {
let url = format!(
"https://sqladmin.googleapis.com/sql/v1beta4/projects/{}/instances",
project_id
);
let resp = client.get(&url).bearer_auth(token).send().await?;
let status = resp.status();
let body = resp.bytes().await?;
if let Some(disabled) = service_disabled_message(&body)? {
verbose_warn!(
"GCP access-map: Cloud SQL Admin API disabled for project {project_id}: {disabled}"
);
} else if status.is_success() {
let json: Value = serde_json::from_slice(&body)?;
if let Some(items) = json.get("items").and_then(|i| i.as_array()) {
let writable = perm_set.iter().any(|p| {
p.starts_with("cloudsql.instances.update")
|| p.starts_with("cloudsql.instances.create")
|| p.starts_with("cloudsql.instances.delete")
|| p.starts_with("sql.instances.update")
|| p.starts_with("sql.instances.create")
|| p.starts_with("sql.instances.delete")
});
for instance in items {
if let Some(name) = instance.get("name").and_then(|n| n.as_str()) {
resources.push(ResourceExposure {
resource_type: "cloudsql_instance".into(),
name: format!("projects/{project_id}/instances/{name}"),
permissions: matching_permissions(
&perm_set,
&["cloudsql.instances.", "sql.instances."],
),
risk: if writable { "high".into() } else { "medium".into() },
reason: if writable {
"Service account can manage Cloud SQL instances".into()
} else {
"Service account can list Cloud SQL instances".into()
},
});
}
}
}
} else if status != StatusCode::FORBIDDEN {
verbose_warn!(
"GCP access-map: Cloud SQL enumeration failed: HTTP {} {}",
status,
String::from_utf8_lossy(&body)
);
}
}
if add_pubsub {
let topics_url = format!("https://pubsub.googleapis.com/v1/projects/{project_id}/topics");
let subs_url =
format!("https://pubsub.googleapis.com/v1/projects/{project_id}/subscriptions");
let writable = perm_set.iter().any(|p| {
p.starts_with("pubsub.topics.publish")
|| p.starts_with("pubsub.topics.create")
|| p.starts_with("pubsub.subscriptions.create")
});
for (url, resource_type) in
[(topics_url, "pubsub_topic"), (subs_url, "pubsub_subscription")]
{
let resp = client.get(&url).bearer_auth(token).send().await?;
let status = resp.status();
let body = resp.bytes().await?;
if let Some(disabled) = service_disabled_message(&body)? {
verbose_warn!(
"GCP access-map: Pub/Sub API disabled for project {project_id}: {disabled}"
);
continue;
}
if status.is_success() {
let json: Value = serde_json::from_slice(&body)?;
let key = if resource_type == "pubsub_topic" { "topics" } else { "subscriptions" };
if let Some(items) = json.get(key).and_then(|i| i.as_array()) {
for item in items {
if let Some(name) = item.get("name").and_then(|n| n.as_str()) {
resources.push(ResourceExposure {
resource_type: resource_type.into(),
name: name.to_string(),
permissions: matching_permissions(
&perm_set,
&["pubsub.topics.", "pubsub.subscriptions."],
),
risk: if writable { "high".into() } else { "medium".into() },
reason: if writable {
"Service account can publish to or manage Pub/Sub resources"
.into()
} else {
"Service account can list Pub/Sub resources".into()
},
});
}
}
}
} else if status != StatusCode::FORBIDDEN {
verbose_warn!(
"GCP access-map: Pub/Sub enumeration failed for {resource_type}: HTTP {} {}",
status,
String::from_utf8_lossy(&body)
);
}
}
}
if add_cloud_run {
let url =
format!("https://run.googleapis.com/v2/projects/{}/locations/-/services", project_id);
let resp = client.get(&url).bearer_auth(token).send().await?;
let status = resp.status();
let body = resp.bytes().await?;
if let Some(disabled) = service_disabled_message(&body)? {
verbose_warn!(
"GCP access-map: Cloud Run API disabled for project {project_id}: {disabled}"
);
} else if status.is_success() {
let json: Value = serde_json::from_slice(&body)?;
if let Some(items) = json.get("services").and_then(|i| i.as_array()) {
let writable = perm_set.iter().any(|p| {
p.starts_with("run.services.update") || p.starts_with("run.services.create")
});
for service in items {
if let Some(name) = service.get("name").and_then(|n| n.as_str()) {
resources.push(ResourceExposure {
resource_type: "cloud_run_service".into(),
name: name.to_string(),
permissions: matching_permissions(&perm_set, &["run.services."]),
risk: if writable { "high".into() } else { "medium".into() },
reason: if writable {
"Service account can deploy or modify Cloud Run services".into()
} else {
"Service account can list Cloud Run services".into()
},
});
}
}
}
} else if status != StatusCode::FORBIDDEN {
verbose_warn!(
"GCP access-map: Cloud Run enumeration failed: HTTP {} {}",
status,
String::from_utf8_lossy(&body)
);
}
}
if add_artifact_registry {
let url = format!(
"https://artifactregistry.googleapis.com/v1/projects/{}/locations/-/repositories",
project_id
);
let resp = client.get(&url).bearer_auth(token).send().await?;
let status = resp.status();
let body = resp.bytes().await?;
if let Some(disabled) = service_disabled_message(&body)? {
verbose_warn!(
"GCP access-map: Artifact Registry API disabled for project {project_id}: {disabled}"
);
} else if status.is_success() {
let json: Value = serde_json::from_slice(&body)?;
if let Some(items) = json.get("repositories").and_then(|i| i.as_array()) {
let writable = perm_set.iter().any(|p| {
p.starts_with("artifactregistry.repositories.uploadArtifacts")
|| p.starts_with("artifactregistry.repositories.create")
|| p.starts_with("artifactregistry.repositories.update")
});
for repo in items {
if let Some(name) = repo.get("name").and_then(|n| n.as_str()) {
resources.push(ResourceExposure {
resource_type: "artifact_registry_repository".into(),
name: name.to_string(),
permissions: matching_permissions(&perm_set, &["artifactregistry."]),
risk: if writable { "high".into() } else { "medium".into() },
reason: if writable {
"Service account can push or modify Artifact Registry repositories"
.into()
} else {
"Service account can list Artifact Registry repositories".into()
},
});
}
}
}
} else if status != StatusCode::FORBIDDEN {
verbose_warn!(
"GCP access-map: Artifact Registry enumeration failed: HTTP {} {}",
status,
String::from_utf8_lossy(&body)
);
}
}
if add_gke {
let url = format!(
"https://container.googleapis.com/v1/projects/{}/locations/-/clusters",
project_id
);
let resp = client.get(&url).bearer_auth(token).send().await?;
let status = resp.status();
let body = resp.bytes().await?;
if let Some(disabled) = service_disabled_message(&body)? {
verbose_warn!(
"GCP access-map: Kubernetes Engine API disabled for project {project_id}: {disabled}"
);
} else if status.is_success() {
let json: Value = serde_json::from_slice(&body)?;
if let Some(items) = json.get("clusters").and_then(|i| i.as_array()) {
let writable = perm_set.iter().any(|p| {
p.starts_with("container.clusters.update")
|| p.starts_with("container.clusters.create")
});
for cluster in items {
if let Some(name) = cluster.get("name").and_then(|n| n.as_str()) {
resources.push(ResourceExposure {
resource_type: "gke_cluster".into(),
name: name.to_string(),
permissions: matching_permissions(&perm_set, &["container.clusters."]),
risk: if writable { "high".into() } else { "medium".into() },
reason: if writable {
"Service account can modify or create GKE clusters".into()
} else {
"Service account can list GKE clusters".into()
},
});
}
}
}
} else if status != StatusCode::FORBIDDEN {
verbose_warn!(
"GCP access-map: GKE enumeration failed: HTTP {} {}",
status,
String::from_utf8_lossy(&body)
);
}
}
if add_bigquery {
let url =
format!("https://bigquery.googleapis.com/bigquery/v2/projects/{}/datasets", project_id);
let resp = client.get(&url).bearer_auth(token).send().await?;
let status = resp.status();
let body = resp.bytes().await?;
if let Some(disabled) = service_disabled_message(&body)? {
verbose_warn!(
"GCP access-map: BigQuery API disabled for project {project_id}: {disabled}"
);
} else if status.is_success() {
let json: Value = serde_json::from_slice(&body)?;
if let Some(items) = json.get("datasets").and_then(|i| i.as_array()) {
let writable = perm_set.iter().any(|p| {
p.starts_with("bigquery.tables.update")
|| p.starts_with("bigquery.tables.delete")
|| p.starts_with("bigquery.jobs.create")
|| p.starts_with("bigquery.datasets.update")
|| p.starts_with("bigquery.datasets.delete")
});
for dataset in items {
if let Some(ds_id) = dataset
.get("datasetReference")
.and_then(|r| r.get("datasetId"))
.and_then(|n| n.as_str())
{
resources.push(ResourceExposure {
resource_type: "bigquery_dataset".into(),
name: format!("projects/{project_id}/datasets/{ds_id}"),
permissions: matching_permissions(
&perm_set,
&["bigquery.datasets.", "bigquery.tables.", "bigquery.jobs."],
),
risk: if writable { "high".into() } else { "medium".into() },
reason: if writable {
"Service account can modify BigQuery datasets or run jobs".into()
} else {
"Service account can list BigQuery datasets".into()
},
});
}
}
}
} else if status != StatusCode::FORBIDDEN {
verbose_warn!(
"GCP access-map: BigQuery enumeration failed: HTTP {} {}",
status,
String::from_utf8_lossy(&body)
);
}
}
if add_secret_manager {
let url =
format!("https://secretmanager.googleapis.com/v1/projects/{}/secrets", project_id);
let resp = client.get(&url).bearer_auth(token).send().await?;
let status = resp.status();
let body = resp.bytes().await?;
if let Some(disabled) = service_disabled_message(&body)? {
verbose_warn!(
"GCP access-map: Secret Manager API disabled for project {project_id}: {disabled}"
);
} else if status.is_success() {
let json: Value = serde_json::from_slice(&body)?;
if let Some(items) = json.get("secrets").and_then(|i| i.as_array()) {
let can_access_values =
perm_set.iter().any(|p| p.contains("secretmanager.versions.access"));
let can_write = perm_set.iter().any(|p| {
p.contains("secretmanager.secrets.create")
|| p.contains("secretmanager.secrets.update")
|| p.contains("secretmanager.versions.add")
});
let high_risk = can_access_values || can_write;
for secret in items {
if let Some(name) = secret.get("name").and_then(|n| n.as_str()) {
let reason = if can_access_values && can_write {
"Service account can read and write secret values"
} else if can_access_values {
"Service account can read secret values"
} else if can_write {
"Service account can create or modify secrets"
} else {
"Service account can list secrets"
};
resources.push(ResourceExposure {
resource_type: "secretmanager_secret".into(),
name: name.to_string(),
permissions: matching_permissions(
&perm_set,
&["secretmanager.secrets.", "secretmanager.versions."],
),
risk: if high_risk { "high".into() } else { "medium".into() },
reason: reason.into(),
});
}
}
}
} else if status != StatusCode::FORBIDDEN {
verbose_warn!(
"GCP access-map: Secret Manager enumeration failed: HTTP {} {}",
status,
String::from_utf8_lossy(&body)
);
}
}
if add_cloud_kms {
let url = format!(
"https://cloudkms.googleapis.com/v1/projects/{}/locations/-/keyRings",
project_id
);
let resp = client.get(&url).bearer_auth(token).send().await?;
let status = resp.status();
let body = resp.bytes().await?;
if let Some(disabled) = service_disabled_message(&body)? {
verbose_warn!(
"GCP access-map: Cloud KMS API disabled for project {project_id}: {disabled}"
);
} else if status.is_success() {
let json: Value = serde_json::from_slice(&body)?;
if let Some(key_rings) = json.get("keyRings").and_then(|k| k.as_array()) {
let can_decrypt = perm_set
.iter()
.any(|p| p.starts_with("cloudkms.cryptoKeyVersions.useToDecrypt"));
let can_encrypt = perm_set
.iter()
.any(|p| p.starts_with("cloudkms.cryptoKeyVersions.useToEncrypt"));
let writable = can_decrypt
|| perm_set.iter().any(|p| {
p.starts_with("cloudkms.cryptoKeys.create")
|| p.starts_with("cloudkms.cryptoKeys.update")
});
for kr in key_rings {
if let Some(name) = kr.get("name").and_then(|n| n.as_str()) {
let risk = if can_decrypt {
"critical"
} else if writable || can_encrypt {
"high"
} else {
"medium"
};
let reason = if can_decrypt {
"Service account can decrypt data using KMS keys in this key ring"
} else if writable {
"Service account can manage KMS keys in this key ring"
} else {
"Service account can list KMS key rings"
};
resources.push(ResourceExposure {
resource_type: "kms_key_ring".into(),
name: name.to_string(),
permissions: matching_permissions(
&perm_set,
&[
"cloudkms.cryptoKeys.",
"cloudkms.keyRings.",
"cloudkms.cryptoKeyVersions.",
],
),
risk: risk.into(),
reason: reason.into(),
});
}
}
}
} else if status != StatusCode::FORBIDDEN {
verbose_warn!(
"GCP access-map: Cloud KMS enumeration failed: HTTP {} {}",
status,
String::from_utf8_lossy(&body)
);
}
}
if add_cloud_functions {
let url = format!(
"https://cloudfunctions.googleapis.com/v2/projects/{}/locations/-/functions",
project_id
);
let resp = client.get(&url).bearer_auth(token).send().await?;
let status = resp.status();
let body = resp.bytes().await?;
if let Some(disabled) = service_disabled_message(&body)? {
verbose_warn!(
"GCP access-map: Cloud Functions API disabled for project {project_id}: {disabled}"
);
} else if status.is_success() {
let json: Value = serde_json::from_slice(&body)?;
if let Some(items) = json.get("functions").and_then(|f| f.as_array()) {
let writable = perm_set.iter().any(|p| {
p.starts_with("cloudfunctions.functions.create")
|| p.starts_with("cloudfunctions.functions.update")
|| p.starts_with("cloudfunctions.functions.delete")
});
for func in items {
if let Some(name) = func.get("name").and_then(|n| n.as_str()) {
resources.push(ResourceExposure {
resource_type: "cloud_function".into(),
name: name.to_string(),
permissions: matching_permissions(
&perm_set,
&["cloudfunctions.functions."],
),
risk: if writable { "high".into() } else { "medium".into() },
reason: if writable {
"Service account can deploy or modify Cloud Functions (code execution)".into()
} else {
"Service account can list Cloud Functions".into()
},
});
}
}
}
} else if status != StatusCode::FORBIDDEN {
verbose_warn!(
"GCP access-map: Cloud Functions enumeration failed: HTTP {} {}",
status,
String::from_utf8_lossy(&body)
);
}
}
if add_service_accounts {
let url = format!("https://iam.googleapis.com/v1/projects/{}/serviceAccounts", project_id);
let resp = client.get(&url).bearer_auth(token).send().await?;
let status = resp.status();
let body = resp.bytes().await?;
if let Some(disabled) = service_disabled_message(&body)? {
verbose_warn!("GCP access-map: IAM API disabled for project {project_id}: {disabled}");
} else if status.is_success() {
let json: Value = serde_json::from_slice(&body)?;
if let Some(accounts) = json.get("accounts").and_then(|a| a.as_array()) {
let can_impersonate = perm_set.iter().any(|p| {
p.contains("serviceAccounts.getAccessToken")
|| p.contains("serviceAccounts.getOpenIdToken")
});
let can_attach = perm_set.iter().any(|p| p.contains("serviceAccounts.actAs"));
for sa in accounts {
if let Some(email) = sa.get("email").and_then(|e| e.as_str()) {
resources.push(ResourceExposure {
resource_type: "service_account".into(),
name: format!("projects/{project_id}/serviceAccounts/{email}"),
permissions: matching_permissions(
&perm_set,
&["iam.serviceAccounts.", "iam.serviceAccountKeys."],
),
risk: if can_impersonate || can_attach {
"high".into()
} else {
"medium".into()
},
reason: if can_impersonate {
"Service account visible and token creation may be authorized".into()
} else if can_attach {
"Service account visible and may be attachable to a workload when additional workload permissions are present".into()
} else {
"Service account visible in the project".into()
},
});
}
}
}
} else if status != StatusCode::FORBIDDEN {
verbose_warn!(
"GCP access-map: service account enumeration failed: HTTP {} {}",
status,
String::from_utf8_lossy(&body)
);
}
}
if add_firestore {
let url = format!("https://firestore.googleapis.com/v1/projects/{}/databases", project_id);
let resp = client.get(&url).bearer_auth(token).send().await?;
let status = resp.status();
let body = resp.bytes().await?;
if let Some(disabled) = service_disabled_message(&body)? {
verbose_warn!(
"GCP access-map: Firestore API disabled for project {project_id}: {disabled}"
);
} else if status.is_success() {
let json: Value = serde_json::from_slice(&body)?;
if let Some(databases) = json.get("databases").and_then(|d| d.as_array()) {
let writable = perm_set.iter().any(|p| {
p.starts_with("datastore.entities.create")
|| p.starts_with("datastore.entities.update")
|| p.starts_with("datastore.entities.delete")
});
for db in databases {
if let Some(name) = db.get("name").and_then(|n| n.as_str()) {
resources.push(ResourceExposure {
resource_type: "firestore_database".into(),
name: name.to_string(),
permissions: matching_permissions(
&perm_set,
&["datastore.databases.", "datastore.entities."],
),
risk: if writable { "high".into() } else { "medium".into() },
reason: if writable {
"Service account can read and write Firestore data".into()
} else {
"Service account can list Firestore databases".into()
},
});
}
}
}
} else if status != StatusCode::FORBIDDEN {
verbose_warn!(
"GCP access-map: Firestore enumeration failed: HTTP {} {}",
status,
String::from_utf8_lossy(&body)
);
}
}
if add_spanner {
let url = format!("https://spanner.googleapis.com/v1/projects/{}/instances", project_id);
let resp = client.get(&url).bearer_auth(token).send().await?;
let status = resp.status();
let body = resp.bytes().await?;
if let Some(disabled) = service_disabled_message(&body)? {
verbose_warn!(
"GCP access-map: Cloud Spanner API disabled for project {project_id}: {disabled}"
);
} else if status.is_success() {
let json: Value = serde_json::from_slice(&body)?;
if let Some(instances) = json.get("instances").and_then(|i| i.as_array()) {
let writable = perm_set.iter().any(|p| {
p.starts_with("spanner.instances.update")
|| p.starts_with("spanner.instances.create")
|| p.starts_with("spanner.databases.write")
});
for instance in instances {
if let Some(name) = instance.get("name").and_then(|n| n.as_str()) {
resources.push(ResourceExposure {
resource_type: "spanner_instance".into(),
name: name.to_string(),
permissions: matching_permissions(
&perm_set,
&["spanner.instances.", "spanner.databases."],
),
risk: if writable { "high".into() } else { "medium".into() },
reason: if writable {
"Service account can manage Cloud Spanner instances".into()
} else {
"Service account can list Cloud Spanner instances".into()
},
});
}
}
}
} else if status != StatusCode::FORBIDDEN {
verbose_warn!(
"GCP access-map: Cloud Spanner enumeration failed: HTTP {} {}",
status,
String::from_utf8_lossy(&body)
);
}
}
Ok(resources)
}
async fn fetch_service_account_iam_policy(
client: &Client,
token: &str,
project_id: &str,
client_email: &str,
) -> Result<Option<Value>> {
let encoded_email = utf8_percent_encode(client_email, NON_ALPHANUMERIC);
let url = format!(
"https://iam.googleapis.com/v1/projects/{}/serviceAccounts/{}:getIamPolicy",
project_id, encoded_email
);
let resp = client
.post(&url)
.bearer_auth(token)
.json(&serde_json::json!({ "options": { "requestedPolicyVersion": 3 } }))
.send()
.await?;
let status = resp.status();
let body = resp.bytes().await?;
if let Some(disabled) = service_disabled_message(&body)? {
verbose_warn!(
"GCP access-map: IAM API disabled when fetching service account policy: {disabled}"
);
return Ok(None);
}
if status == StatusCode::FORBIDDEN {
verbose_warn!("GCP access-map: service account IAM policy forbidden for {client_email}");
return Ok(None);
}
if !status.is_success() {
return Err(anyhow!(
"Failed to fetch service account IAM policy: HTTP {} {}",
status,
String::from_utf8_lossy(&body)
));
}
let policy: Value = serde_json::from_slice(&body)?;
Ok(Some(policy))
}
async fn fetch_visible_service_account_bindings(
client: &Client,
token: &str,
source: &str,
resources: &[ResourceExposure],
roles: &[RoleBinding],
evidence: &mut AuthorizationEvidence,
) -> Vec<GcpIamBinding> {
if !collect_permission_set(roles).contains("iam.serviceAccounts.getIamPolicy") {
return Vec::new();
}
let mut targets = BTreeMap::<String, (String, String)>::new();
for resource in resources.iter().filter(|resource| resource.resource_type == "service_account")
{
let Some((project, email)) = parse_service_account_resource(&resource.name) else {
continue;
};
if email == source {
continue;
}
targets
.entry(resource.name.clone())
.or_insert_with(|| (project.to_string(), email.to_string()));
}
if targets.len() > MAX_SERVICE_ACCOUNT_POLICY_PROBES {
evidence.limitations.push(format!(
"Direct service-account impersonation checks were limited to {MAX_SERVICE_ACCOUNT_POLICY_PROBES} visible targets."
));
}
let requests = targets.into_iter().take(MAX_SERVICE_ACCOUNT_POLICY_PROBES).map(
|(scope, (project, email))| async move {
let result = fetch_service_account_iam_policy(client, token, &project, &email).await;
(scope, result)
},
);
let mut results: Vec<_> =
stream::iter(requests).buffer_unordered(SERVICE_ACCOUNT_POLICY_CONCURRENCY).collect().await;
results.sort_by(|left, right| left.0.cmp(&right.0));
let mut bindings = Vec::new();
let mut unreadable = 0usize;
for (scope, result) in results {
match result {
Ok(Some(policy)) => {
collect_iam_bindings(&policy, &scope, "service_account_binding", &mut bindings)
}
Ok(None) => unreadable += 1,
Err(err) => {
unreadable += 1;
verbose_warn!(
"GCP access-map: failed to fetch target service account policy for {scope}: {err}"
);
}
}
}
if unreadable > 0 {
evidence.limitations.push(format!(
"Direct impersonation bindings could not be checked on {unreadable} visible service account(s) because their IAM policies were unreadable."
));
}
bindings
}
fn parse_service_account_resource(resource: &str) -> Option<(&str, &str)> {
let mut parts = resource.split('/');
match (parts.next(), parts.next(), parts.next(), parts.next(), parts.next()) {
(Some("projects"), Some(project), Some("serviceAccounts"), Some(email), None) => {
Some((project, email))
}
_ => None,
}
}
async fn add_impersonation_binding_policy_evidence(
client: &Client,
token: &str,
bindings: &[GcpIamBinding],
role_permissions: &mut BTreeMap<String, Vec<String>>,
evidence: &mut AuthorizationEvidence,
) {
let mut expanded = 0usize;
let mut expansion_limited = false;
for binding in bindings {
let permissions = if let Some(permissions) = role_permissions.get(&binding.role) {
permissions.clone()
} else if expanded < MAX_IMPERSONATION_ROLE_EXPANSIONS {
expanded += 1;
let fetched =
fetch_role_permissions(client, token, &binding.role).await.unwrap_or_else(|err| {
verbose_warn!(
"GCP access-map: failed to expand direct impersonation role {}: {err}",
binding.role
);
evidence.limitations.push(format!(
"Permissions for direct impersonation role {} could not be expanded.",
binding.role
));
RolePermissions { permissions: Vec::new(), disabled: false }
});
if fetched.disabled {
evidence.limitations.push(format!(
"IAM role {} is disabled or deleted and was ignored.",
binding.role
));
continue;
}
role_permissions.insert(binding.role.clone(), fetched.permissions.clone());
fetched.permissions
} else {
expansion_limited = true;
continue;
};
if !impersonation_capabilities(&permissions).is_empty() {
upsert_binding_policy(evidence, binding, &permissions);
}
}
if expansion_limited {
evidence.limitations.push(format!(
"Direct impersonation analysis expanded at most {MAX_IMPERSONATION_ROLE_EXPANSIONS} additional IAM roles."
));
}
}
async fn add_incoming_impersonation_evidence(
client: &Client,
token: &str,
service_account: &str,
bindings: &[GcpIamBinding],
role_permissions: &mut BTreeMap<String, Vec<String>>,
evidence: &mut AuthorizationEvidence,
) -> Vec<String> {
let mut notes = Vec::new();
let mut expanded = 0usize;
for binding in bindings {
let permissions = if let Some(permissions) = role_permissions.get(&binding.role) {
permissions.clone()
} else if expanded < MAX_IMPERSONATION_ROLE_EXPANSIONS {
expanded += 1;
let permissions =
fetch_role_permissions(client, token, &binding.role).await.unwrap_or_else(|err| {
verbose_warn!(
"GCP access-map: failed to expand impersonation role {}: {err}",
binding.role
);
RolePermissions { permissions: Vec::new(), disabled: false }
});
if permissions.disabled {
evidence.limitations.push(format!(
"IAM role {} is disabled or deleted and was ignored.",
binding.role
));
continue;
}
role_permissions.insert(binding.role.clone(), permissions.permissions.clone());
permissions.permissions
} else {
continue;
};
let capabilities = impersonation_capabilities(&permissions);
if capabilities.is_empty() {
continue;
}
let statement_id = format!("{}#0", binding.id);
upsert_binding_policy(evidence, binding, &permissions);
for member in &binding.members {
if member == &format!("serviceAccount:{service_account}") {
continue;
}
for (permission, relationship) in &capabilities {
if evidence.paths.len() >= MAX_AUTHORIZATION_PATHS {
record_gcp_path_cap(evidence);
return notes;
}
notes.push(format!(
"{member} has {permission} on this service account through {}",
binding.role
));
evidence.paths.push(AuthorizationPath {
direction: Some("inbound".into()),
status: if binding.condition_keys.is_empty() {
"potential".into()
} else {
"conditional".into()
},
hops: vec![AuthorizationHop {
from: member.clone(),
to: service_account.into(),
relationship: (*relationship).into(),
}],
evidence: vec![statement_id.clone()],
conditions: binding.condition_keys.clone(),
});
}
}
}
if expanded >= MAX_IMPERSONATION_ROLE_EXPANSIONS {
evidence.limitations.push(format!(
"Impersonation analysis expanded at most {MAX_IMPERSONATION_ROLE_EXPANSIONS} additional IAM roles."
));
}
notes
}
#[derive(Clone, Debug)]
struct ReachableServiceAccount {
email: String,
status: String,
}
#[derive(Default)]
struct GcpImpersonationDiscovery {
roles: Vec<RoleBinding>,
impacts: Vec<RoleImpact>,
resources: Vec<ResourceExposure>,
notes: Vec<String>,
}
fn add_outgoing_impersonation_paths(
source: &str,
resources: &[ResourceExposure],
evidence: &mut AuthorizationEvidence,
) -> Vec<ReachableServiceAccount> {
let mut reachable = BTreeMap::<String, ReachableServiceAccount>::new();
let candidates: Vec<(String, Vec<String>, Vec<String>, String)> = evidence
.policies
.iter()
.filter(|policy| {
matches!(policy.kind.as_str(), "iam_binding" | "service_account_binding")
&& policy.statements.iter().any(|statement| {
statement
.principals
.iter()
.any(|principal| principal == &format!("serviceAccount:{source}"))
})
})
.flat_map(|policy| {
policy.statements.iter().filter_map(|statement| {
let capabilities = impersonation_capabilities(&statement.actions);
(!capabilities.is_empty()).then(|| {
(
statement.id.clone(),
statement.condition_keys.clone(),
capabilities
.iter()
.map(|(permission, _)| (*permission).to_string())
.collect(),
policy.scope.clone().unwrap_or_default(),
)
})
})
})
.collect();
for (statement_id, conditions, permissions, scope) in candidates {
for (permission, relationship) in impersonation_capabilities(&permissions) {
for resource in resources
.iter()
.filter(|resource| resource.resource_type == "service_account")
.take(128)
{
if !gcp_scope_contains_service_account(&scope, &resource.name) {
continue;
}
if evidence.paths.len() >= MAX_AUTHORIZATION_PATHS {
record_gcp_path_cap(evidence);
return reachable.into_values().collect();
}
let target = resource.name.rsplit('/').next().unwrap_or(&resource.name);
if target == source {
continue;
}
let status = if conditions.is_empty() { "potential" } else { "conditional" };
evidence.paths.push(AuthorizationPath {
direction: Some("outbound".into()),
status: status.into(),
hops: vec![AuthorizationHop {
from: source.into(),
to: target.into(),
relationship: relationship.into(),
}],
evidence: vec![statement_id.clone(), permission.into()],
conditions: conditions.clone(),
});
if !matches!(
permission,
"iam.serviceAccounts.getAccessToken"
| "iam.serviceAccounts.signJwt"
| "iam.serviceAccounts.signBlob"
) {
continue;
}
let entry = reachable.entry(target.to_string()).or_insert_with(|| {
ReachableServiceAccount { email: target.to_string(), status: status.into() }
});
if gcp_path_status_rank(status) > gcp_path_status_rank(&entry.status) {
entry.status = status.into();
}
}
}
}
reachable.into_values().collect()
}
fn gcp_scope_contains_service_account(scope: &str, target: &str) -> bool {
if scope.is_empty() || scope.starts_with("folders/") || scope.starts_with("organizations/") {
return true;
}
if scope.contains("/serviceAccounts/") {
return scope == target;
}
if let Some(project) = scope.strip_prefix("projects/").and_then(|value| value.split('/').next())
{
return target.starts_with(&format!("projects/{project}/serviceAccounts/"));
}
true
}
fn gcp_path_status_rank(status: &str) -> u8 {
match status {
"potential" => 2,
"conditional" => 1,
_ => 0,
}
}
async fn build_outgoing_impersonation_impacts(
client: &Client,
token: &str,
reachable: &[ReachableServiceAccount],
bindings: &[GcpIamBinding],
role_permissions: &mut BTreeMap<String, Vec<String>>,
evidence: &mut AuthorizationEvidence,
) -> GcpImpersonationDiscovery {
let mut impacts = BTreeMap::<(String, String), RoleImpact>::new();
let mut expanded = 0usize;
let mut expansion_limited = false;
let mut impact_limited = false;
for target in reachable {
let member = format!("serviceAccount:{}", target.email);
for binding in bindings.iter().filter(|binding| {
binding.kind == "iam_binding" && binding.members.iter().any(|item| item == &member)
}) {
let key = (target.email.clone(), binding.role.clone());
if !impacts.contains_key(&key) && impacts.len() >= MAX_IMPERSONATED_ROLE_IMPACTS {
impact_limited = true;
continue;
}
let permissions = if let Some(permissions) = role_permissions.get(&binding.role) {
permissions.clone()
} else if expanded < MAX_IMPERSONATION_ROLE_EXPANSIONS {
expanded += 1;
let fetched = fetch_role_permissions(client, token, &binding.role)
.await
.unwrap_or_else(|err| {
verbose_warn!(
"GCP access-map: failed to expand role {} for impersonated service account {}: {err}",
binding.role,
target.email
);
evidence.limitations.push(format!(
"Permissions for IAM role {} on impersonated service account {} could not be expanded.",
binding.role, target.email
));
RolePermissions { permissions: Vec::new(), disabled: false }
});
if fetched.disabled {
evidence.limitations.push(format!(
"IAM role {} is disabled or deleted and was ignored.",
binding.role
));
continue;
}
role_permissions.insert(binding.role.clone(), fetched.permissions.clone());
fetched.permissions
} else {
expansion_limited = true;
Vec::new()
};
upsert_binding_policy(evidence, binding, &permissions);
let binding_status =
if binding.condition_keys.is_empty() { "potential" } else { "conditional" };
let status =
if gcp_path_status_rank(&target.status) <= gcp_path_status_rank(binding_status) {
target.status.as_str()
} else {
binding_status
};
let impact = impacts.entry(key).or_insert_with(|| RoleImpact {
target: Some(target.email.clone()),
role: binding.role.clone(),
name: Some(binding.role.clone()),
status: status.into(),
hop_count: 1,
permissions: permissions.clone(),
grants: Vec::new(),
});
if gcp_path_status_rank(status) > gcp_path_status_rank(&impact.status) {
impact.status = status.into();
}
impact.permissions.extend(permissions.iter().cloned());
impact.permissions.sort();
impact.permissions.dedup();
impact.grants.push(AuthorizationGrant {
permissions,
excluded_permissions: Vec::new(),
resources: vec![binding.scope.clone()],
excluded_resources: Vec::new(),
condition_keys: binding.condition_keys.clone(),
evidence: vec![format!("{}#0", binding.id)],
});
}
}
if expansion_limited {
evidence.limitations.push(format!(
"Impersonated-service-account analysis expanded at most {MAX_IMPERSONATION_ROLE_EXPANSIONS} additional IAM roles."
));
}
if impact_limited {
evidence.limitations.push(format!(
"Impersonated-service-account impact was limited to {MAX_IMPERSONATED_ROLE_IMPACTS} target-role pairs."
));
}
let mut impacts: Vec<RoleImpact> = impacts.into_values().collect();
impacts.sort_by(|left, right| {
left.target.cmp(&right.target).then_with(|| left.role.cmp(&right.role))
});
let roles = impacts
.iter()
.map(|impact| RoleBinding {
name: impact.role.clone(),
source: format!(
"impersonated service account {} ({}; {} scope(s))",
impact.target.as_deref().unwrap_or("unknown"),
impact.status,
impact.grants.len()
),
permissions: impact.permissions.clone(),
})
.collect();
let resources = gcp_impersonated_resource_exposures(&impacts);
let notes = if impacts.is_empty() {
Vec::new()
} else {
let targets = impacts
.iter()
.filter_map(|impact| impact.target.as_deref())
.collect::<HashSet<_>>()
.len();
vec![format!(
"Potential service-account impersonation reaches {targets} identity/identities, {} role assignment(s), and {} hierarchy-scoped grant(s).",
impacts.len(),
impacts.iter().map(|impact| impact.grants.len()).sum::<usize>()
)]
};
GcpImpersonationDiscovery { roles, impacts, resources, notes }
}
fn gcp_impersonated_resource_exposures(impacts: &[RoleImpact]) -> Vec<ResourceExposure> {
let mut resources = Vec::new();
for impact in impacts {
let role = RoleBinding {
name: impact.role.clone(),
source: "impersonated".into(),
permissions: impact.permissions.clone(),
};
let summary = classify_permissions(std::slice::from_ref(&role));
let risk = if !summary.admin.is_empty() || !summary.privilege_escalation.is_empty() {
"critical"
} else if !summary.risky.is_empty() {
"high"
} else if !summary.read_only.is_empty() {
"medium"
} else {
"low"
};
for grant in &impact.grants {
for resource in &grant.resources {
resources.push(ResourceExposure {
resource_type: "impersonated_service_account_scope".into(),
name: resource.clone(),
permissions: grant.permissions.clone(),
risk: risk.into(),
reason: format!(
"Potentially reachable by impersonating {} and inheriting {} [{}]",
impact.target.as_deref().unwrap_or("unknown service account"),
impact.role,
impact.status
),
});
}
}
}
resources
}
fn record_gcp_path_cap(evidence: &mut AuthorizationEvidence) {
let note =
format!("Authorization path evidence was limited to {MAX_AUTHORIZATION_PATHS} paths.");
if !evidence.limitations.contains(¬e) {
evidence.limitations.push(note);
}
}
fn impersonation_capabilities(permissions: &[String]) -> Vec<(&str, &'static str)> {
let mappings = [
("iam.serviceAccounts.getAccessToken", "can_mint_access_token"),
("iam.serviceAccounts.getOpenIdToken", "can_mint_openid_token"),
("iam.serviceAccounts.actAs", "can_act_as"),
("iam.serviceAccounts.signJwt", "can_sign_jwt"),
("iam.serviceAccounts.signBlob", "can_sign_blob"),
("iam.serviceAccounts.implicitDelegation", "can_delegate"),
];
mappings
.into_iter()
.filter(|(permission, _)| permissions.iter().any(|candidate| candidate == permission))
.collect()
}
fn upsert_binding_policy(
evidence: &mut AuthorizationEvidence,
binding: &GcpIamBinding,
permissions: &[String],
) {
if let Some(policy) = evidence.policies.iter_mut().find(|policy| policy.id == binding.id) {
if let Some(statement) = policy.statements.first_mut() {
statement.actions = permissions.to_vec();
statement.principals = binding.members.clone();
}
return;
}
evidence.policies.push(PolicyEvidence {
id: binding.id.clone(),
name: Some(binding.role.clone()),
kind: binding.kind.clone(),
attached_to: binding.scope.clone(),
attached_via: None,
scope: Some(binding.scope.clone()),
version: binding.version.clone(),
statements: vec![AuthorizationStatement {
id: format!("{}#0", binding.id),
sid: None,
effect: "Allow".into(),
actions: permissions.to_vec(),
not_actions: Vec::new(),
resources: vec![binding.scope.clone()],
not_resources: Vec::new(),
principals: binding.members.clone(),
not_principals: Vec::new(),
condition_keys: binding.condition_keys.clone(),
}],
});
}
fn derive_risk_notes(roles: &[RoleBinding], permissions: &PermissionSummary) -> Vec<String> {
let mut notes = Vec::new();
if !permissions.admin.is_empty() {
notes.push(format!("Admin-level roles attached: {}", permissions.admin.join(", ")));
}
if !permissions.privilege_escalation.is_empty() {
notes.push(format!(
"Privilege escalation permissions detected: {}",
permissions.privilege_escalation.join(", ")
));
}
let perm_set = collect_permission_set(roles);
if perm_set.iter().any(|p| p.contains("serviceAccounts.actAs")) {
notes.push(
"Can attach other service accounts to workloads when the required workload permissions are also present (iam.serviceAccounts.actAs)".into(),
);
}
if perm_set.iter().any(|p| p.contains("resourcemanager.projects.setIamPolicy")) {
notes.push("Can modify project IAM policies".into());
}
if perm_set.iter().any(|p| {
p.starts_with("storage.") && (p.contains("objects.create") || p.contains("buckets.update"))
}) {
notes.push("Has write access to Cloud Storage resources".into());
}
if perm_set.iter().any(|p| p.contains("secretmanager.secrets.addVersion")) {
notes.push("Can write new versions into Secret Manager".into());
}
if perm_set.iter().any(|p| p.contains("secretmanager.versions.access")) {
notes.push("Can read secret values from Secret Manager".into());
}
if perm_set.iter().any(|p| p.contains("cloudkms.cryptoKeyVersions.useToDecrypt")) {
notes.push("Can decrypt data using Cloud KMS".into());
}
if perm_set.iter().any(|p| {
p.starts_with("cloudfunctions.functions.create")
|| p.starts_with("cloudfunctions.functions.update")
}) {
notes.push("Can deploy or modify Cloud Functions (code execution)".into());
}
if perm_set.iter().any(|p| p.contains("compute.instances.setMetadata")) {
notes.push("Can modify instance metadata (SSH key injection risk)".into());
}
if roles.iter().any(|r| r.source.starts_with("org:")) {
notes.push("Inherited organization-level roles detected".into());
}
if roles.iter().any(|r| r.source.starts_with("folder:")) {
notes.push("Inherited folder-level roles detected".into());
}
notes
}
fn sorted(items: HashSet<String>) -> Vec<String> {
let mut v: Vec<_> = items.into_iter().collect();
v.sort();
v
}
async fn test_project_permissions(
client: &Client,
token: &str,
project_id: &str,
) -> Result<Vec<String>> {
let candidates = vec![
"resourcemanager.projects.getIamPolicy",
"resourcemanager.projects.setIamPolicy",
"resourcemanager.projects.testIamPermissions",
"iam.serviceAccounts.actAs",
"iam.serviceAccounts.get",
"iam.serviceAccounts.list",
"iam.serviceAccounts.getAccessToken",
"iam.serviceAccountKeys.list",
"iam.serviceAccountTokenCreator",
"storage.buckets.list",
"storage.objects.list",
"compute.instances.list",
"compute.instances.create",
"compute.instances.setMetadata",
"bigquery.datasets.get",
"bigquery.tables.list",
"bigquery.jobs.create",
"secretmanager.secrets.list",
"secretmanager.versions.access",
"cloudsql.instances.list",
"pubsub.topics.list",
"pubsub.subscriptions.list",
"run.services.list",
"cloudfunctions.functions.list",
"cloudfunctions.functions.create",
"artifactregistry.repositories.list",
"container.clusters.list",
"cloudkms.keyRings.list",
"cloudkms.cryptoKeyVersions.useToDecrypt",
"spanner.instances.list",
"datastore.databases.list",
];
let url = format!(
"https://cloudresourcemanager.googleapis.com/v1/projects/{}:testIamPermissions",
project_id
);
let resp = client
.post(url)
.bearer_auth(token)
.json(&serde_json::json!({ "permissions": candidates }))
.send()
.await?;
if resp.status() == StatusCode::FORBIDDEN {
verbose_warn!("GCP access-map: testIamPermissions forbidden for project {project_id}");
return Ok(Vec::new());
}
let resp = resp.error_for_status()?;
let json: Value = resp.json().await?;
let permissions = json
.get("permissions")
.and_then(|p| p.as_array())
.map(|arr| arr.iter().filter_map(|p| p.as_str().map(|s| s.to_string())).collect())
.unwrap_or_default();
Ok(permissions)
}
async fn test_service_account_permissions(
client: &Client,
token: &str,
project_id: &str,
client_email: &str,
) -> Result<Vec<String>> {
let candidates = vec![
"iam.serviceAccounts.get",
"iam.serviceAccounts.getIamPolicy",
"iam.serviceAccounts.actAs",
"iam.serviceAccounts.getAccessToken",
"iam.serviceAccounts.getOpenIdToken",
"iam.serviceAccounts.signBlob",
"iam.serviceAccounts.signJwt",
"iam.serviceAccounts.implicitDelegation",
"iam.serviceAccountKeys.list",
];
let encoded_email = utf8_percent_encode(client_email, NON_ALPHANUMERIC);
let resource = format!("projects/{}/serviceAccounts/{}", project_id, encoded_email);
let url = format!("https://iam.googleapis.com/v1/{}:testIamPermissions", resource);
let resp = client
.post(url)
.bearer_auth(token)
.json(&serde_json::json!({ "permissions": candidates }))
.send()
.await?;
if resp.status() == StatusCode::FORBIDDEN {
verbose_warn!(
"GCP access-map: testIamPermissions forbidden for service account {client_email}"
);
return Ok(Vec::new());
}
let resp = resp.error_for_status()?;
let json: Value = resp.json().await?;
let permissions = json
.get("permissions")
.and_then(|p| p.as_array())
.map(|arr| arr.iter().filter_map(|p| p.as_str().map(|s| s.to_string())).collect())
.unwrap_or_default();
Ok(permissions)
}
fn service_disabled_message(body: &[u8]) -> Result<Option<String>> {
let parsed: Value = match serde_json::from_slice(body) {
Ok(v) => v,
Err(_) => return Ok(None),
};
let Some(error) = parsed.get("error") else {
return Ok(None);
};
if let Some(details) = error.get("details").and_then(|d| d.as_array()) {
for detail in details {
let reason = detail.get("reason").and_then(|r| r.as_str());
if reason == Some("SERVICE_DISABLED") {
let metadata = detail.get("metadata");
let service_title = metadata
.and_then(|m| m.get("serviceTitle"))
.and_then(|s| s.as_str())
.or_else(|| metadata.and_then(|m| m.get("service")).and_then(|s| s.as_str()))
.unwrap_or("unknown service");
let activation_url = metadata
.and_then(|m| m.get("activationUrl"))
.and_then(|s| s.as_str())
.unwrap_or("https://console.developers.google.com/apis/dashboard");
return Ok(Some(format!(
"{service_title} is disabled; enable it at {activation_url}"
)));
}
}
}
Ok(None)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn inherited_binding_retains_scope_role_and_condition_names() {
let policy = serde_json::json!({
"version": 3,
"bindings": [{
"role": "roles/storage.objectAdmin",
"members": ["serviceAccount:scanner@example.iam.gserviceaccount.com"],
"condition": {
"title": "temporary",
"expression": "request.time < timestamp('2030-01-01T00:00:00Z')"
}
}]
});
let mut roles = Vec::new();
let mut evidence = AuthorizationEvidence::default();
collect_roles(
&policy,
"scanner@example.iam.gserviceaccount.com",
"folder:456",
"folders/456",
&mut roles,
&mut evidence,
);
assert_eq!(roles, [("roles/storage.objectAdmin".into(), "folder:456".into())]);
assert_eq!(evidence.policies.len(), 1);
assert_eq!(evidence.policies[0].scope.as_deref(), Some("folders/456"));
assert_eq!(evidence.policies[0].version.as_deref(), Some("3"));
assert_eq!(evidence.policies[0].statements[0].condition_keys, ["expression", "title"]);
let serialized = serde_json::to_string(&evidence).unwrap();
assert!(!serialized.contains("2030-01-01"));
}
#[tokio::test]
async fn service_account_policy_creates_conditional_incoming_path() {
let policy = serde_json::json!({
"bindings": [{
"role": "roles/iam.serviceAccountTokenCreator",
"members": ["serviceAccount:caller@example.iam.gserviceaccount.com"],
"condition": {"expression": "resource.name.startsWith('projects/example')"}
}]
});
let mut bindings = Vec::new();
collect_iam_bindings(
&policy,
"projects/example/serviceAccounts/target@example.iam.gserviceaccount.com",
"service_account_binding",
&mut bindings,
);
let mut role_permissions = BTreeMap::from([(
"roles/iam.serviceAccountTokenCreator".into(),
vec!["iam.serviceAccounts.getAccessToken".into()],
)]);
let mut evidence = AuthorizationEvidence::default();
let notes = add_incoming_impersonation_evidence(
&Client::new(),
"unused",
"target@example.iam.gserviceaccount.com",
&bindings,
&mut role_permissions,
&mut evidence,
)
.await;
assert_eq!(notes.len(), 1);
assert_eq!(evidence.paths.len(), 1);
assert_eq!(evidence.paths[0].direction.as_deref(), Some("inbound"));
assert_eq!(evidence.paths[0].status, "conditional");
assert_eq!(evidence.paths[0].hops[0].relationship, "can_mint_access_token");
assert_eq!(evidence.paths[0].conditions, ["expression"]);
}
#[test]
fn token_creation_permission_links_visible_service_accounts() {
let resources = vec![ResourceExposure {
resource_type: "service_account".into(),
name: "projects/example/serviceAccounts/target@example.iam.gserviceaccount.com".into(),
permissions: Vec::new(),
risk: "high".into(),
reason: "visible".into(),
}];
let mut evidence = AuthorizationEvidence {
policies: vec![PolicyEvidence {
id: "binding".into(),
name: Some("roles/iam.serviceAccountTokenCreator".into()),
kind: "iam_binding".into(),
attached_to: "source@example.iam.gserviceaccount.com".into(),
scope: Some("projects/example".into()),
statements: vec![AuthorizationStatement {
id: "binding#0".into(),
effect: "Allow".into(),
actions: vec!["iam.serviceAccounts.getAccessToken".into()],
principals: vec![
"serviceAccount:source@example.iam.gserviceaccount.com".into(),
],
condition_keys: vec!["expression".into()],
..AuthorizationStatement::default()
}],
..PolicyEvidence::default()
}],
..AuthorizationEvidence::default()
};
add_outgoing_impersonation_paths(
"source@example.iam.gserviceaccount.com",
&resources,
&mut evidence,
);
assert_eq!(evidence.paths.len(), 1);
assert_eq!(evidence.paths[0].direction.as_deref(), Some("outbound"));
assert_eq!(evidence.paths[0].status, "conditional");
assert_eq!(evidence.paths[0].hops[0].relationship, "can_mint_access_token");
assert_eq!(evidence.paths[0].hops[0].to, "target@example.iam.gserviceaccount.com");
assert_eq!(evidence.paths[0].conditions, ["expression"]);
}
#[test]
fn act_as_path_does_not_inherit_target_service_account_roles() {
let source = "source@example.iam.gserviceaccount.com";
let target = "target@example.iam.gserviceaccount.com";
let resources = vec![ResourceExposure {
resource_type: "service_account".into(),
name: format!("projects/example/serviceAccounts/{target}"),
permissions: Vec::new(),
risk: "high".into(),
reason: "visible".into(),
}];
let mut evidence = AuthorizationEvidence {
policies: vec![PolicyEvidence {
id: "source-user".into(),
name: Some("roles/iam.serviceAccountUser".into()),
kind: "iam_binding".into(),
attached_to: "projects/example".into(),
scope: Some("projects/example".into()),
statements: vec![AuthorizationStatement {
id: "source-user#0".into(),
effect: "Allow".into(),
actions: vec!["iam.serviceAccounts.actAs".into()],
principals: vec![format!("serviceAccount:{source}")],
..AuthorizationStatement::default()
}],
..PolicyEvidence::default()
}],
..AuthorizationEvidence::default()
};
let reachable = add_outgoing_impersonation_paths(source, &resources, &mut evidence);
assert!(reachable.is_empty());
assert_eq!(evidence.paths.len(), 1);
assert_eq!(evidence.paths[0].hops[0].relationship, "can_act_as");
}
#[test]
fn signing_path_inherits_target_service_account_roles() {
let source = "source@example.iam.gserviceaccount.com";
let target = "target@example.iam.gserviceaccount.com";
let resources = vec![ResourceExposure {
resource_type: "service_account".into(),
name: format!("projects/example/serviceAccounts/{target}"),
permissions: Vec::new(),
risk: "high".into(),
reason: "visible".into(),
}];
let mut evidence = AuthorizationEvidence {
policies: vec![PolicyEvidence {
id: "source-token-creator".into(),
name: Some("roles/iam.serviceAccountTokenCreator".into()),
kind: "iam_binding".into(),
attached_to: "projects/example".into(),
scope: Some("projects/example".into()),
statements: vec![AuthorizationStatement {
id: "source-token-creator#0".into(),
effect: "Allow".into(),
actions: vec!["iam.serviceAccounts.signJwt".into()],
principals: vec![format!("serviceAccount:{source}")],
..AuthorizationStatement::default()
}],
..PolicyEvidence::default()
}],
..AuthorizationEvidence::default()
};
let reachable = add_outgoing_impersonation_paths(source, &resources, &mut evidence);
assert_eq!(reachable.len(), 1);
assert_eq!(reachable[0].email, target);
assert_eq!(evidence.paths[0].hops[0].relationship, "can_sign_jwt");
}
#[tokio::test]
async fn impersonatable_service_account_roles_expose_their_hierarchy_scope() {
let source = "source@example.iam.gserviceaccount.com";
let target = "target@example.iam.gserviceaccount.com";
let resources = vec![ResourceExposure {
resource_type: "service_account".into(),
name: format!("projects/example/serviceAccounts/{target}"),
permissions: Vec::new(),
risk: "high".into(),
reason: "visible".into(),
}];
let mut evidence = AuthorizationEvidence {
policies: vec![PolicyEvidence {
id: "source-token-creator".into(),
name: Some("roles/iam.serviceAccountTokenCreator".into()),
kind: "iam_binding".into(),
attached_to: "projects/example".into(),
scope: Some("projects/example".into()),
statements: vec![AuthorizationStatement {
id: "source-token-creator#0".into(),
effect: "Allow".into(),
actions: vec!["iam.serviceAccounts.getAccessToken".into()],
resources: vec!["projects/example".into()],
principals: vec![format!("serviceAccount:{source}")],
..AuthorizationStatement::default()
}],
..PolicyEvidence::default()
}],
..AuthorizationEvidence::default()
};
let reachable = add_outgoing_impersonation_paths(source, &resources, &mut evidence);
let bindings = vec![GcpIamBinding {
id: "target-storage-admin".into(),
role: "roles/storage.objectAdmin".into(),
members: vec![format!("serviceAccount:{target}")],
scope: "projects/example".into(),
kind: "iam_binding".into(),
version: Some("3".into()),
condition_keys: vec!["expression".into()],
}];
let mut role_permissions = BTreeMap::from([(
"roles/storage.objectAdmin".into(),
vec!["storage.objects.get".into(), "storage.objects.create".into()],
)]);
let discovery = build_outgoing_impersonation_impacts(
&Client::new(),
"unused",
&reachable,
&bindings,
&mut role_permissions,
&mut evidence,
)
.await;
assert_eq!(reachable.len(), 1);
assert_eq!(discovery.impacts.len(), 1);
let impact = &discovery.impacts[0];
assert_eq!(impact.target.as_deref(), Some(target));
assert_eq!(impact.role, "roles/storage.objectAdmin");
assert_eq!(impact.status, "conditional");
assert_eq!(impact.grants[0].resources, ["projects/example"]);
assert_eq!(impact.grants[0].condition_keys, ["expression"]);
assert_eq!(discovery.roles[0].permissions.len(), 2);
let effective = classify_permissions(&discovery.roles);
assert_eq!(effective.admin, ["roles/storage.objectAdmin"]);
assert_eq!(discovery.resources[0].resource_type, "impersonated_service_account_scope");
assert!(discovery.resources[0].reason.contains(target));
}
#[tokio::test]
async fn target_specific_binding_creates_outgoing_impersonation_path() {
let source = "source@example.iam.gserviceaccount.com";
let target = "target@example.iam.gserviceaccount.com";
let resources = vec![ResourceExposure {
resource_type: "service_account".into(),
name: format!("projects/example/serviceAccounts/{target}"),
permissions: Vec::new(),
risk: "high".into(),
reason: "visible".into(),
}];
let bindings = vec![GcpIamBinding {
id: "target-token-creator".into(),
role: "roles/iam.serviceAccountTokenCreator".into(),
members: vec![format!("serviceAccount:{source}")],
scope: format!("projects/example/serviceAccounts/{target}"),
kind: "service_account_binding".into(),
version: Some("3".into()),
condition_keys: Vec::new(),
}];
let mut role_permissions = BTreeMap::from([(
"roles/iam.serviceAccountTokenCreator".into(),
vec!["iam.serviceAccounts.getAccessToken".into()],
)]);
let mut evidence = AuthorizationEvidence::default();
add_impersonation_binding_policy_evidence(
&Client::new(),
"unused",
&bindings,
&mut role_permissions,
&mut evidence,
)
.await;
let reachable = add_outgoing_impersonation_paths(source, &resources, &mut evidence);
assert_eq!(reachable.len(), 1);
assert_eq!(reachable[0].email, target);
assert_eq!(evidence.paths.len(), 1);
assert_eq!(evidence.paths[0].hops[0].to, target);
assert_eq!(evidence.paths[0].hops[0].relationship, "can_mint_access_token");
assert_eq!(
evidence.paths[0].evidence,
["target-token-creator#0", "iam.serviceAccounts.getAccessToken"]
);
}
#[test]
fn parses_only_canonical_service_account_resources() {
assert_eq!(
parse_service_account_resource(
"projects/example/serviceAccounts/target@example.iam.gserviceaccount.com"
),
Some(("example", "target@example.iam.gserviceaccount.com"))
);
assert_eq!(parse_service_account_resource("projects/example"), None);
}
#[test]
fn project_scoped_impersonation_does_not_cross_projects() {
assert!(gcp_scope_contains_service_account(
"projects/allowed",
"projects/allowed/serviceAccounts/target@example.iam.gserviceaccount.com"
));
assert!(!gcp_scope_contains_service_account(
"projects/allowed",
"projects/other/serviceAccounts/target@example.iam.gserviceaccount.com"
));
}
#[test]
fn outgoing_paths_are_capped() {
let resources = (0..(MAX_AUTHORIZATION_PATHS + 10))
.map(|index| ResourceExposure {
resource_type: "service_account".into(),
name: format!(
"projects/example/serviceAccounts/target-{index}@example.iam.gserviceaccount.com"
),
permissions: Vec::new(),
risk: "high".into(),
reason: "visible".into(),
})
.collect::<Vec<_>>();
let mut evidence = AuthorizationEvidence {
policies: vec![PolicyEvidence {
id: "binding".into(),
kind: "iam_binding".into(),
attached_to: "projects/example".into(),
statements: vec![AuthorizationStatement {
id: "binding#0".into(),
effect: "Allow".into(),
actions: vec![
"iam.serviceAccounts.getAccessToken".into(),
"iam.serviceAccounts.actAs".into(),
"iam.serviceAccounts.signJwt".into(),
],
principals: vec![
"serviceAccount:source@example.iam.gserviceaccount.com".into(),
],
..AuthorizationStatement::default()
}],
..PolicyEvidence::default()
}],
..AuthorizationEvidence::default()
};
add_outgoing_impersonation_paths(
"source@example.iam.gserviceaccount.com",
&resources,
&mut evidence,
);
assert!(evidence.paths.len() <= MAX_AUTHORIZATION_PATHS);
assert!(evidence.limitations.iter().any(|note| note.contains("limited")));
}
}