added buildkit and harness to access-map

This commit is contained in:
Mick Grove 2026-02-17 22:58:29 -08:00
commit 3b1085baa6
9 changed files with 1031 additions and 8 deletions

View file

@ -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`

View file

@ -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]

View file

@ -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 <FILE>`).
- **Token types supported**: tokens accepted by Buildkite's REST API with `Authorization: Bearer <TOKEN>` (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 <FILE>`).
- **Auth header**: Harness APIs authenticate via `x-api-key: <TOKEN>` (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.

View file

@ -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<AccessMapRequest>) -> Vec<AccessMapResul
AccessMapRequest::Bitbucket { token, fingerprint } => {
(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<AccessMapResult> {
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<AccessMapResult> {
harness::map_access_from_token(token).await
}
}
// -------------------------------------------------------------------------------------------------
// Helper functions
// -------------------------------------------------------------------------------------------------

368
src/access_map/buildkite.rs Normal file
View file

@ -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<String>,
#[serde(default)]
scopes: Vec<String>,
}
#[derive(Deserialize)]
struct BuildkiteUser {
#[serde(default)]
id: Option<String>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
email: Option<String>,
#[serde(default)]
created_at: Option<String>,
}
#[derive(Deserialize)]
struct BuildkiteOrganization {
#[serde(default)]
name: Option<String>,
#[serde(default)]
slug: Option<String>,
}
#[derive(Deserialize)]
struct BuildkitePipeline {
#[serde(default)]
name: Option<String>,
#[serde(default)]
slug: Option<String>,
#[serde(default)]
visibility: Option<String>,
}
pub async fn map_access(args: &AccessMapArgs) -> Result<AccessMapResult> {
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<AccessMapResult> {
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<BuildkiteAccessToken> {
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<BuildkiteUser> {
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<Vec<BuildkiteOrganization>> {
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<Vec<BuildkitePipeline>> {
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<BuildkitePipeline> =
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",
}
}

404
src/access_map/harness.rs Normal file
View file

@ -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<String>,
#[serde(default)]
name: Option<String>,
#[serde(default, rename = "accountIdentifier")]
account_identifier: Option<String>,
}
#[derive(Debug, Deserialize, Default, Clone)]
struct HarnessProject {
#[serde(default)]
identifier: Option<String>,
#[serde(default)]
name: Option<String>,
}
pub async fn map_access(args: &AccessMapArgs) -> Result<AccessMapResult> {
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<AccessMapResult> {
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<Option<Value>> {
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::<Value>()
.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<Vec<HarnessOrg>> {
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<HarnessOrg> = 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<Vec<HarnessProject>> {
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<HarnessProject> = 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<T: DeserializeOwned>(value: Value) -> Vec<T> {
if let Ok(items) = serde_json::from_value::<Vec<T>>(value.clone()) {
return items;
}
if let Some(data) = value.get("data") {
if let Ok(items) = serde_json::from_value::<Vec<T>>(data.clone()) {
return items;
}
if let Some(content) = data.get("content") {
if let Ok(items) = serde_json::from_value::<Vec<T>>(content.clone()) {
return items;
}
}
if let Some(items) = data.get("items") {
if let Ok(items) = serde_json::from_value::<Vec<T>>(items.clone()) {
return items;
}
}
}
if let Some(content) = value.get("content") {
if let Ok(items) = serde_json::from_value::<Vec<T>>(content.clone()) {
return items;
}
}
Vec::new()
}
fn extract_first_string(value: Option<&Value>, paths: &[&str]) -> Option<String> {
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<String> {
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<String> = 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",
}
}

View file

@ -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,
}

View file

@ -299,6 +299,26 @@ fn build_revoke_command(
akid_from_validation_body: Option<&str>,
) -> Option<String> {
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<String> = 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<String> = 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,

View file

@ -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<AccessMapRequest> {
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());
}
}
}