forked from mirrors/kingfisher
added buildkit and harness to access-map
This commit is contained in:
parent
32d40c0b53
commit
3b1085baa6
9 changed files with 1031 additions and 8 deletions
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
368
src/access_map/buildkite.rs
Normal 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
404
src/access_map/harness.rs
Normal 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",
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue