diff --git a/CHANGELOG.md b/CHANGELOG.md index a8743ea..4ad5be5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. ## [v1.84.0] - Added/updated `pipedrive` and `amplitude` rules +- Access Map: added Buildkite provider. Enumerates token scopes, user identity, organizations, and pipelines with severity classification based on scope risk. +- Access Map: added Harness provider. Uses `x-api-key` authentication to enumerate organizations/projects when permitted (best-effort). +- Access Map CLI: added providers `buildkite`, `harness`. +- Reports: omit `validate`/`revoke` command hints when required template vars are missing (prevents suggesting unrunnable commands, e.g. Harness `ACCOUNTIDENTIFIER`). ## [v1.83.0] - Kingfisher can now generate an auditor-friendly HTML report: `--format html --output kingfisher-audit.html` diff --git a/crates/kingfisher-rules/data/rules/harness.yml b/crates/kingfisher-rules/data/rules/harness.yml index aeb81d0..b12ab7c 100644 --- a/crates/kingfisher-rules/data/rules/harness.yml +++ b/crates/kingfisher-rules/data/rules/harness.yml @@ -6,13 +6,15 @@ rules: \b ( pat\. - [A-Z0-9]{22} + [A-Z0-9_-]{22} \. [0-9a-f]{24} \. [A-Z0-9]{20} ) \b + pattern_requirements: + min_digits: 4 min_entropy: 3.4 confidence: medium examples: @@ -25,13 +27,64 @@ rules: content: request: method: GET - url: https://app.harness.io/ng/api/apikey/aggregate + # Use an endpoint that does not require additional query params. + url: https://app.harness.io/v1/orgs?limit=1&page=1 headers: Accept: application/json x-api-key: "{{ TOKEN }}" response_matcher: - # Valid token + authorized OR valid token but missing params/perms + - report_response: true + # 403 can still mean a live token with restricted scope. - type: StatusMatch - status: [200, 400, 403] + status: [200, 403] + - type: StatusMatch + status: [401] negative: true - type: JsonValid + + # Self-revocation support (delete the backing API key). + # + # Harness exposes DELETE /ng/api/apikey/{identifier}, authenticated with x-api-key, + # and requires accountIdentifier/apiKeyType/parentIdentifier query parameters. + # + # Required runtime vars for revoke command: + # - ACCOUNTIDENTIFIER: Harness account ID + # + # API key metadata is derived from token validation in step 1. + revocation: + type: HttpMultiStep + content: + steps: + - name: validate_token_and_extract_api_key + request: + method: POST + url: https://app.harness.io/ng/api/token/validate?accountIdentifier={{ ACCOUNTIDENTIFIER }} + headers: + Accept: application/json + x-api-key: "{{ TOKEN }}" + response_matcher: + - type: StatusMatch + status: [200] + - type: JsonValid + extract: + APIKEYIDENTIFIER: + type: JsonPath + path: "$.data.apiKeyIdentifier" + PARENTIDENTIFIER: + type: JsonPath + path: "$.data.parentIdentifier" + APIKEYTYPE: + type: JsonPath + path: "$.data.apiKeyType" + + - name: delete_api_key + request: + method: DELETE + url: https://app.harness.io/ng/api/apikey/{{ APIKEYIDENTIFIER }}?accountIdentifier={{ ACCOUNTIDENTIFIER }}&apiKeyType={{ APIKEYTYPE }}&parentIdentifier={{ PARENTIDENTIFIER }} + headers: + Accept: application/json + x-api-key: "{{ TOKEN }}" + response_matcher: + - report_response: true + - type: StatusMatch + status: [200, 204] diff --git a/docs/ACCESS_MAP.md b/docs/ACCESS_MAP.md index 6d70a26..b0db889 100644 --- a/docs/ACCESS_MAP.md +++ b/docs/ACCESS_MAP.md @@ -218,8 +218,50 @@ kingfisher access-map bitbucket ./bitbucket.token --json-out bitbucket.access-ma - Access map uses `https://api.bitbucket.org/2.0` as the API base. - Workspace owners are classified as High severity. +### Buildkite (`buildkite`) + +- **Credential**: a single Buildkite API token string (read from a file for `kingfisher access-map buildkite `). +- **Token types supported**: tokens accepted by Buildkite's REST API with `Authorization: Bearer ` (API access tokens, commonly `bkua_...`). + +Kingfisher queries `/v2/access-token` for token metadata and scopes, `/v2/user` for identity, `/v2/organizations` for organization memberships, and `/v2/organizations/{org}/pipelines` for pipeline enumeration. Token scopes and organization access are used to classify risk. + +#### Standalone example (Buildkite) + +```bash +printf '%s' 'bkua_example...' > ./buildkite.token +kingfisher access-map buildkite ./buildkite.token --json-out buildkite.access-map.json +``` + +#### Notes (Buildkite) + +- Access map uses `https://api.buildkite.com/v2` as the API base. +- Tokens with `write_organizations` or `write_teams` scopes are classified as High severity. + +### Harness (`harness`) + +- **Credential**: a single Harness API key / personal access token (PAT) string (read from a file for `kingfisher access-map harness `). +- **Auth header**: Harness APIs authenticate via `x-api-key: ` (see the Harness API docs). + +Kingfisher performs best-effort, read-only enumeration: + +- Queries the API key aggregate endpoint for basic token metadata (when available). +- Enumerates organizations via `GET https://app.harness.io/v1/orgs` and projects via `GET https://app.harness.io/v1/orgs/{org}/projects` when the key has permission. + +If organizations/projects are not enumerable (scope-limited keys), Kingfisher still produces an access-map record with a conservative severity and a note explaining the limitation. + +#### Standalone example (Harness) + +```bash +printf '%s' 'pat.example...' > ./harness.token +kingfisher access-map harness ./harness.token --json-out harness.access-map.json +``` + +#### Notes (Harness) + +- Access map uses `https://app.harness.io` as the API base. + ## Notes on access-map generation during `scan --access-map` - Access-map entries are only recorded for **validated** findings. - Some providers require extra context that Kingfisher infers from the finding context or validation response (for example, Azure DevOps organization name). -- Validated Hugging Face, Gitea, and Bitbucket credentials discovered during scans with `--access-map` are automatically collected and mapped, matching the existing behavior for other platforms. +- Validated Hugging Face, Gitea, Bitbucket, and Buildkite credentials discovered during scans with `--access-map` are automatically collected and mapped, matching the existing behavior for other platforms. diff --git a/src/access_map.rs b/src/access_map.rs index eb07647..e6df880 100644 --- a/src/access_map.rs +++ b/src/access_map.rs @@ -8,10 +8,12 @@ mod aws; mod azure; mod azure_devops; mod bitbucket; +mod buildkite; mod gcp; mod gitea; mod github; mod gitlab; +mod harness; mod huggingface; pub(crate) mod mongodb; pub(crate) mod postgres; @@ -48,6 +50,8 @@ pub async fn run(args: AccessMapArgs) -> Result<()> { AccessMapProvider::Huggingface => huggingface::map_access(&args).await?, AccessMapProvider::Gitea => gitea::map_access(&args).await?, AccessMapProvider::Bitbucket => bitbucket::map_access(&args).await?, + AccessMapProvider::Buildkite => buildkite::map_access(&args).await?, + AccessMapProvider::Harness => harness::map_access(&args).await?, }; let json = serde_json::to_string_pretty(&result)?; @@ -96,6 +100,10 @@ pub enum AccessMapRequest { Gitea { token: String, fingerprint: String }, /// A Bitbucket token. Bitbucket { token: String, fingerprint: String }, + /// A Buildkite token. + Buildkite { token: String, fingerprint: String }, + /// A Harness API token (x-api-key). + Harness { token: String, fingerprint: String }, } /// Structured output describing the resolved identity and its risk profile. @@ -290,6 +298,12 @@ pub async fn map_requests(requests: Vec) -> Vec { (map_token(&BitbucketMapper, &token).await, fingerprint) } + AccessMapRequest::Buildkite { token, fingerprint } => { + (map_token(&BuildkiteMapper, &token).await, fingerprint) + } + AccessMapRequest::Harness { token, fingerprint } => { + (map_token(&HarnessMapper, &token).await, fingerprint) + } }; mapped.fingerprint = Some(fp); @@ -395,6 +409,32 @@ impl TokenAccessMapper for BitbucketMapper { } } +/// Buildkite access mapper. +pub struct BuildkiteMapper; + +impl TokenAccessMapper for BuildkiteMapper { + fn cloud_name(&self) -> &'static str { + "buildkite" + } + + async fn map_access_from_token(&self, token: &str) -> Result { + buildkite::map_access_from_token(token).await + } +} + +/// Harness access mapper. +pub struct HarnessMapper; + +impl TokenAccessMapper for HarnessMapper { + fn cloud_name(&self) -> &'static str { + "harness" + } + + async fn map_access_from_token(&self, token: &str) -> Result { + harness::map_access_from_token(token).await + } +} + // ------------------------------------------------------------------------------------------------- // Helper functions // ------------------------------------------------------------------------------------------------- diff --git a/src/access_map/buildkite.rs b/src/access_map/buildkite.rs new file mode 100644 index 0000000..0a67d9d --- /dev/null +++ b/src/access_map/buildkite.rs @@ -0,0 +1,368 @@ +use anyhow::{anyhow, Context, Result}; +use reqwest::{header, Client}; +use serde::Deserialize; +use tracing::warn; + +use crate::{cli::commands::access_map::AccessMapArgs, validation::GLOBAL_USER_AGENT}; + +use super::{ + build_recommendations, AccessMapResult, AccessSummary, AccessTokenDetails, PermissionSummary, + ResourceExposure, RoleBinding, Severity, +}; + +const BUILDKITE_API: &str = "https://api.buildkite.com/v2"; + +#[derive(Deserialize)] +struct BuildkiteAccessToken { + #[serde(default)] + uuid: Option, + #[serde(default)] + scopes: Vec, +} + +#[derive(Deserialize)] +struct BuildkiteUser { + #[serde(default)] + id: Option, + #[serde(default)] + name: Option, + #[serde(default)] + email: Option, + #[serde(default)] + created_at: Option, +} + +#[derive(Deserialize)] +struct BuildkiteOrganization { + #[serde(default)] + name: Option, + #[serde(default)] + slug: Option, +} + +#[derive(Deserialize)] +struct BuildkitePipeline { + #[serde(default)] + name: Option, + #[serde(default)] + slug: Option, + #[serde(default)] + visibility: Option, +} + +pub async fn map_access(args: &AccessMapArgs) -> Result { + let token = if let Some(path) = args.credential_path.as_deref() { + let raw = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read Buildkite token from {}", path.display()))?; + raw.trim().to_string() + } else { + return Err(anyhow!("Buildkite access-map requires a validated token from scan results")); + }; + + map_access_from_token(&token).await +} + +pub async fn map_access_from_token(token: &str) -> Result { + let client = Client::builder() + .user_agent(GLOBAL_USER_AGENT.as_str()) + .build() + .context("Failed to build Buildkite HTTP client")?; + + let token_info = fetch_access_token(&client, token).await?; + + let user = fetch_user(&client, token).await.unwrap_or_else(|err| { + warn!("Buildkite access-map: user lookup failed: {err}"); + BuildkiteUser { id: None, name: None, email: None, created_at: None } + }); + + let username = user + .name + .clone() + .or_else(|| user.email.clone()) + .unwrap_or_else(|| "buildkite_user".to_string()); + + let identity = AccessSummary { + id: username.clone(), + access_type: "user".into(), + project: None, + tenant: None, + account_id: user.id.clone(), + }; + + let mut risk_notes = Vec::new(); + let mut resources = Vec::new(); + let mut permissions = PermissionSummary::default(); + let mut roles = Vec::new(); + + for scope in &token_info.scopes { + let role = RoleBinding { + name: format!("scope:{scope}"), + source: "buildkite".into(), + permissions: vec![scope.clone()], + }; + roles.push(role); + + match classify_scope(scope) { + ScopeRisk::Admin => permissions.admin.push(scope.clone()), + ScopeRisk::Write => permissions.risky.push(scope.clone()), + ScopeRisk::Read => permissions.read_only.push(scope.clone()), + } + } + + let orgs = list_organizations(&client, token).await.unwrap_or_else(|err| { + warn!("Buildkite access-map: organization enumeration failed: {err}"); + Vec::new() + }); + + for org in &orgs { + let org_name = org + .slug + .clone() + .or_else(|| org.name.clone()) + .unwrap_or_else(|| "unknown_org".to_string()); + + resources.push(ResourceExposure { + resource_type: "organization".into(), + name: org_name.clone(), + permissions: token_info.scopes.clone(), + risk: severity_to_str(if has_admin_scope(&token_info.scopes) { + Severity::High + } else { + Severity::Medium + }) + .to_string(), + reason: "Organization accessible with this token".to_string(), + }); + + let pipelines = list_pipelines(&client, token, &org_name).await.unwrap_or_else(|err| { + warn!("Buildkite access-map: pipeline enumeration for {org_name} failed: {err}"); + Vec::new() + }); + + for pipeline in &pipelines { + let pipeline_name = pipeline + .name + .clone() + .or_else(|| pipeline.slug.clone()) + .unwrap_or_else(|| "unknown_pipeline".to_string()); + + let is_private = pipeline.visibility.as_deref() != Some("public"); + + let (risk, perm_label) = if is_private { + (Severity::Medium, "pipeline:private") + } else { + (Severity::Low, "pipeline:public") + }; + + resources.push(ResourceExposure { + resource_type: "pipeline".into(), + name: format!("{org_name}/{pipeline_name}"), + permissions: vec![perm_label.to_string()], + risk: severity_to_str(risk).to_string(), + reason: if is_private { + "Accessible private pipeline".to_string() + } else { + "Accessible public pipeline".to_string() + }, + }); + } + } + + permissions.admin.sort(); + permissions.admin.dedup(); + permissions.risky.sort(); + permissions.risky.dedup(); + permissions.read_only.sort(); + permissions.read_only.dedup(); + + let severity = derive_severity(&token_info.scopes, &orgs); + + if orgs.is_empty() && token_info.scopes.is_empty() { + resources.push(ResourceExposure { + resource_type: "account".into(), + name: username.clone(), + permissions: Vec::new(), + risk: severity_to_str(Severity::Low).to_string(), + reason: "Buildkite account associated with the token".into(), + }); + risk_notes.push("Token did not enumerate any organizations or scopes".into()); + } + + Ok(AccessMapResult { + cloud: "buildkite".into(), + identity, + roles, + permissions, + resources, + severity, + recommendations: build_recommendations(severity), + risk_notes, + token_details: Some(AccessTokenDetails { + name: user.name.clone(), + username: user.email.clone(), + account_type: None, + company: None, + location: None, + email: user.email.clone(), + url: None, + token_type: None, + created_at: user.created_at.clone(), + last_used_at: None, + expires_at: None, + user_id: user.id.or(token_info.uuid), + scopes: token_info.scopes, + }), + provider_metadata: None, + fingerprint: None, + }) +} + +async fn fetch_access_token(client: &Client, token: &str) -> Result { + let resp = client + .get(format!("{BUILDKITE_API}/access-token")) + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .header(header::ACCEPT, "application/json") + .send() + .await + .context("Buildkite access-map: failed to fetch access-token info")?; + + if !resp.status().is_success() { + return Err(anyhow!( + "Buildkite access-map: access-token lookup failed with HTTP {}", + resp.status() + )); + } + + resp.json().await.context("Buildkite access-map: invalid access-token JSON") +} + +async fn fetch_user(client: &Client, token: &str) -> Result { + let resp = client + .get(format!("{BUILDKITE_API}/user")) + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .header(header::ACCEPT, "application/json") + .send() + .await + .context("Buildkite access-map: failed to fetch user info")?; + + if !resp.status().is_success() { + return Err(anyhow!( + "Buildkite access-map: user lookup failed with HTTP {}", + resp.status() + )); + } + + resp.json().await.context("Buildkite access-map: invalid user JSON") +} + +async fn list_organizations(client: &Client, token: &str) -> Result> { + let resp = client + .get(format!("{BUILDKITE_API}/organizations")) + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .header(header::ACCEPT, "application/json") + .send() + .await + .context("Buildkite access-map: failed to list organizations")?; + + if !resp.status().is_success() { + warn!("Buildkite access-map: organization enumeration failed with HTTP {}", resp.status()); + return Ok(Vec::new()); + } + + resp.json().await.context("Buildkite access-map: invalid organizations JSON") +} + +async fn list_pipelines( + client: &Client, + token: &str, + org_slug: &str, +) -> Result> { + let mut pipelines = Vec::new(); + let mut page = 1; + + loop { + let resp = client + .get(format!( + "{BUILDKITE_API}/organizations/{org_slug}/pipelines?per_page=100&page={page}" + )) + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .header(header::ACCEPT, "application/json") + .send() + .await + .context("Buildkite access-map: failed to list pipelines")?; + + if !resp.status().is_success() { + warn!("Buildkite access-map: pipeline enumeration failed with HTTP {}", resp.status()); + break; + } + + let batch: Vec = + resp.json().await.context("Buildkite access-map: invalid pipelines JSON")?; + + if batch.is_empty() { + break; + } + + pipelines.extend(batch); + page += 1; + } + + Ok(pipelines) +} + +enum ScopeRisk { + Admin, + Write, + Read, +} + +fn classify_scope(scope: &str) -> ScopeRisk { + match scope { + "write_organizations" | "write_teams" => ScopeRisk::Admin, + "write_pipelines" + | "write_builds" + | "write_agents" + | "write_artifacts" + | "write_build_logs" + | "write_notification_services" + | "write_suites" + | "write_test_plan" + | "write_user" + | "write_registries" + | "write_clusters" + | "write_cluster_tokens" + | "write_rule" => ScopeRisk::Write, + _ if scope.starts_with("write_") => ScopeRisk::Write, + _ => ScopeRisk::Read, + } +} + +fn has_admin_scope(scopes: &[String]) -> bool { + scopes.iter().any(|s| matches!(s.as_str(), "write_organizations" | "write_teams")) +} + +fn derive_severity(scopes: &[String], orgs: &[BuildkiteOrganization]) -> Severity { + if has_admin_scope(scopes) { + return Severity::High; + } + + let has_write = scopes.iter().any(|s| s.starts_with("write_")); + if has_write && !orgs.is_empty() { + return Severity::Medium; + } + + if !orgs.is_empty() { + return Severity::Low; + } + + Severity::Low +} + +fn severity_to_str(severity: Severity) -> &'static str { + match severity { + Severity::Low => "low", + Severity::Medium => "medium", + Severity::High => "high", + Severity::Critical => "critical", + } +} diff --git a/src/access_map/harness.rs b/src/access_map/harness.rs new file mode 100644 index 0000000..cac4fb2 --- /dev/null +++ b/src/access_map/harness.rs @@ -0,0 +1,404 @@ +use anyhow::{anyhow, Context, Result}; +use reqwest::{header, Client, StatusCode}; +use serde::{de::DeserializeOwned, Deserialize}; +use serde_json::Value; +use tracing::warn; + +use crate::{cli::commands::access_map::AccessMapArgs, validation::GLOBAL_USER_AGENT}; + +use super::{ + build_recommendations, AccessMapResult, AccessSummary, AccessTokenDetails, PermissionSummary, + ResourceExposure, RoleBinding, Severity, +}; + +const HARNESS_API: &str = "https://app.harness.io"; + +#[derive(Debug, Deserialize, Default, Clone)] +struct HarnessOrg { + #[serde(default)] + identifier: Option, + #[serde(default)] + name: Option, + #[serde(default, rename = "accountIdentifier")] + account_identifier: Option, +} + +#[derive(Debug, Deserialize, Default, Clone)] +struct HarnessProject { + #[serde(default)] + identifier: Option, + #[serde(default)] + name: Option, +} + +pub async fn map_access(args: &AccessMapArgs) -> Result { + let token = if let Some(path) = args.credential_path.as_deref() { + let raw = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read Harness token from {}", path.display()))?; + raw.trim().to_string() + } else { + return Err(anyhow!("Harness access-map requires a validated token from scan results")); + }; + + map_access_from_token(&token).await +} + +pub async fn map_access_from_token(token: &str) -> Result { + let client = Client::builder() + .user_agent(GLOBAL_USER_AGENT.as_str()) + .build() + .context("Failed to build Harness HTTP client")?; + + let aggregate = fetch_api_key_aggregate(&client, token).await?; + let discovered_scopes = extract_first_string_vec( + aggregate.as_ref(), + &["data.scopes", "scopes", "data.permissions", "permissions"], + ); + + let token_name = extract_first_string( + aggregate.as_ref(), + &["data.name", "name", "data.identifier", "identifier"], + ); + let token_id = + extract_first_string(aggregate.as_ref(), &["data.id", "id", "data.uuid", "uuid"]); + let account_id = extract_first_string( + aggregate.as_ref(), + &["data.accountIdentifier", "accountIdentifier", "data.accountId", "accountId"], + ); + + let mut risk_notes = Vec::new(); + let orgs = list_organizations(&client, token).await.unwrap_or_else(|err| { + warn!("Harness access-map: organization enumeration failed: {err}"); + risk_notes.push(format!("Organization enumeration failed: {err}")); + Vec::new() + }); + + let mut resources = Vec::new(); + let mut roles = Vec::new(); + let mut permissions = PermissionSummary::default(); + let mut total_projects = 0usize; + + for scope in &discovered_scopes { + roles.push(RoleBinding { + name: format!("scope:{scope}"), + source: "harness".into(), + permissions: vec![scope.clone()], + }); + + let scope_lc = scope.to_ascii_lowercase(); + if scope_lc.contains("admin") || scope_lc.contains("manage") || scope_lc.contains("owner") { + permissions.admin.push(scope.clone()); + } else if scope_lc.contains("write") + || scope_lc.contains("create") + || scope_lc.contains("update") + || scope_lc.contains("delete") + || scope_lc.contains("execute") + { + permissions.risky.push(scope.clone()); + } else { + permissions.read_only.push(scope.clone()); + } + } + + for org in &orgs { + let org_name = org + .identifier + .clone() + .or_else(|| org.name.clone()) + .unwrap_or_else(|| "unknown_org".to_string()); + + resources.push(ResourceExposure { + resource_type: "organization".into(), + name: org_name.clone(), + permissions: vec!["organization:read".to_string()], + risk: severity_to_str(Severity::Low).to_string(), + reason: "Organization visible to this API key".to_string(), + }); + + let projects = list_projects(&client, token, &org_name).await.unwrap_or_else(|err| { + warn!("Harness access-map: project enumeration for {org_name} failed: {err}"); + risk_notes.push(format!("Project enumeration for org {org_name} failed: {err}")); + Vec::new() + }); + + total_projects += projects.len(); + for project in &projects { + let project_name = project + .identifier + .clone() + .or_else(|| project.name.clone()) + .unwrap_or_else(|| "unknown_project".to_string()); + + resources.push(ResourceExposure { + resource_type: "project".into(), + name: format!("{org_name}/{project_name}"), + permissions: vec!["project:read".to_string()], + risk: severity_to_str(Severity::Medium).to_string(), + reason: "Project visible to this API key".to_string(), + }); + } + } + + if resources.is_empty() { + resources.push(ResourceExposure { + resource_type: "account".into(), + name: account_id.clone().unwrap_or_else(|| "harness_account".to_string()), + permissions: Vec::new(), + risk: severity_to_str(Severity::Low).to_string(), + reason: "Harness account associated with this API key".into(), + }); + risk_notes.push( + "No organizations/projects were enumerable with this key (scope-limited or API access restricted)" + .into(), + ); + } + + permissions.admin.sort(); + permissions.admin.dedup(); + permissions.risky.sort(); + permissions.risky.dedup(); + permissions.read_only.sort(); + permissions.read_only.dedup(); + + let severity = derive_severity(&permissions, total_projects); + let identity_label = token_name.unwrap_or_else(|| "harness_api_key".to_string()); + + Ok(AccessMapResult { + cloud: "harness".into(), + identity: AccessSummary { + id: identity_label, + access_type: "token".into(), + project: None, + tenant: None, + account_id: account_id + .or_else(|| orgs.iter().find_map(|o| o.account_identifier.clone())), + }, + roles, + permissions, + resources, + severity, + recommendations: build_recommendations(severity), + risk_notes, + token_details: Some(AccessTokenDetails { + name: None, + username: None, + account_type: Some("api_key".into()), + company: None, + location: None, + email: None, + url: Some("https://app.harness.io/".into()), + token_type: Some("pat".into()), + created_at: None, + last_used_at: None, + expires_at: None, + user_id: token_id, + scopes: discovered_scopes, + }), + provider_metadata: None, + fingerprint: None, + }) +} + +async fn fetch_api_key_aggregate(client: &Client, token: &str) -> Result> { + let resp = client + .get(format!("{HARNESS_API}/ng/api/apikey/aggregate")) + .header("x-api-key", token) + .header(header::ACCEPT, "application/json") + .send() + .await + .context("Harness access-map: failed to query API key aggregate endpoint")?; + + match resp.status() { + StatusCode::OK | StatusCode::BAD_REQUEST | StatusCode::FORBIDDEN => { + let json = resp + .json::() + .await + .context("Harness access-map: invalid JSON from aggregate endpoint")?; + Ok(Some(json)) + } + StatusCode::UNAUTHORIZED => { + Err(anyhow!("Harness access-map: token rejected with HTTP 401")) + } + status => { + warn!("Harness access-map: aggregate endpoint returned HTTP {}", status); + Ok(None) + } + } +} + +async fn list_organizations(client: &Client, token: &str) -> Result> { + let mut orgs = Vec::new(); + let mut page = 1usize; + + loop { + let resp = client + .get(format!("{HARNESS_API}/v1/orgs?limit=100&page={page}")) + .header("x-api-key", token) + .header(header::ACCEPT, "application/json") + .send() + .await + .context("Harness access-map: failed to list organizations")?; + + match resp.status() { + StatusCode::OK => { + let json: Value = + resp.json().await.context("Harness access-map: invalid organizations JSON")?; + let batch: Vec = parse_collection(json); + if batch.is_empty() { + break; + } + orgs.extend(batch); + page += 1; + } + StatusCode::UNAUTHORIZED => { + return Err(anyhow!("Harness access-map: organization listing unauthorized (401)")); + } + StatusCode::FORBIDDEN => break, + status => { + warn!("Harness access-map: org enumeration returned HTTP {}", status); + break; + } + } + } + + Ok(orgs) +} + +async fn list_projects(client: &Client, token: &str, org: &str) -> Result> { + let mut projects = Vec::new(); + let mut page = 1usize; + + loop { + let resp = client + .get(format!("{HARNESS_API}/v1/orgs/{org}/projects?limit=100&page={page}")) + .header("x-api-key", token) + .header(header::ACCEPT, "application/json") + .send() + .await + .context("Harness access-map: failed to list projects")?; + + match resp.status() { + StatusCode::OK => { + let json: Value = + resp.json().await.context("Harness access-map: invalid projects JSON")?; + let batch: Vec = parse_collection(json); + if batch.is_empty() { + break; + } + projects.extend(batch); + page += 1; + } + StatusCode::UNAUTHORIZED => { + return Err(anyhow!("Harness access-map: project listing unauthorized (401)")); + } + StatusCode::FORBIDDEN => break, + status => { + warn!( + "Harness access-map: project enumeration for org {org} returned HTTP {}", + status + ); + break; + } + } + } + + Ok(projects) +} + +fn parse_collection(value: Value) -> Vec { + if let Ok(items) = serde_json::from_value::>(value.clone()) { + return items; + } + + if let Some(data) = value.get("data") { + if let Ok(items) = serde_json::from_value::>(data.clone()) { + return items; + } + if let Some(content) = data.get("content") { + if let Ok(items) = serde_json::from_value::>(content.clone()) { + return items; + } + } + if let Some(items) = data.get("items") { + if let Ok(items) = serde_json::from_value::>(items.clone()) { + return items; + } + } + } + + if let Some(content) = value.get("content") { + if let Ok(items) = serde_json::from_value::>(content.clone()) { + return items; + } + } + + Vec::new() +} + +fn extract_first_string(value: Option<&Value>, paths: &[&str]) -> Option { + let value = value?; + for path in paths { + if let Some(v) = value_at_path(value, path) { + if let Some(s) = v.as_str() { + if !s.is_empty() { + return Some(s.to_string()); + } + } + } + } + None +} + +fn extract_first_string_vec(value: Option<&Value>, paths: &[&str]) -> Vec { + let Some(value) = value else { + return Vec::new(); + }; + + for path in paths { + if let Some(v) = value_at_path(value, path) { + if let Some(arr) = v.as_array() { + let mut out: Vec = arr + .iter() + .filter_map(|x| x.as_str().map(|s| s.to_string())) + .filter(|s| !s.is_empty()) + .collect(); + out.sort(); + out.dedup(); + if !out.is_empty() { + return out; + } + } + } + } + + Vec::new() +} + +fn value_at_path<'a>(value: &'a Value, path: &str) -> Option<&'a Value> { + let mut current = value; + for part in path.split('.') { + current = current.get(part)?; + } + Some(current) +} + +fn derive_severity(permissions: &PermissionSummary, total_projects: usize) -> Severity { + if !permissions.admin.is_empty() { + return Severity::High; + } + + if !permissions.risky.is_empty() || total_projects > 0 { + return Severity::Medium; + } + + Severity::Low +} + +fn severity_to_str(severity: Severity) -> &'static str { + match severity { + Severity::Low => "low", + Severity::Medium => "medium", + Severity::High => "high", + Severity::Critical => "critical", + } +} diff --git a/src/cli/commands/access_map.rs b/src/cli/commands/access_map.rs index 9a8b491..9592822 100644 --- a/src/cli/commands/access_map.rs +++ b/src/cli/commands/access_map.rs @@ -5,7 +5,7 @@ use clap::{Args, ValueEnum}; /// Inspect a cloud credential and derive the effective identity and blast radius. #[derive(Args, Debug)] pub struct AccessMapArgs { - /// Cloud provider: aws | gcp | azure | github | gitlab | slack | postgres | mongodb | huggingface | gitea | bitbucket + /// Cloud provider: aws | gcp | azure | github | gitlab | slack | postgres | mongodb | huggingface | gitea | bitbucket | buildkite | harness #[clap(value_parser, value_name = "PROVIDER")] pub provider: AccessMapProvider, @@ -49,4 +49,8 @@ pub enum AccessMapProvider { Gitea, /// Bitbucket Bitbucket, + /// Buildkite + Buildkite, + /// Harness + Harness, } diff --git a/src/reporter.rs b/src/reporter.rs index 1a31f61..d8e2b7f 100644 --- a/src/reporter.rs +++ b/src/reporter.rs @@ -299,6 +299,26 @@ fn build_revoke_command( akid_from_validation_body: Option<&str>, ) -> Option { let required_vars = required_vars_for_revocation(revocation); + + // Only generate a revoke command when the report can produce a *runnable* command line. + // If a revocation template references variables we can't populate from the finding data, + // omit the revoke command entirely (instead of suggesting a command that will fail at runtime). + let mut provided_vars: BTreeSet = BTreeSet::new(); + provided_vars.insert("TOKEN".to_string()); + for (k, v) in dependent_captures { + if !v.trim().is_empty() { + provided_vars.insert(k.to_ascii_uppercase()); + } + } + if let Some(akid) = akid_from_captures.or(akid_from_validation_body) { + if !akid.trim().is_empty() { + provided_vars.insert("AKID".to_string()); + } + } + if required_vars.iter().any(|req| !provided_vars.contains(req)) { + return None; + } + let var_args = build_var_args( dependent_captures, akid_from_captures, @@ -365,6 +385,24 @@ fn build_validate_command( use crate::rules::Validation; let required_vars = required_vars_for_validation(validation); + + // Same as revoke: only emit a validate command if it's runnable from the report output. + let mut provided_vars: BTreeSet = BTreeSet::new(); + provided_vars.insert("TOKEN".to_string()); + for (k, v) in dependent_captures { + if !v.trim().is_empty() { + provided_vars.insert(k.to_ascii_uppercase()); + } + } + if let Some(akid) = akid_from_captures.or(akid_from_validation_body) { + if !akid.trim().is_empty() { + provided_vars.insert("AKID".to_string()); + } + } + if required_vars.iter().any(|req| !provided_vars.contains(req)) { + return None; + } + let var_args = build_var_args( dependent_captures, akid_from_captures, @@ -1005,11 +1043,23 @@ impl DetailsReporter { // Generate revoke command for active credentials with revocation support let revoke_cmd = if rm.validation_success { if let Some(revocation) = &rm.m.rule.syntax().revocation { + // Merge dependent captures with named regex captures so the generated command is runnable. + // (Some rules capture required revocation parameters directly in the match.) + let mut merged_vars = rm.m.dependent_captures.clone(); + for cap in rm.m.groups.captures.iter() { + let Some(name) = cap.name else { continue }; + if name.eq_ignore_ascii_case("TOKEN") { + continue; + } + merged_vars + .entry(name.to_uppercase()) + .or_insert_with(|| cap.raw_value().to_string()); + } build_revoke_command( rm.m.rule.id(), revocation, &raw_snippet, - &rm.m.dependent_captures, + &merged_vars, akid_from_captures.as_deref(), akid_from_body.as_deref(), ) @@ -1642,6 +1692,35 @@ mod tests { assert!(!vars.contains("B64ENC")); } + #[test] + fn build_revoke_command_is_omitted_when_required_vars_missing() { + // Revocation template requires ACCOUNTIDENTIFIER, but the finding doesn't have it. + let revocation = Revocation::Http(crate::rules::HttpValidation { + request: crate::rules::HttpRequest { + method: "DELETE".to_string(), + url: "https://example.com/revoke?accountIdentifier={{ ACCOUNTIDENTIFIER }}&token={{ TOKEN }}" + .to_string(), + headers: BTreeMap::new(), + body: None, + response_matcher: None, + multipart: None, + response_is_html: false, + }, + multipart: None, + }); + + let cmd = build_revoke_command( + "kingfisher.example.1", + &revocation, + "secret", + &BTreeMap::new(), + None, + None, + ); + + assert!(cmd.is_none(), "command should be omitted when vars missing, got: {cmd:?}"); + } + fn sample_scan_args() -> ScanArgs { ScanArgs { num_jobs: 1, diff --git a/src/scanner/validation.rs b/src/scanner/validation.rs index fa52848..7bd1272 100644 --- a/src/scanner/validation.rs +++ b/src/scanner/validation.rs @@ -137,6 +137,21 @@ impl AccessMapCollector { }); } + pub fn record_buildkite(&self, token: &str, fingerprint: String) { + let key = xxhash_rust::xxh3::xxh3_64(format!("buildkite|{token}").as_bytes()); + self.inner.entry(key).or_insert_with(|| AccessMapRequest::Buildkite { + token: token.to_string(), + fingerprint, + }); + } + + pub fn record_harness(&self, token: &str, fingerprint: String) { + let key = xxhash_rust::xxh3::xxh3_64(format!("harness|{token}").as_bytes()); + self.inner + .entry(key) + .or_insert_with(|| AccessMapRequest::Harness { token: token.to_string(), fingerprint }); + } + pub fn into_requests(self) -> Vec { self.inner.iter().map(|entry| entry.value().clone()).collect() } @@ -763,7 +778,21 @@ fn maybe_record_access_map(om: &OwnedBlobMatch, collector: Option<&AccessMapColl if om.rule.id().starts_with("kingfisher.bitbucket.") { if let Some((_, value, ..)) = captures.iter().find(|(name, ..)| name == "TOKEN") { if !value.is_empty() { - collector.record_bitbucket(value, fp); + collector.record_bitbucket(value, fp.clone()); + } + } + } + if om.rule.id().starts_with("kingfisher.buildkite.") { + if let Some((_, value, ..)) = captures.iter().find(|(name, ..)| name == "TOKEN") { + if !value.is_empty() { + collector.record_buildkite(value, fp.clone()); + } + } + } + if om.rule.id().starts_with("kingfisher.harness.") { + if let Some((_, value, ..)) = captures.iter().find(|(name, ..)| name == "TOKEN") { + if !value.is_empty() { + collector.record_harness(value, fp.clone()); } } }