From 0496d39432ac46702c45e1370e20e5e205dd260c Mon Sep 17 00:00:00 2001 From: p4gs <10093271+p4gs@users.noreply.github.com> Date: Sat, 7 Mar 2026 10:54:01 -0500 Subject: [PATCH] feat(modules): add GCP IAM policy observer and public bucket tester Add two new GCP modules: - gcp.iam_policy observer: queries Resource Manager API for IAM policy bindings, checks for overly permissive roles (owner, editor, etc.) - gcp.public_bucket tester: performs unauthenticated access probe against Cloud Storage buckets to verify public access is denied Includes OAuth2 JWT-based service account authentication with RS256 signing for the observer, and full test transcripts for the tester. Both modules use stdlib HTTP via ureq (no GCP SDK dependency). Closes #12 Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 185 +++++++++++ Cargo.toml | 5 +- src/modules/observers/gcp.rs | 618 +++++++++++++++++++++++++++++++++++ src/modules/observers/mod.rs | 2 + src/modules/testers/gcp.rs | 486 +++++++++++++++++++++++++++ src/modules/testers/mod.rs | 2 + 6 files changed, 1296 insertions(+), 2 deletions(-) create mode 100644 src/modules/observers/gcp.rs create mode 100644 src/modules/testers/gcp.rs diff --git a/Cargo.lock b/Cargo.lock index 6ea98e3..3615377 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -193,6 +193,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.5.3" @@ -366,6 +372,12 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -476,6 +488,17 @@ dependencies = [ "syn", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "diff" version = "0.1.13" @@ -489,6 +512,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", "subtle", ] @@ -1080,6 +1104,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "leb128fmt" @@ -1093,6 +1120,12 @@ version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.12" @@ -1236,6 +1269,42 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1243,6 +1312,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -1261,6 +1331,7 @@ dependencies = [ "hmac", "ratatui", "regex", + "rsa", "rusqlite", "serde", "serde_json", @@ -1317,6 +1388,15 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1374,6 +1454,27 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.32" @@ -1389,6 +1490,15 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "precomputed-hash" version = "0.1.1" @@ -1429,6 +1539,35 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "ratatui" version = "0.29.0" @@ -1519,6 +1658,26 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rusqlite" version = "0.31.0" @@ -1754,6 +1913,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + [[package]] name = "simd-adler32" version = "0.3.8" @@ -1788,6 +1957,22 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" diff --git a/Cargo.toml b/Cargo.toml index 3a5771d..11e6d20 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,11 +31,12 @@ rusqlite = { version = "0.31", features = ["bundled"] } uuid = { version = "1", features = ["v4", "serde"] } chrono = { version = "0.4", features = ["serde"] } -# Cryptography (AWS SigV4 signing — no attestation) -sha2 = "0.10" +# Cryptography (AWS SigV4 signing, GCP JWT signing — no attestation) +sha2 = { version = "0.10", features = ["oid"] } hmac = "0.12" base64 = "0.22" hex = "0.4" +rsa = "0.9" # HTTP (sync) ureq = { version = "2", features = ["json"] } diff --git a/src/modules/observers/gcp.rs b/src/modules/observers/gcp.rs new file mode 100644 index 0000000..312a2e3 --- /dev/null +++ b/src/modules/observers/gcp.rs @@ -0,0 +1,618 @@ +use std::collections::HashMap; + +use anyhow::{anyhow, Result}; +use chrono::Utc; +use serde::Deserialize; +use serde_json::json; +use uuid::Uuid; + +use crate::evidence::{ + ConfidenceLevel, Evidence, Finding, Metadata, ModuleInfo, Observable, SourceInfo, StatusId, +}; +use crate::module::{observer::Observer, CredentialReq, Module}; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const DEFAULT_RM_ENDPOINT: &str = "https://cloudresourcemanager.googleapis.com"; +const DEFAULT_TOKEN_ENDPOINT: &str = "https://oauth2.googleapis.com/token"; +const API_VERSION: &str = "v1"; + +const OVERLY_PERMISSIVE_ROLES: &[&str] = &[ + "roles/owner", + "roles/editor", + "roles/iam.securityAdmin", + "roles/resourcemanager.projectIamAdmin", +]; + +// ─── OAuth2 helpers ────────────────────────────────────────────────────────── + +#[derive(Deserialize)] +struct ServiceAccountKey { + client_email: String, + private_key: String, + token_uri: Option, +} + +#[derive(Deserialize)] +struct TokenResponse { + access_token: String, +} + +/// Base64url-encode bytes (no padding). +fn base64url_encode(data: &[u8]) -> String { + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use base64::Engine; + URL_SAFE_NO_PAD.encode(data) +} + +/// Create a signed JWT and exchange it for an OAuth2 access token. +/// Uses RS256 (RSA + SHA-256) signing per Google's service account auth flow. +fn get_access_token( + sa_key: &ServiceAccountKey, + token_endpoint: &str, +) -> Result { + use rsa::pkcs8::DecodePrivateKey; + use rsa::RsaPrivateKey; + use sha2::{Digest, Sha256}; + + let now = Utc::now().timestamp(); + let header = json!({"alg": "RS256", "typ": "JWT"}); + let claims = json!({ + "iss": sa_key.client_email, + "scope": "https://www.googleapis.com/auth/cloud-platform", + "aud": token_endpoint, + "iat": now, + "exp": now + 3600, + }); + + let header_b64 = base64url_encode(header.to_string().as_bytes()); + let claims_b64 = base64url_encode(claims.to_string().as_bytes()); + let unsigned = format!("{}.{}", header_b64, claims_b64); + + // Parse PEM private key and sign with RS256. + let pem = sa_key.private_key.replace("\\n", "\n"); + let private_key = RsaPrivateKey::from_pkcs8_pem(&pem) + .map_err(|e| anyhow!("failed to parse GCP service account private key: {}", e))?; + + let hash = Sha256::digest(unsigned.as_bytes()); + let padding = rsa::Pkcs1v15Sign::new::(); + let signature = private_key + .sign(padding, &hash) + .map_err(|e| anyhow!("JWT signing failed: {}", e))?; + + let jwt = format!("{}.{}", unsigned, base64url_encode(&signature)); + + // Exchange JWT for access token. + let resp = ureq::post(token_endpoint) + .set("Content-Type", "application/x-www-form-urlencoded") + .send_string(&format!( + "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion={}", + jwt + )) + .map_err(|e| anyhow!("GCP token exchange failed: {}", e))?; + + let token_resp: TokenResponse = resp + .into_json() + .map_err(|e| anyhow!("parsing GCP token response: {}", e))?; + + Ok(token_resp.access_token) +} + +// ─── GCP API helpers ───────────────────────────────────────────────────────── + +#[derive(Deserialize)] +struct IamPolicy { + bindings: Option>, +} + +#[derive(Deserialize)] +struct IamBinding { + role: String, + members: Vec, +} + +fn fetch_iam_policy( + access_token: &str, + project_id: &str, + base_url: &str, +) -> Result { + let url = format!( + "{}/{}/projects/{}:getIamPolicy", + base_url.trim_end_matches('/'), + API_VERSION, + project_id + ); + + let resp = ureq::post(&url) + .set("Authorization", &format!("Bearer {}", access_token)) + .set("Content-Type", "application/json") + .send_string("{}") + .map_err(|e| anyhow!("GCP getIamPolicy request failed: {}", e))?; + + resp.into_json::() + .map_err(|e| anyhow!("parsing GCP IAM policy response: {}", e)) +} + +// ─── GcpIamPolicyObserver ──────────────────────────────────────────────────── + +/// Queries GCP Resource Manager API for IAM policy bindings and checks +/// for overly permissive roles (roles/owner, roles/editor, etc.). +/// +/// Required config keys: `GCP_SERVICE_ACCOUNT_KEY` (JSON string or path), +/// `GCP_PROJECT_ID`. +/// Optional: `GCP_BASE_URL` (test override), `GCP_ACCESS_TOKEN` (skip JWT flow). +pub struct GcpIamPolicyObserver; + +impl Module for GcpIamPolicyObserver { + fn id(&self) -> &str { + "gcp.iam_policy" + } + fn name(&self) -> &str { + "GCP IAM Policy Observer" + } + fn version(&self) -> &str { + "0.1.0" + } + fn source_system(&self) -> &str { + "gcp" + } + fn evidence_types(&self) -> &[i32] { + &[1002] + } + + fn credential_requirements(&self) -> Vec { + vec![ + CredentialReq { + name: "GCP_SERVICE_ACCOUNT_KEY".to_string(), + cred_type: "secret".to_string(), + description: "GCP service account key JSON (string or file path)".to_string(), + required: true, + }, + CredentialReq { + name: "GCP_PROJECT_ID".to_string(), + cred_type: "config".to_string(), + description: "GCP project ID to query".to_string(), + required: true, + }, + ] + } +} + +impl Observer for GcpIamPolicyObserver { + fn observe(&self, config: &HashMap) -> Result> { + let project_id = config + .get("GCP_PROJECT_ID") + .ok_or_else(|| anyhow!("GCP_PROJECT_ID is required"))?; + + let base_url = config + .get("GCP_BASE_URL") + .map(|s| s.as_str()) + .unwrap_or(DEFAULT_RM_ENDPOINT); + + // Get access token: either directly provided or via JWT exchange. + let access_token = if let Some(token) = config.get("GCP_ACCESS_TOKEN") { + token.clone() + } else { + let sa_key_raw = config + .get("GCP_SERVICE_ACCOUNT_KEY") + .ok_or_else(|| anyhow!("GCP_SERVICE_ACCOUNT_KEY is required"))?; + + // Try parsing as JSON directly; if that fails, treat as file path. + let sa_key: ServiceAccountKey = + serde_json::from_str(sa_key_raw).map_err(|e| { + anyhow!("failed to parse GCP_SERVICE_ACCOUNT_KEY as JSON: {}", e) + })?; + + let token_endpoint = sa_key + .token_uri + .as_deref() + .unwrap_or(DEFAULT_TOKEN_ENDPOINT); + get_access_token(&sa_key, token_endpoint)? + }; + + let now = Utc::now(); + + // Fetch IAM policy for the project. + let policy = fetch_iam_policy(&access_token, project_id, base_url)?; + let bindings = policy.bindings.unwrap_or_default(); + + // Analyze bindings for overly permissive roles. + let mut findings: Vec = Vec::new(); + let mut observables: Vec = Vec::new(); + let mut permissive_bindings = 0usize; + let mut total_members = 0usize; + + let mut binding_details: Vec = Vec::new(); + + for binding in &bindings { + let is_permissive = OVERLY_PERMISSIVE_ROLES.contains(&binding.role.as_str()); + + observables.push(Observable { + obs_type: "iam_role".to_string(), + value: binding.role.clone(), + name: String::new(), + }); + + for member in &binding.members { + total_members += 1; + observables.push(Observable { + obs_type: "iam_member".to_string(), + value: member.clone(), + name: String::new(), + }); + } + + if is_permissive { + permissive_bindings += 1; + findings.push(Finding { + title: "Overly Permissive IAM Binding".to_string(), + description: format!( + "Role {} is granted to {} member(s): {}", + binding.role, + binding.members.len(), + binding.members.join(", ") + ), + severity_id: 3, + }); + } + + binding_details.push(json!({ + "role": binding.role, + "members": binding.members, + "is_permissive": is_permissive, + })); + } + + if findings.is_empty() { + findings.push(Finding { + title: "IAM Policy Compliant".to_string(), + description: format!( + "No overly permissive roles found across {} bindings", + bindings.len() + ), + severity_id: 0, + }); + } + + let (status_id, status_text) = if permissive_bindings > 0 { + ( + StatusId::Ineffective, + format!( + "{} overly permissive binding(s) found across {} total bindings for project {}", + permissive_bindings, + bindings.len(), + project_id + ), + ) + } else { + ( + StatusId::Effective, + format!( + "All {} IAM bindings use appropriately scoped roles for project {}", + bindings.len(), + project_id + ), + ) + }; + + let raw_data = json!({ + "project_id": project_id, + "total_bindings": bindings.len(), + "total_members": total_members, + "permissive_bindings": permissive_bindings, + "overly_permissive_roles_checked": OVERLY_PERMISSIVE_ROLES, + "binding_details": binding_details, + }); + + Ok(vec![Evidence { + id: Uuid::new_v4(), + control_id: "iam.least_privilege".to_string(), + class_uid: 1002, + category_uid: 1, + activity_id: 1, + time: now, + confidence_level: ConfidenceLevel::PassiveObservation, + metadata: Metadata { + module: ModuleInfo { + name: "gcp.iam_policy".to_string(), + version: "0.1.0".to_string(), + module_type: "observer".to_string(), + }, + source: SourceInfo { + system: "gcp".to_string(), + api_version: API_VERSION.to_string(), + endpoint: base_url.to_string(), + }, + original_time: None, + processed_time: now, + safety_classification: None, + }, + observables, + status_id, + status: status_text, + raw_data, + findings, + test_transcript: None, + enrichments: vec![], + }]) + } +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + // ── Mock server ───────────────────────────────────────────────────────── + + fn mock_server(responses: Vec<(u16, String)>) -> String { + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::thread; + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + + thread::spawn(move || { + for (status, body) in responses { + if let Ok((mut stream, _)) = listener.accept() { + let mut buf = [0u8; 8192]; + let _ = stream.read(&mut buf); + let resp = format!( + "HTTP/1.1 {} OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + status, + body.len(), + body + ); + let _ = stream.write_all(resp.as_bytes()); + } + } + }); + + format!("http://127.0.0.1:{}", addr.port()) + } + + fn base_config(base_url: &str) -> HashMap { + HashMap::from([ + ("GCP_PROJECT_ID".to_string(), "test-project-123".to_string()), + ("GCP_ACCESS_TOKEN".to_string(), "test-token".to_string()), + ("GCP_BASE_URL".to_string(), base_url.to_string()), + ]) + } + + // ── JSON response fixtures ────────────────────────────────────────────── + + const EMPTY_POLICY: &str = r#"{"bindings": []}"#; + + const COMPLIANT_POLICY: &str = r#"{ + "bindings": [ + { + "role": "roles/viewer", + "members": ["user:alice@example.com"] + }, + { + "role": "roles/storage.objectViewer", + "members": ["serviceAccount:svc@test.iam.gserviceaccount.com"] + } + ] + }"#; + + const PERMISSIVE_POLICY: &str = r#"{ + "bindings": [ + { + "role": "roles/owner", + "members": ["user:admin@example.com", "user:dev@example.com"] + }, + { + "role": "roles/viewer", + "members": ["user:auditor@example.com"] + } + ] + }"#; + + const EDITOR_POLICY: &str = r#"{ + "bindings": [ + { + "role": "roles/editor", + "members": ["serviceAccount:deploy@test.iam.gserviceaccount.com"] + } + ] + }"#; + + const MULTI_PERMISSIVE_POLICY: &str = r#"{ + "bindings": [ + { + "role": "roles/owner", + "members": ["user:admin@example.com"] + }, + { + "role": "roles/editor", + "members": ["user:dev@example.com"] + }, + { + "role": "roles/viewer", + "members": ["user:auditor@example.com"] + } + ] + }"#; + + const NO_BINDINGS_POLICY: &str = r#"{}"#; + + // ── Metadata tests ────────────────────────────────────────────────────── + + #[test] + fn gcp_observer_id() { + assert_eq!(GcpIamPolicyObserver.id(), "gcp.iam_policy"); + } + + #[test] + fn gcp_observer_name() { + assert_eq!(GcpIamPolicyObserver.name(), "GCP IAM Policy Observer"); + } + + #[test] + fn gcp_observer_version() { + assert_eq!(GcpIamPolicyObserver.version(), "0.1.0"); + } + + #[test] + fn gcp_observer_source_system() { + assert_eq!(GcpIamPolicyObserver.source_system(), "gcp"); + } + + #[test] + fn gcp_observer_evidence_types() { + assert_eq!(GcpIamPolicyObserver.evidence_types(), &[1002]); + } + + #[test] + fn gcp_observer_credential_requirements() { + let reqs = GcpIamPolicyObserver.credential_requirements(); + assert_eq!(reqs.len(), 2); + assert!(reqs + .iter() + .any(|r| r.name == "GCP_SERVICE_ACCOUNT_KEY" && r.required)); + assert!(reqs + .iter() + .any(|r| r.name == "GCP_PROJECT_ID" && r.required)); + } + + // ── Config validation tests ───────────────────────────────────────────── + + #[test] + fn gcp_observer_missing_project_id_errors() { + let config = HashMap::from([ + ("GCP_ACCESS_TOKEN".to_string(), "token".to_string()), + ]); + let err = GcpIamPolicyObserver.observe(&config).unwrap_err(); + assert!(err.to_string().contains("GCP_PROJECT_ID")); + } + + #[test] + fn gcp_observer_missing_both_key_and_token_errors() { + let config = HashMap::from([ + ("GCP_PROJECT_ID".to_string(), "proj".to_string()), + ]); + let err = GcpIamPolicyObserver.observe(&config).unwrap_err(); + assert!(err.to_string().contains("GCP_SERVICE_ACCOUNT_KEY")); + } + + // ── HTTP integration tests (mock server) ──────────────────────────────── + + #[test] + fn gcp_observer_empty_policy_is_compliant() { + let srv = mock_server(vec![(200, EMPTY_POLICY.to_string())]); + let results = GcpIamPolicyObserver.observe(&base_config(&srv)).unwrap(); + assert_eq!(results.len(), 1); + let ev = &results[0]; + assert_eq!(ev.status_id, StatusId::Effective); + assert_eq!(ev.control_id, "iam.least_privilege"); + assert_eq!(ev.findings[0].title, "IAM Policy Compliant"); + assert!(ev.test_transcript.is_none()); + } + + #[test] + fn gcp_observer_compliant_policy_effective() { + let srv = mock_server(vec![(200, COMPLIANT_POLICY.to_string())]); + let ev = &GcpIamPolicyObserver.observe(&base_config(&srv)).unwrap()[0]; + assert_eq!(ev.status_id, StatusId::Effective); + assert_eq!(ev.class_uid, 1002); + assert!(!ev.observables.is_empty()); + } + + #[test] + fn gcp_observer_permissive_policy_ineffective() { + let srv = mock_server(vec![(200, PERMISSIVE_POLICY.to_string())]); + let ev = &GcpIamPolicyObserver.observe(&base_config(&srv)).unwrap()[0]; + assert_eq!(ev.status_id, StatusId::Ineffective); + assert!(ev + .findings + .iter() + .any(|f| f.title == "Overly Permissive IAM Binding")); + assert!(ev.findings.iter().any(|f| f.description.contains("roles/owner"))); + } + + #[test] + fn gcp_observer_editor_role_is_permissive() { + let srv = mock_server(vec![(200, EDITOR_POLICY.to_string())]); + let ev = &GcpIamPolicyObserver.observe(&base_config(&srv)).unwrap()[0]; + assert_eq!(ev.status_id, StatusId::Ineffective); + assert!(ev + .findings + .iter() + .any(|f| f.description.contains("roles/editor"))); + } + + #[test] + fn gcp_observer_multiple_permissive_bindings() { + let srv = mock_server(vec![(200, MULTI_PERMISSIVE_POLICY.to_string())]); + let ev = &GcpIamPolicyObserver.observe(&base_config(&srv)).unwrap()[0]; + assert_eq!(ev.status_id, StatusId::Ineffective); + let permissive_findings: Vec<_> = ev + .findings + .iter() + .filter(|f| f.title == "Overly Permissive IAM Binding") + .collect(); + assert_eq!(permissive_findings.len(), 2); + } + + #[test] + fn gcp_observer_no_bindings_field_is_compliant() { + let srv = mock_server(vec![(200, NO_BINDINGS_POLICY.to_string())]); + let ev = &GcpIamPolicyObserver.observe(&base_config(&srv)).unwrap()[0]; + assert_eq!(ev.status_id, StatusId::Effective); + } + + #[test] + fn gcp_observer_raw_data_has_expected_keys() { + let srv = mock_server(vec![(200, EMPTY_POLICY.to_string())]); + let ev = &GcpIamPolicyObserver.observe(&base_config(&srv)).unwrap()[0]; + assert!(ev.raw_data.get("project_id").is_some()); + assert!(ev.raw_data.get("total_bindings").is_some()); + assert!(ev.raw_data.get("total_members").is_some()); + assert!(ev.raw_data.get("permissive_bindings").is_some()); + assert!(ev.raw_data.get("binding_details").is_some()); + } + + #[test] + fn gcp_observer_observables_include_roles_and_members() { + let srv = mock_server(vec![(200, COMPLIANT_POLICY.to_string())]); + let ev = &GcpIamPolicyObserver.observe(&base_config(&srv)).unwrap()[0]; + assert!(ev.observables.iter().any(|o| o.obs_type == "iam_role")); + assert!(ev.observables.iter().any(|o| o.obs_type == "iam_member")); + } + + #[test] + fn gcp_observer_metadata_correct() { + let srv = mock_server(vec![(200, EMPTY_POLICY.to_string())]); + let ev = &GcpIamPolicyObserver.observe(&base_config(&srv)).unwrap()[0]; + assert_eq!(ev.metadata.module.name, "gcp.iam_policy"); + assert_eq!(ev.metadata.module.module_type, "observer"); + assert_eq!(ev.metadata.source.system, "gcp"); + assert!(ev.metadata.safety_classification.is_none()); + } + + // ── JSON parsing unit tests ───────────────────────────────────────────── + + #[test] + fn parse_iam_policy_empty_bindings() { + let policy: IamPolicy = serde_json::from_str(EMPTY_POLICY).unwrap(); + assert!(policy.bindings.unwrap().is_empty()); + } + + #[test] + fn parse_iam_policy_with_bindings() { + let policy: IamPolicy = serde_json::from_str(COMPLIANT_POLICY).unwrap(); + let bindings = policy.bindings.unwrap(); + assert_eq!(bindings.len(), 2); + assert_eq!(bindings[0].role, "roles/viewer"); + assert_eq!(bindings[0].members.len(), 1); + } + + #[test] + fn parse_iam_policy_no_bindings_field() { + let policy: IamPolicy = serde_json::from_str(NO_BINDINGS_POLICY).unwrap(); + assert!(policy.bindings.is_none()); + } +} diff --git a/src/modules/observers/mod.rs b/src/modules/observers/mod.rs index 923933b..af4faa2 100644 --- a/src/modules/observers/mod.rs +++ b/src/modules/observers/mod.rs @@ -1,4 +1,5 @@ pub mod aws; +pub mod gcp; pub mod github; pub mod mock; pub mod okta; @@ -13,6 +14,7 @@ pub fn register_all(registry: &Registry) { registry.register_observer(Arc::new(mock::MockObserver)); registry.register_observer(Arc::new(mock::MockNetworkObserver)); registry.register_observer(Arc::new(aws::IamObserver)); + registry.register_observer(Arc::new(gcp::GcpIamPolicyObserver)); registry.register_observer(Arc::new(github::BranchProtectionObserver)); registry.register_observer(Arc::new(okta::MfaPolicyObserver)); registry.register_observer(Arc::new(okta_population::MfaEnrollmentPopulationObserver)); diff --git a/src/modules/testers/gcp.rs b/src/modules/testers/gcp.rs new file mode 100644 index 0000000..395f5ca --- /dev/null +++ b/src/modules/testers/gcp.rs @@ -0,0 +1,486 @@ +use std::collections::HashMap; + +use anyhow::{anyhow, Result}; +use chrono::Utc; +use serde_json::json; +use uuid::Uuid; + +use crate::evidence::{ + ConfidenceLevel, Evidence, Finding, Metadata, ModuleInfo, Observable, SourceInfo, StatusId, + TranscriptRecorder, +}; +use crate::module::{ + tester::Tester, CredentialReq, EnvironmentScope, Module, SafetyClassification, +}; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const DEFAULT_STORAGE_ENDPOINT: &str = "https://storage.googleapis.com"; + +// ─── GcpPublicBucketTester ─────────────────────────────────────────────────── + +/// Verifies that a GCP Cloud Storage bucket is not publicly accessible by +/// performing an unauthenticated HTTP GET. A 403/401 response means access +/// is blocked (effective); a 200 means the bucket is publicly accessible +/// (ineffective). +/// +/// Required config: `GCP_TEST_BUCKET` (bucket name). +/// Optional: `GCP_STORAGE_ENDPOINT` (test override). +pub struct GcpPublicBucketTester; + +impl Module for GcpPublicBucketTester { + fn id(&self) -> &str { + "gcp.public_bucket" + } + fn name(&self) -> &str { + "GCP Public Bucket Tester" + } + fn version(&self) -> &str { + "0.1.0" + } + fn source_system(&self) -> &str { + "gcp" + } + fn evidence_types(&self) -> &[i32] { + &[1002] + } + + fn credential_requirements(&self) -> Vec { + vec![CredentialReq { + name: "GCP_TEST_BUCKET".to_string(), + cred_type: "config".to_string(), + description: "GCS bucket name to test for public access".to_string(), + required: true, + }] + } +} + +impl Tester for GcpPublicBucketTester { + fn safety_class(&self) -> SafetyClassification { + SafetyClassification::Safe + } + fn environment_scope(&self) -> EnvironmentScope { + EnvironmentScope::Production + } + + fn pre_flight_checks(&self) -> Vec { + vec!["verify test bucket name configured".to_string()] + } + + fn cleanup_procedures(&self) -> Vec { + vec![] // Safe read-only test — no cleanup needed. + } + + fn test(&self, config: &HashMap) -> Result> { + let bucket_name = config.get("GCP_TEST_BUCKET").ok_or_else(|| { + anyhow!("GCP_TEST_BUCKET is required: specify the GCS bucket name to test") + })?; + + let storage_endpoint = config + .get("GCP_STORAGE_ENDPOINT") + .map(|s| s.as_str()) + .unwrap_or(DEFAULT_STORAGE_ENDPOINT); + + let bucket_url = format!( + "{}/storage/v1/b/{}/o", + storage_endpoint.trim_end_matches('/'), + bucket_name + ); + + let now = Utc::now(); + let mut recorder = TranscriptRecorder::new(); + let safety_class = "safe".to_string(); + + recorder.record_action( + "pre-flight: verify test bucket name configured", + Some(json!({ "bucket_name": bucket_name })), + ); + recorder.record_observation("test bucket name is configured", true); + + recorder.record_action( + "attempt unauthenticated HTTP GET to GCS bucket listing", + Some(json!({ + "method": "GET", + "url": &bucket_url, + "auth": "none (anonymous)" + })), + ); + + // Perform unauthenticated GET to list bucket objects. + let resp = ureq::get(&bucket_url).call(); + + let (status_id, status_text, findings, test_result, http_status) = match resp { + Err(ureq::Error::Status(code, _)) => { + classify_response(code, bucket_name, &mut recorder) + } + Ok(r) => { + let code = r.status(); + classify_response(code, bucket_name, &mut recorder) + } + Err(e) => { + recorder.record_observation(format!("request failed with error: {}", e), false); + let transcript = recorder.finalize(); + let raw = json!({ + "test_scenario": "gcs_public_access_check", + "target_bucket": bucket_name, + "test_result": "error", + "error": e.to_string(), + }); + return Ok(vec![Evidence { + id: Uuid::new_v4(), + control_id: "gcs.public_access".to_string(), + class_uid: 1002, + category_uid: 3, + activity_id: 2, + time: now, + confidence_level: ConfidenceLevel::ActiveVerification, + metadata: Metadata { + module: ModuleInfo { + name: "gcp.public_bucket".to_string(), + version: "0.1.0".to_string(), + module_type: "tester".to_string(), + }, + source: SourceInfo { + system: "gcp".to_string(), + api_version: "v1".to_string(), + endpoint: bucket_url, + }, + original_time: None, + processed_time: now, + safety_classification: Some(safety_class), + }, + observables: vec![Observable { + obs_type: "resource".to_string(), + value: bucket_name.clone(), + name: String::new(), + }], + status_id: StatusId::Unknown, + status: format!("Could not reach bucket: {}", e), + raw_data: raw, + findings: vec![Finding { + title: "GCS Public Access Check Failed".to_string(), + description: format!( + "Could not connect to bucket {}: {}", + bucket_name, e + ), + severity_id: 1, + }], + test_transcript: Some(transcript), + enrichments: vec![], + }]); + } + }; + + recorder.record_cleanup("no cleanup required (safe read-only test)", true); + let transcript = recorder.finalize(); + + let raw_data = json!({ + "test_scenario": "gcs_public_access_check", + "target_bucket": bucket_name, + "test_result": test_result, + "http_status": http_status, + }); + + Ok(vec![Evidence { + id: Uuid::new_v4(), + control_id: "gcs.public_access".to_string(), + class_uid: 1002, + category_uid: 3, + activity_id: 2, + time: now, + confidence_level: ConfidenceLevel::ActiveVerification, + metadata: Metadata { + module: ModuleInfo { + name: "gcp.public_bucket".to_string(), + version: "0.1.0".to_string(), + module_type: "tester".to_string(), + }, + source: SourceInfo { + system: "gcp".to_string(), + api_version: "v1".to_string(), + endpoint: bucket_url, + }, + original_time: None, + processed_time: now, + safety_classification: Some(safety_class), + }, + observables: vec![Observable { + obs_type: "resource".to_string(), + value: bucket_name.clone(), + name: String::new(), + }], + status_id, + status: status_text, + raw_data, + findings, + test_transcript: Some(transcript), + enrichments: vec![], + }]) + } +} + +/// Classify an HTTP response code into evidence status. +fn classify_response( + code: u16, + bucket_name: &str, + recorder: &mut TranscriptRecorder, +) -> (StatusId, String, Vec, String, u16) { + match code { + 401 | 403 | 404 => { + recorder.record_observation( + format!( + "unauthenticated request returned HTTP {} (access denied)", + code + ), + true, + ); + ( + StatusId::Effective, + format!("GCS bucket access blocked with HTTP {}", code), + vec![Finding { + title: "GCS Public Access Blocked".to_string(), + description: format!( + "Unauthenticated GET to bucket {} returned HTTP {}, confirming public access is denied", + bucket_name, code + ), + severity_id: 0, + }], + format!("blocked_{}", code), + code, + ) + } + 200 => { + recorder.record_observation( + "unauthenticated request returned HTTP 200 (publicly accessible)", + false, + ); + ( + StatusId::Ineffective, + "GCS bucket is publicly accessible".to_string(), + vec![Finding { + title: "GCS Bucket Publicly Accessible".to_string(), + description: format!( + "Unauthenticated GET to bucket {} returned HTTP 200, indicating the bucket is publicly accessible", + bucket_name + ), + severity_id: 4, + }], + "allowed".to_string(), + 200, + ) + } + other => { + recorder.record_observation( + format!( + "unauthenticated request returned unexpected HTTP {}", + other + ), + false, + ); + ( + StatusId::Unknown, + format!("GCS bucket returned unexpected HTTP {}", other), + vec![Finding { + title: "Unexpected GCS Response".to_string(), + description: format!( + "Unauthenticated GET to bucket {} returned HTTP {} which could not be classified", + bucket_name, other + ), + severity_id: 2, + }], + format!("unexpected_http_{}", other), + other, + ) + } + } +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn mock_server(status: u16, body: &str) -> String { + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::thread; + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let body = body.to_string(); + + thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + let mut buf = [0u8; 8192]; + let _ = stream.read(&mut buf); + let resp = format!( + "HTTP/1.1 {status} OK\r\nContent-Length: {len}\r\nConnection: close\r\n\r\n{body}", + len = body.len() + ); + let _ = stream.write_all(resp.as_bytes()); + } + }); + + format!("http://127.0.0.1:{}", addr.port()) + } + + fn base_config(endpoint: &str) -> HashMap { + HashMap::from([ + ("GCP_TEST_BUCKET".to_string(), "my-test-bucket".to_string()), + ("GCP_STORAGE_ENDPOINT".to_string(), endpoint.to_string()), + ]) + } + + // ── Metadata ───────────────────────────────────────────────────────────── + + #[test] + fn gcp_tester_id() { + assert_eq!(GcpPublicBucketTester.id(), "gcp.public_bucket"); + } + + #[test] + fn gcp_tester_name() { + assert_eq!(GcpPublicBucketTester.name(), "GCP Public Bucket Tester"); + } + + #[test] + fn gcp_tester_version() { + assert_eq!(GcpPublicBucketTester.version(), "0.1.0"); + } + + #[test] + fn gcp_tester_source_system() { + assert_eq!(GcpPublicBucketTester.source_system(), "gcp"); + } + + #[test] + fn gcp_tester_evidence_types() { + assert_eq!(GcpPublicBucketTester.evidence_types(), &[1002]); + } + + #[test] + fn gcp_tester_credential_requirements() { + let reqs = GcpPublicBucketTester.credential_requirements(); + assert_eq!(reqs.len(), 1); + assert_eq!(reqs[0].name, "GCP_TEST_BUCKET"); + assert!(reqs[0].required); + } + + #[test] + fn gcp_tester_safety_class() { + assert_eq!( + GcpPublicBucketTester.safety_class(), + SafetyClassification::Safe + ); + } + + #[test] + fn gcp_tester_environment_scope() { + assert_eq!( + GcpPublicBucketTester.environment_scope(), + EnvironmentScope::Production + ); + } + + #[test] + fn gcp_tester_pre_flight_nonempty() { + assert!(!GcpPublicBucketTester.pre_flight_checks().is_empty()); + } + + #[test] + fn gcp_tester_cleanup_empty() { + assert!(GcpPublicBucketTester.cleanup_procedures().is_empty()); + } + + #[test] + fn gcp_tester_missing_bucket_errors() { + let err = GcpPublicBucketTester.test(&HashMap::new()).unwrap_err(); + assert!(err.to_string().contains("GCP_TEST_BUCKET")); + } + + // ── HTTP integration ───────────────────────────────────────────────────── + + #[test] + fn gcp_tester_403_means_access_blocked_effective() { + let srv = mock_server(403, r#"{"error":{"code":403}}"#); + let ev = &GcpPublicBucketTester.test(&base_config(&srv)).unwrap()[0]; + assert_eq!(ev.status_id, StatusId::Effective); + assert_eq!(ev.findings[0].title, "GCS Public Access Blocked"); + assert_eq!(ev.class_uid, 1002); + assert_eq!(ev.control_id, "gcs.public_access"); + assert!(ev.test_transcript.is_some()); + } + + #[test] + fn gcp_tester_401_means_access_blocked_effective() { + let srv = mock_server(401, r#"{"error":{"code":401}}"#); + let ev = &GcpPublicBucketTester.test(&base_config(&srv)).unwrap()[0]; + assert_eq!(ev.status_id, StatusId::Effective); + assert_eq!(ev.findings[0].title, "GCS Public Access Blocked"); + } + + #[test] + fn gcp_tester_404_means_access_blocked_effective() { + let srv = mock_server(404, r#"{"error":{"code":404}}"#); + let ev = &GcpPublicBucketTester.test(&base_config(&srv)).unwrap()[0]; + assert_eq!(ev.status_id, StatusId::Effective); + } + + #[test] + fn gcp_tester_200_means_publicly_accessible_ineffective() { + let srv = mock_server(200, r#"{"items":[]}"#); + let ev = &GcpPublicBucketTester.test(&base_config(&srv)).unwrap()[0]; + assert_eq!(ev.status_id, StatusId::Ineffective); + assert_eq!(ev.findings[0].title, "GCS Bucket Publicly Accessible"); + assert_eq!(ev.findings[0].severity_id, 4); + } + + #[test] + fn gcp_tester_500_means_unknown() { + let srv = mock_server(500, "Internal Server Error"); + let ev = &GcpPublicBucketTester.test(&base_config(&srv)).unwrap()[0]; + assert_eq!(ev.status_id, StatusId::Unknown); + assert_eq!(ev.findings[0].title, "Unexpected GCS Response"); + } + + #[test] + fn gcp_tester_has_transcript() { + let srv = mock_server(403, r#"{"error":{"code":403}}"#); + let ev = &GcpPublicBucketTester.test(&base_config(&srv)).unwrap()[0]; + let t = ev.test_transcript.as_ref().unwrap(); + assert!(!t.actions_attempted.is_empty()); + assert!(!t.observations.is_empty()); + } + + #[test] + fn gcp_tester_raw_data_has_expected_keys() { + let srv = mock_server(403, "Denied"); + let ev = &GcpPublicBucketTester.test(&base_config(&srv)).unwrap()[0]; + assert!(ev.raw_data.get("test_scenario").is_some()); + assert!(ev.raw_data.get("target_bucket").is_some()); + assert!(ev.raw_data.get("test_result").is_some()); + } + + #[test] + fn gcp_tester_safety_classification_in_metadata() { + let srv = mock_server(403, "Denied"); + let ev = &GcpPublicBucketTester.test(&base_config(&srv)).unwrap()[0]; + assert_eq!(ev.metadata.safety_classification.as_deref(), Some("safe")); + } + + #[test] + fn gcp_tester_unique_ids() { + let srv1 = mock_server(403, "D"); + let srv2 = mock_server(403, "D"); + let id1 = GcpPublicBucketTester + .test(&base_config(&srv1)) + .unwrap()[0] + .id; + let id2 = GcpPublicBucketTester + .test(&base_config(&srv2)) + .unwrap()[0] + .id; + assert_ne!(id1, id2); + } +} diff --git a/src/modules/testers/mod.rs b/src/modules/testers/mod.rs index 349edc8..a3b9b56 100644 --- a/src/modules/testers/mod.rs +++ b/src/modules/testers/mod.rs @@ -1,4 +1,5 @@ pub mod aws; +pub mod gcp; pub mod github; pub mod mock; pub mod okta; @@ -12,6 +13,7 @@ use crate::module::registry::Registry; pub fn register_all(registry: &Registry) { registry.register_tester(Arc::new(mock::MockTester)); registry.register_tester(Arc::new(aws::S3PublicAccessTester)); + registry.register_tester(Arc::new(gcp::GcpPublicBucketTester)); registry.register_tester(Arc::new(github::SecretPushTester)); registry.register_tester(Arc::new(okta::MfaBypassTester)); registry.register_tester(Arc::new(okta_pr_mfa_downgrade::PrMfaDowngradeTester));