added more access-maps

This commit is contained in:
Mick Grove 2026-02-19 18:19:20 -08:00
commit a9c5d8524f
10 changed files with 1071 additions and 38 deletions

View file

@ -6,7 +6,10 @@ All notable changes to this project will be documented in this file.
- 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`.
- Access Map: added OpenAI provider. Supports standalone `access-map openai` and automatic mapping for validated `kingfisher.openai.*` findings.
- Access Map: added Anthropic provider. Supports standalone `access-map anthropic` and automatic mapping for validated `kingfisher.anthropic.*` findings.
- Access Map: added Salesforce provider. Supports standalone `access-map salesforce` (token + instance) and automatic mapping for validated `kingfisher.salesforce.*` findings.
- Access Map CLI: added providers `buildkite`, `harness`, `openai`, `anthropic`, `salesforce`.
- Reports: omit `validate`/`revoke` command hints when required template vars are missing (prevents suggesting unrunnable commands, e.g. Harness `ACCOUNTIDENTIFIER`).
- Access Map GCP: added resource enumeration for Cloud KMS key rings, Cloud Functions, Firestore databases, Cloud Spanner instances, and project service accounts.
- Access Map GCP: populated `token_details` with service account metadata (display name, unique ID, disabled status).

View file

@ -1,5 +1,5 @@
rules:
- name: Salesforce Access / Refresh Token
- name: Salesforce Access Token
id: kingfisher.salesforce.1
pattern: |
(?xi)
@ -8,7 +8,7 @@ rules:
00
[A-Z0-9]{13}
!
[A-Z0-9._-]{90,120}
[A-Z0-9._-]{80,260}
)
pattern_requirements:
min_digits: 6
@ -253,5 +253,65 @@ rules:
<sendSecretInApis>true</sendSecretInApis>
<tokenUrl>https://api.example.net/oauth/token</tokenUrl>
</AuthProvider>
references:
- https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/intro_oauth_and_connected_apps.htm
- name: Salesforce Refresh Token
id: kingfisher.salesforce.6
pattern: |
(?xi)(?s)
(?:salesforce|sforce|login\.salesforce\.com|test\.salesforce\.com|my\.salesforce\.com)
(?:.|[\n\r]){0,256}?
\brefresh(?:_|[\s-])token\b
(?:.|[\n\r]){0,24}?
(?:
[:=]
|
["']\s*:\s*["']
)
\s*
(
5A[A-Z0-9._~-]{40,510}
)
(?:
\b
|
["']
)
pattern_requirements:
min_digits: 4
min_entropy: 3.5
confidence: medium
examples:
- |
{
"instance_url": "https://mydomain.my.salesforce.com",
"refresh_token": "5Aep861vGfRt9a8nT3qgV7wU1rYp3kL2mN8dQ6zX4cB7jH9sT1vW2xY3zA4bC5dE6fG7hI8jK9mN0pQ1rS2tU3vW4xY5z"
}
- |
salesforce:
token_endpoint: https://login.salesforce.com/services/oauth2/token
refresh_token: 5AefmTn2q8JdV4pP7xR1wY5zC9kL3mN6qS0uV2xY8bD1fG4hJ7kM9nQ2rT5vW8yZ1aC3eF6gH9jK2mP5sR8uV1xY4
references:
- https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/intro_understanding_web_server_oauth_flow.htm
- https://help.salesforce.com/s/articleView?id=sf.remoteaccess_oauth_refresh_token_flow.htm&type=5
- name: Salesforce Connected App Consumer Key (Prefixed)
id: kingfisher.salesforce.7
pattern: |
(?xi)(?s)
(?:salesforce|sforce|connected(?:_|[\s-])app|consumer(?:_|[\s-])key|client(?:_|[\s-])id)
(?:.|[\n\r]){0,128}?
\b
(
3MVG9[A-Z0-9._~-]{20,180}
)
\b
pattern_requirements:
min_digits: 4
min_entropy: 3.6
confidence: medium
examples:
- 3MVG9P8aWj9n4kT2xQ5mV7rY1bC3dF6gH8jK0mN2pR4tU6wX8zA1cE3gH5kM7qS9uV2xY4bD6fJ8nP1rT3vW5yZ7
references:
- https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/intro_oauth_and_connected_apps.htm

View file

@ -260,8 +260,64 @@ kingfisher access-map harness ./harness.token --json-out harness.access-map.json
- Access map uses `https://app.harness.io` as the API base.
### OpenAI (`openai`)
- **Credential**: a single OpenAI API key string (read from a file for `kingfisher access-map openai <FILE>`).
- **Token types supported**: OpenAI keys accepted by `Authorization: Bearer <TOKEN>` (for example `sk-...`, `sk-proj-...`, `sk-svcacct-...`).
Kingfisher performs read-only enumeration via:
- `GET https://api.openai.com/v1/models` to list accessible models and infer organization ownership.
- `GET https://api.openai.com/v1/me` for token identity metadata when available.
- `GET https://api.openai.com/v1/organization/projects` for project visibility when the key has permission (best-effort).
#### Standalone example (OpenAI)
```bash
printf '%s' 'sk-example...' > ./openai.token
kingfisher access-map openai ./openai.token --json-out openai.access-map.json
```
#### Notes (OpenAI)
- Access map uses `https://api.openai.com/v1` as the API base.
### Salesforce (`salesforce`)
- **Credential**: Salesforce access token plus instance domain.
- **Supported standalone formats** for `kingfisher access-map salesforce <FILE>`:
- JSON:
- `token` (or `access_token`)
- `instance_url` (or `instance`), such as `https://mydomain.my.salesforce.com`
- Free-form text containing both:
- a Salesforce access token (`00...!...`)
- an instance host (`<instance>.my.salesforce.com`)
Kingfisher performs read-only enumeration via:
- `GET /services/data/v60.0/limits` to confirm API access and gather account-level API context.
- `GET /services/oauth2/userinfo` for identity metadata when available.
- `GET /services/data/v60.0/sobjects` for visible object metadata (best-effort).
#### Standalone example (Salesforce)
```bash
cat > ./salesforce.json <<'EOF'
{
"token": "00DE0X0A0M0PeLE!AQcAQH0dMHEXAMPLE...",
"instance_url": "https://mydomain.my.salesforce.com"
}
EOF
kingfisher access-map salesforce ./salesforce.json --json-out salesforce.access-map.json
```
#### Notes (Salesforce)
- Access map currently targets `https://<instance>.my.salesforce.com` and API version `v60.0`.
## 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, Bitbucket, and Buildkite credentials discovered during scans with `--access-map` are automatically collected and mapped, matching the existing behavior for other platforms.
- Validated Hugging Face, Gitea, Bitbucket, Buildkite, Harness, OpenAI, Anthropic, and Salesforce credentials discovered during scans with `--access-map` are automatically collected and mapped, matching the existing behavior for other platforms.

View file

@ -4,6 +4,7 @@ use serde::Serialize;
use crate::cli::commands::access_map::{AccessMapArgs, AccessMapProvider};
mod anthropic;
mod aws;
mod azure;
mod azure_devops;
@ -16,8 +17,10 @@ mod gitlab;
mod harness;
mod huggingface;
pub(crate) mod mongodb;
mod openai;
pub(crate) mod postgres;
mod report;
mod salesforce;
mod slack;
/// Trait for access map providers that map a single token to an access profile.
@ -52,6 +55,9 @@ pub async fn run(args: AccessMapArgs) -> Result<()> {
AccessMapProvider::Bitbucket => bitbucket::map_access(&args).await?,
AccessMapProvider::Buildkite => buildkite::map_access(&args).await?,
AccessMapProvider::Harness => harness::map_access(&args).await?,
AccessMapProvider::Openai => openai::map_access(&args).await?,
AccessMapProvider::Anthropic => anthropic::map_access(&args).await?,
AccessMapProvider::Salesforce => salesforce::map_access(&args).await?,
};
let json = serde_json::to_string_pretty(&result)?;
@ -104,6 +110,12 @@ pub enum AccessMapRequest {
Buildkite { token: String, fingerprint: String },
/// A Harness API token (x-api-key).
Harness { token: String, fingerprint: String },
/// An OpenAI API token.
OpenAI { token: String, fingerprint: String },
/// An Anthropic API token.
Anthropic { token: String, fingerprint: String },
/// A Salesforce access token plus instance domain.
Salesforce { token: String, instance: String, fingerprint: String },
}
/// Structured output describing the resolved identity and its risk profile.
@ -304,6 +316,18 @@ pub async fn map_requests(requests: Vec<AccessMapRequest>) -> Vec<AccessMapResul
AccessMapRequest::Harness { token, fingerprint } => {
(map_token(&HarnessMapper, &token).await, fingerprint)
}
AccessMapRequest::OpenAI { token, fingerprint } => {
(map_token(&OpenAiMapper, &token).await, fingerprint)
}
AccessMapRequest::Anthropic { token, fingerprint } => {
(map_token(&AnthropicMapper, &token).await, fingerprint)
}
AccessMapRequest::Salesforce { token, instance, fingerprint } => (
salesforce::map_access_from_token_and_instance(&token, &instance)
.await
.unwrap_or_else(|err| build_failed_result("salesforce", "token", err)),
fingerprint,
),
};
mapped.fingerprint = Some(fp);
@ -435,6 +459,32 @@ impl TokenAccessMapper for HarnessMapper {
}
}
/// OpenAI access mapper.
pub struct OpenAiMapper;
impl TokenAccessMapper for OpenAiMapper {
fn cloud_name(&self) -> &'static str {
"openai"
}
async fn map_access_from_token(&self, token: &str) -> Result<AccessMapResult> {
openai::map_access_from_token(token).await
}
}
/// Anthropic access mapper.
pub struct AnthropicMapper;
impl TokenAccessMapper for AnthropicMapper {
fn cloud_name(&self) -> &'static str {
"anthropic"
}
async fn map_access_from_token(&self, token: &str) -> Result<AccessMapResult> {
anthropic::map_access_from_token(token).await
}
}
// -------------------------------------------------------------------------------------------------
// Helper functions
// -------------------------------------------------------------------------------------------------

180
src/access_map/anthropic.rs Normal file
View file

@ -0,0 +1,180 @@
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 ANTHROPIC_API: &str = "https://api.anthropic.com/v1";
const ANTHROPIC_VERSION: &str = "2023-06-01";
const MAX_MODEL_RESOURCES: usize = 50;
#[derive(Debug, Deserialize, Default, Clone)]
struct AnthropicModelsResponse {
#[serde(default)]
data: Vec<AnthropicModel>,
}
#[derive(Debug, Deserialize, Default, Clone)]
struct AnthropicModel {
#[serde(default)]
id: Option<String>,
#[serde(default)]
display_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 Anthropic token from {}", path.display()))?;
raw.trim().to_string()
} else {
return Err(anyhow!("Anthropic 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 Anthropic HTTP client")?;
let mut risk_notes = Vec::new();
let mut roles = Vec::new();
let mut permissions = PermissionSummary::default();
let mut resources = Vec::new();
let models = list_models(&client, token).await.unwrap_or_else(|err| {
warn!("Anthropic access-map: model enumeration failed: {err}");
risk_notes.push(format!("Model enumeration failed: {err}"));
Vec::new()
});
let token_kind = detect_token_type(token);
roles.push(RoleBinding {
name: format!("token_type:{token_kind}"),
source: "anthropic".into(),
permissions: vec![format!("token:{token_kind}")],
});
permissions.read_only.push("models:list".to_string());
for model in models.iter().take(MAX_MODEL_RESOURCES) {
let model_name = model
.id
.clone()
.or_else(|| model.display_name.clone())
.unwrap_or_else(|| "unknown_model".to_string());
resources.push(ResourceExposure {
resource_type: "model".into(),
name: model_name,
permissions: vec!["model:read".to_string()],
risk: severity_to_str(Severity::Low).to_string(),
reason: "Model accessible to this Anthropic key".to_string(),
});
}
if models.len() > MAX_MODEL_RESOURCES {
risk_notes.push(format!(
"Model resource list truncated to first {MAX_MODEL_RESOURCES} entries ({} total models visible)",
models.len()
));
}
if resources.is_empty() {
resources.push(ResourceExposure {
resource_type: "account".into(),
name: "anthropic_api_key".into(),
permissions: Vec::new(),
risk: severity_to_str(Severity::Low).to_string(),
reason: "Anthropic account associated with this API key".to_string(),
});
risk_notes.push("No models were enumerable for this key".to_string());
}
permissions.read_only.sort();
permissions.read_only.dedup();
let severity = Severity::Low;
Ok(AccessMapResult {
cloud: "anthropic".into(),
identity: AccessSummary {
id: "anthropic_api_key".into(),
access_type: "token".into(),
project: None,
tenant: None,
account_id: None,
},
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://console.anthropic.com/settings/keys".into()),
token_type: Some(token_kind.to_string()),
created_at: None,
last_used_at: None,
expires_at: None,
user_id: None,
scopes: Vec::new(),
}),
provider_metadata: None,
fingerprint: None,
})
}
async fn list_models(client: &Client, token: &str) -> Result<Vec<AnthropicModel>> {
let resp = client
.get(format!("{ANTHROPIC_API}/models"))
.header("x-api-key", token)
.header("anthropic-version", ANTHROPIC_VERSION)
.header(header::ACCEPT, "application/json")
.send()
.await
.context("Anthropic access-map: failed to list models")?;
if !resp.status().is_success() {
return Err(anyhow!(
"Anthropic access-map: model listing failed with HTTP {}",
resp.status()
));
}
let body: AnthropicModelsResponse =
resp.json().await.context("Anthropic access-map: invalid model list JSON")?;
Ok(body.data)
}
fn detect_token_type(token: &str) -> &'static str {
if token.starts_with("sk-ant-admin") {
"admin_api_key"
} else if token.starts_with("sk-ant-api") {
"api_key"
} else {
"unknown_api_key"
}
}
fn severity_to_str(severity: Severity) -> &'static str {
match severity {
Severity::Low => "low",
Severity::Medium => "medium",
Severity::High => "high",
Severity::Critical => "critical",
}
}

View file

@ -47,17 +47,14 @@ pub async fn map_access_from_json(data: &str) -> Result<AccessMapResult> {
let mut project_id =
if token_context.project_id.is_empty() { None } else { Some(token_context.project_id) };
let sa_metadata = match fetch_service_account_metadata(&http_client, &access_token, &client_email)
.await
{
Ok(meta) => meta,
Err(err) => {
verbose_warn!(
"GCP access-map: failed to fetch service account metadata: {err}"
);
ServiceAccountMetadata::default()
}
};
let sa_metadata =
match fetch_service_account_metadata(&http_client, &access_token, &client_email).await {
Ok(meta) => meta,
Err(err) => {
verbose_warn!("GCP access-map: failed to fetch service account metadata: {err}");
ServiceAccountMetadata::default()
}
};
if project_id.is_none() {
project_id = sa_metadata.project_id.clone();
@ -1105,9 +1102,8 @@ async fn enumerate_resources(
} else if status.is_success() {
let json: Value = serde_json::from_slice(&body)?;
if let Some(items) = json.get("secrets").and_then(|i| i.as_array()) {
let can_access_values = perm_set
.iter()
.any(|p| p.contains("secretmanager.versions.access"));
let can_access_values =
perm_set.iter().any(|p| p.contains("secretmanager.versions.access"));
let can_write = perm_set.iter().any(|p| {
p.contains("secretmanager.secrets.create")
|| p.contains("secretmanager.secrets.update")
@ -1197,7 +1193,11 @@ async fn enumerate_resources(
name: name.to_string(),
permissions: matching_permissions(
&perm_set,
&["cloudkms.cryptoKeys.", "cloudkms.keyRings.", "cloudkms.cryptoKeyVersions."],
&[
"cloudkms.cryptoKeys.",
"cloudkms.keyRings.",
"cloudkms.cryptoKeyVersions.",
],
),
risk: risk.into(),
reason: reason.into(),
@ -1265,24 +1265,20 @@ async fn enumerate_resources(
}
if add_service_accounts {
let url = format!(
"https://iam.googleapis.com/v1/projects/{}/serviceAccounts",
project_id
);
let url = format!("https://iam.googleapis.com/v1/projects/{}/serviceAccounts", project_id);
let resp = client.get(&url).bearer_auth(token).send().await?;
let status = resp.status();
let body = resp.bytes().await?;
if let Some(disabled) = service_disabled_message(&body)? {
verbose_warn!(
"GCP access-map: IAM API disabled for project {project_id}: {disabled}"
);
verbose_warn!("GCP access-map: IAM API disabled for project {project_id}: {disabled}");
} else if status.is_success() {
let json: Value = serde_json::from_slice(&body)?;
if let Some(accounts) = json.get("accounts").and_then(|a| a.as_array()) {
let can_impersonate = perm_set
.iter()
.any(|p| p.contains("serviceAccounts.actAs") || p.contains("serviceAccounts.getAccessToken"));
let can_impersonate = perm_set.iter().any(|p| {
p.contains("serviceAccounts.actAs")
|| p.contains("serviceAccounts.getAccessToken")
});
for sa in accounts {
if let Some(email) = sa.get("email").and_then(|e| e.as_str()) {
@ -1313,10 +1309,7 @@ async fn enumerate_resources(
}
if add_firestore {
let url = format!(
"https://firestore.googleapis.com/v1/projects/{}/databases",
project_id
);
let url = format!("https://firestore.googleapis.com/v1/projects/{}/databases", project_id);
let resp = client.get(&url).bearer_auth(token).send().await?;
let status = resp.status();
let body = resp.bytes().await?;
@ -1363,10 +1356,7 @@ async fn enumerate_resources(
}
if add_spanner {
let url = format!(
"https://spanner.googleapis.com/v1/projects/{}/instances",
project_id
);
let url = format!("https://spanner.googleapis.com/v1/projects/{}/instances", project_id);
let resp = client.get(&url).bearer_auth(token).send().await?;
let status = resp.status();
let body = resp.bytes().await?;

328
src/access_map/openai.rs Normal file
View file

@ -0,0 +1,328 @@
use std::collections::BTreeSet;
use anyhow::{anyhow, Context, Result};
use reqwest::{header, Client, StatusCode};
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 OPENAI_API: &str = "https://api.openai.com/v1";
const MAX_MODEL_RESOURCES: usize = 50;
#[derive(Debug, Deserialize, Default, Clone)]
struct OpenAiMe {
#[serde(default)]
id: Option<String>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
email: Option<String>,
}
#[derive(Debug, Deserialize, Default, Clone)]
struct OpenAiModelsResponse {
#[serde(default)]
data: Vec<OpenAiModel>,
}
#[derive(Debug, Deserialize, Default, Clone)]
struct OpenAiModel {
#[serde(default)]
id: Option<String>,
#[serde(default, rename = "owned_by")]
owned_by: Option<String>,
}
#[derive(Debug, Deserialize, Default, Clone)]
struct OpenAiProjectsResponse {
#[serde(default)]
data: Vec<OpenAiProject>,
}
#[derive(Debug, Deserialize, Default, Clone)]
struct OpenAiProject {
#[serde(default)]
id: Option<String>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
archived: bool,
}
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 OpenAI token from {}", path.display()))?;
raw.trim().to_string()
} else {
return Err(anyhow!("OpenAI 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 OpenAI HTTP client")?;
let mut risk_notes = Vec::new();
let mut roles = Vec::new();
let mut permissions = PermissionSummary::default();
let mut resources = Vec::new();
let models_result = list_models(&client, token).await;
let me_result = fetch_me(&client, token).await;
if models_result.is_err() && me_result.is_err() {
return Err(anyhow!(
"OpenAI access-map: both /models and /me lookups failed; token may not be valid for access mapping"
));
}
let models = models_result.unwrap_or_else(|err| {
warn!("OpenAI access-map: model enumeration failed: {err}");
risk_notes.push(format!("Model enumeration failed: {err}"));
Vec::new()
});
let me = me_result.unwrap_or_else(|err| {
warn!("OpenAI access-map: /me lookup failed: {err}");
risk_notes.push(format!("Identity lookup failed: {err}"));
OpenAiMe::default()
});
let token_kind = detect_token_type(token);
roles.push(RoleBinding {
name: format!("token_type:{token_kind}"),
source: "openai".into(),
permissions: vec![format!("token:{token_kind}")],
});
permissions.read_only.push("models:list".to_string());
let projects = list_projects(&client, token).await.unwrap_or_else(|err| {
warn!("OpenAI access-map: project enumeration failed: {err}");
risk_notes.push(format!("Project enumeration failed: {err}"));
Vec::new()
});
if !projects.is_empty() {
permissions.risky.push("projects:list".to_string());
}
let identity_id = me
.email
.clone()
.or_else(|| me.name.clone())
.or_else(|| me.id.clone())
.unwrap_or_else(|| "openai_api_key".to_string());
let mut owners = BTreeSet::new();
for model in &models {
if let Some(owner) = model.owned_by.as_ref() {
if !owner.is_empty() {
owners.insert(owner.clone());
}
}
}
for owner in owners {
resources.push(ResourceExposure {
resource_type: "organization".into(),
name: owner,
permissions: vec!["models:list".to_string()],
risk: severity_to_str(Severity::Low).to_string(),
reason: "Organization inferred from accessible models".to_string(),
});
}
for project in &projects {
let project_name = project
.name
.clone()
.or_else(|| project.id.clone())
.unwrap_or_else(|| "unknown_project".to_string());
let risk = if project.archived { Severity::Low } else { Severity::Medium };
resources.push(ResourceExposure {
resource_type: "project".into(),
name: project_name,
permissions: vec!["project:read".to_string()],
risk: severity_to_str(risk).to_string(),
reason: "Project visible to this OpenAI key".to_string(),
});
}
let mut model_count = 0usize;
for model in &models {
if model_count >= MAX_MODEL_RESOURCES {
break;
}
if let Some(model_id) = model.id.as_ref() {
resources.push(ResourceExposure {
resource_type: "model".into(),
name: model_id.clone(),
permissions: vec!["model:read".to_string()],
risk: severity_to_str(Severity::Low).to_string(),
reason: "Model accessible to this OpenAI key".to_string(),
});
model_count += 1;
}
}
if models.len() > MAX_MODEL_RESOURCES {
risk_notes.push(format!(
"Model resource list truncated to first {MAX_MODEL_RESOURCES} entries ({} total models visible)",
models.len()
));
}
if resources.is_empty() {
resources.push(ResourceExposure {
resource_type: "account".into(),
name: identity_id.clone(),
permissions: Vec::new(),
risk: severity_to_str(Severity::Low).to_string(),
reason: "OpenAI account associated with this API key".to_string(),
});
risk_notes.push("No projects, organizations, or models were enumerable".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(&permissions, projects.len(), models.len());
Ok(AccessMapResult {
cloud: "openai".into(),
identity: AccessSummary {
id: identity_id,
access_type: "token".into(),
project: None,
tenant: None,
account_id: me.id.clone(),
},
roles,
permissions,
resources,
severity,
recommendations: build_recommendations(severity),
risk_notes,
token_details: Some(AccessTokenDetails {
name: me.name,
username: None,
account_type: Some("api_key".into()),
company: None,
location: None,
email: me.email,
url: Some("https://platform.openai.com/".into()),
token_type: Some(token_kind.to_string()),
created_at: None,
last_used_at: None,
expires_at: None,
user_id: me.id,
scopes: Vec::new(),
}),
provider_metadata: None,
fingerprint: None,
})
}
async fn list_models(client: &Client, token: &str) -> Result<Vec<OpenAiModel>> {
let resp = client
.get(format!("{OPENAI_API}/models"))
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::ACCEPT, "application/json")
.send()
.await
.context("OpenAI access-map: failed to list models")?;
if !resp.status().is_success() {
return Err(anyhow!("OpenAI access-map: model listing failed with HTTP {}", resp.status()));
}
let body: OpenAiModelsResponse =
resp.json().await.context("OpenAI access-map: invalid model list JSON")?;
Ok(body.data)
}
async fn fetch_me(client: &Client, token: &str) -> Result<OpenAiMe> {
let resp = client
.get(format!("{OPENAI_API}/me"))
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::ACCEPT, "application/json")
.send()
.await
.context("OpenAI access-map: failed to query /me")?;
if !resp.status().is_success() {
return Err(anyhow!("OpenAI access-map: /me failed with HTTP {}", resp.status()));
}
resp.json().await.context("OpenAI access-map: invalid /me JSON")
}
async fn list_projects(client: &Client, token: &str) -> Result<Vec<OpenAiProject>> {
let resp = client
.get(format!("{OPENAI_API}/organization/projects"))
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::ACCEPT, "application/json")
.send()
.await
.context("OpenAI access-map: failed to list organization projects")?;
match resp.status() {
StatusCode::OK => {
let body: OpenAiProjectsResponse =
resp.json().await.context("OpenAI access-map: invalid projects JSON")?;
Ok(body.data)
}
StatusCode::FORBIDDEN | StatusCode::NOT_FOUND => Ok(Vec::new()),
StatusCode::UNAUTHORIZED => {
Err(anyhow!("OpenAI access-map: project listing unauthorized (401)"))
}
status => Err(anyhow!("OpenAI access-map: project listing failed with HTTP {status}")),
}
}
fn detect_token_type(token: &str) -> &'static str {
if token.starts_with("sk-proj-") {
"project_api_key"
} else if token.starts_with("sk-svcacct-") {
"service_account_api_key"
} else if token.starts_with("sk-None-") {
"legacy_api_key"
} else {
"api_key"
}
}
fn derive_severity(permissions: &PermissionSummary, projects: usize, models: usize) -> Severity {
if !permissions.admin.is_empty() {
return Severity::High;
}
if !permissions.risky.is_empty() || projects > 0 {
return Severity::Medium;
}
if models > 0 {
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",
}
}

View file

@ -0,0 +1,306 @@
use anyhow::{anyhow, Context, Result};
use once_cell::sync::Lazy;
use regex::Regex;
use reqwest::{header, Client, StatusCode};
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 SALESFORCE_API_VERSION: &str = "v60.0";
const MAX_OBJECT_RESOURCES: usize = 100;
static TOKEN_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?xi)\b(00[A-Z0-9]{13}![A-Z0-9._-]{80,260})\b")
.expect("valid salesforce token regex")
});
static INSTANCE_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?xi)\b([A-Z0-9-]{5,128})\.my\.salesforce\.com\b")
.expect("valid salesforce instance regex")
});
pub async fn map_access(args: &AccessMapArgs) -> Result<AccessMapResult> {
let path = args.credential_path.as_deref().ok_or_else(|| {
anyhow!("Salesforce access-map requires a credential file with token and instance")
})?;
let raw = std::fs::read_to_string(path).with_context(|| {
format!("Failed to read Salesforce credential file from {}", path.display())
})?;
let (token, instance) = parse_salesforce_credentials(&raw)?;
map_access_from_token_and_instance(&token, &instance).await
}
pub async fn map_access_from_token_and_instance(
token: &str,
instance: &str,
) -> Result<AccessMapResult> {
let instance = normalize_instance(instance)
.ok_or_else(|| anyhow!("Salesforce access-map requires a valid instance domain"))?;
let base_url = format!("https://{instance}.my.salesforce.com");
let client = Client::builder()
.user_agent(GLOBAL_USER_AGENT.as_str())
.build()
.context("Failed to build Salesforce HTTP client")?;
let mut risk_notes = Vec::new();
let mut permissions = PermissionSummary::default();
permissions.read_only.push("limits:read".to_string());
let limits = fetch_limits(&client, token, &base_url).await?;
let user_info = fetch_user_info(&client, token, &base_url).await.unwrap_or_else(|err| {
warn!("Salesforce access-map: userinfo lookup failed: {err}");
risk_notes.push(format!("Identity lookup failed: {err}"));
Value::Null
});
let objects = list_sobjects(&client, token, &base_url).await.unwrap_or_else(|err| {
warn!("Salesforce access-map: sobject enumeration failed: {err}");
risk_notes.push(format!("Object enumeration failed: {err}"));
Vec::new()
});
if !objects.is_empty() {
permissions.read_only.push("sobjects:list".to_string());
}
permissions.risky.push("rest_api:access".to_string());
permissions.read_only.sort();
permissions.read_only.dedup();
permissions.risky.sort();
permissions.risky.dedup();
let organization_id =
value_as_string(&user_info, &["organization_id", "organizationId", "org_id", "orgId"]);
let user_id = value_as_string(&user_info, &["user_id", "userId", "sub", "id"]);
let username =
value_as_string(&user_info, &["preferred_username", "preferredUsername", "email", "name"]);
let identity_id = username
.clone()
.or_else(|| user_id.clone())
.or_else(|| organization_id.clone())
.unwrap_or_else(|| "salesforce_access_token".to_string());
let roles = vec![RoleBinding {
name: "token_type:access_token".into(),
source: "salesforce".into(),
permissions: vec!["rest_api:access".into()],
}];
let mut resources = vec![ResourceExposure {
resource_type: "salesforce_org".into(),
name: organization_id.clone().unwrap_or_else(|| instance.clone()),
permissions: vec!["limits:read".into()],
risk: severity_to_str(Severity::Medium).to_string(),
reason: "Salesforce org reachable with this access token".to_string(),
}];
for object_name in objects.iter().take(MAX_OBJECT_RESOURCES) {
resources.push(ResourceExposure {
resource_type: "sobject".into(),
name: object_name.clone(),
permissions: vec!["object:read_metadata".into()],
risk: severity_to_str(Severity::Low).to_string(),
reason: "Object metadata visible to this token".to_string(),
});
}
if objects.len() > MAX_OBJECT_RESOURCES {
risk_notes.push(format!(
"Object resource list truncated to first {MAX_OBJECT_RESOURCES} entries ({} total objects visible)",
objects.len()
));
}
if !limits.is_object() {
risk_notes.push("Salesforce limits response was not a JSON object".to_string());
}
let severity = Severity::Medium;
Ok(AccessMapResult {
cloud: "salesforce".into(),
identity: AccessSummary {
id: identity_id,
access_type: "token".into(),
project: organization_id.clone(),
tenant: None,
account_id: organization_id.clone(),
},
roles,
permissions,
resources,
severity,
recommendations: build_recommendations(severity),
risk_notes,
token_details: Some(AccessTokenDetails {
name: username,
username: None,
account_type: Some("access_token".into()),
company: None,
location: None,
email: None,
url: Some(base_url),
token_type: Some("access_token".into()),
created_at: None,
last_used_at: None,
expires_at: None,
user_id,
scopes: Vec::new(),
}),
provider_metadata: None,
fingerprint: None,
})
}
fn parse_salesforce_credentials(raw: &str) -> Result<(String, String)> {
if let Ok(json) = serde_json::from_str::<Value>(raw) {
let token = value_as_string(&json, &["token", "access_token", "salesforce_token"]);
let instance =
value_as_string(&json, &["instance", "instance_url", "instanceUrl", "domain", "host"]);
if let (Some(token), Some(instance)) = (token, instance) {
let normalized = normalize_instance(&instance).ok_or_else(|| {
anyhow!("Credential JSON contains an invalid Salesforce instance")
})?;
return Ok((token, normalized));
}
}
let token = TOKEN_RE.captures(raw).and_then(|caps| caps.get(1).map(|m| m.as_str().to_string()));
let instance =
INSTANCE_RE.captures(raw).and_then(|caps| caps.get(1).map(|m| m.as_str().to_string()));
if let (Some(token), Some(instance)) = (token, instance) {
return Ok((token, instance));
}
let lines: Vec<&str> = raw
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.collect();
if lines.len() >= 2 {
if let Some(instance) = normalize_instance(lines[1]) {
return Ok((lines[0].to_string(), instance));
}
}
Err(anyhow!(
"Salesforce credential format not recognized. Provide JSON with token + instance_url, or text containing both."
))
}
fn normalize_instance(raw: &str) -> Option<String> {
let mut value = raw.trim().trim_matches('/').to_ascii_lowercase();
if value.starts_with("https://") {
value = value.trim_start_matches("https://").to_string();
} else if value.starts_with("http://") {
value = value.trim_start_matches("http://").to_string();
}
if let Some(rest) = value.strip_suffix(".my.salesforce.com") {
value = rest.to_string();
}
value = value.split('/').next().unwrap_or_default().to_string();
if value.len() < 5 || value.len() > 128 {
return None;
}
if !value.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
return None;
}
Some(value)
}
async fn fetch_limits(client: &Client, token: &str, base_url: &str) -> Result<Value> {
let resp = client
.get(format!("{base_url}/services/data/{SALESFORCE_API_VERSION}/limits"))
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::ACCEPT, "application/json")
.send()
.await
.context("Salesforce access-map: failed to query limits endpoint")?;
if resp.status() != StatusCode::OK {
return Err(anyhow!(
"Salesforce access-map: limits endpoint failed with HTTP {}",
resp.status()
));
}
resp.json().await.context("Salesforce access-map: invalid limits JSON")
}
async fn fetch_user_info(client: &Client, token: &str, base_url: &str) -> Result<Value> {
let resp = client
.get(format!("{base_url}/services/oauth2/userinfo"))
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::ACCEPT, "application/json")
.send()
.await
.context("Salesforce access-map: failed to query userinfo endpoint")?;
if !resp.status().is_success() {
return Err(anyhow!(
"Salesforce access-map: userinfo lookup failed with HTTP {}",
resp.status()
));
}
resp.json().await.context("Salesforce access-map: invalid userinfo JSON")
}
async fn list_sobjects(client: &Client, token: &str, base_url: &str) -> Result<Vec<String>> {
let resp = client
.get(format!("{base_url}/services/data/{SALESFORCE_API_VERSION}/sobjects"))
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::ACCEPT, "application/json")
.send()
.await
.context("Salesforce access-map: failed to query sobjects endpoint")?;
if !resp.status().is_success() {
return Err(anyhow!(
"Salesforce access-map: sobjects listing failed with HTTP {}",
resp.status()
));
}
let body: Value = resp.json().await.context("Salesforce access-map: invalid sobjects JSON")?;
let mut names = Vec::new();
if let Some(arr) = body.get("sobjects").and_then(|v| v.as_array()) {
for item in arr {
if let Some(name) = value_as_string(item, &["name", "label"]) {
if !name.is_empty() {
names.push(name);
}
}
}
}
names.sort();
names.dedup();
Ok(names)
}
fn value_as_string(value: &Value, keys: &[&str]) -> Option<String> {
for key in keys {
if let Some(s) = value.get(*key).and_then(|v| v.as_str()) {
let trimmed = s.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
}
None
}
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 | buildkite | harness
/// Cloud provider: aws | gcp | azure | github | gitlab | slack | postgres | mongodb | huggingface | gitea | bitbucket | buildkite | harness | openai | anthropic | salesforce
#[clap(value_parser, value_name = "PROVIDER")]
pub provider: AccessMapProvider,
@ -53,4 +53,10 @@ pub enum AccessMapProvider {
Buildkite,
/// Harness
Harness,
/// OpenAI
Openai,
/// Anthropic
Anthropic,
/// Salesforce
Salesforce,
}

View file

@ -152,6 +152,30 @@ impl AccessMapCollector {
.or_insert_with(|| AccessMapRequest::Harness { token: token.to_string(), fingerprint });
}
pub fn record_openai(&self, token: &str, fingerprint: String) {
let key = xxhash_rust::xxh3::xxh3_64(format!("openai|{token}").as_bytes());
self.inner
.entry(key)
.or_insert_with(|| AccessMapRequest::OpenAI { token: token.to_string(), fingerprint });
}
pub fn record_anthropic(&self, token: &str, fingerprint: String) {
let key = xxhash_rust::xxh3::xxh3_64(format!("anthropic|{token}").as_bytes());
self.inner.entry(key).or_insert_with(|| AccessMapRequest::Anthropic {
token: token.to_string(),
fingerprint,
});
}
pub fn record_salesforce(&self, token: &str, instance: &str, fingerprint: String) {
let key = xxhash_rust::xxh3::xxh3_64(format!("salesforce|{instance}|{token}").as_bytes());
self.inner.entry(key).or_insert_with(|| AccessMapRequest::Salesforce {
token: token.to_string(),
instance: instance.to_string(),
fingerprint,
});
}
pub fn into_requests(self) -> Vec<AccessMapRequest> {
self.inner.iter().map(|entry| entry.value().clone()).collect()
}
@ -796,6 +820,36 @@ fn maybe_record_access_map(om: &OwnedBlobMatch, collector: Option<&AccessMapColl
}
}
}
if om.rule.id().starts_with("kingfisher.openai.") {
if let Some((_, value, ..)) = captures.iter().find(|(name, ..)| name == "TOKEN") {
if !value.is_empty() {
collector.record_openai(value, fp.clone());
}
}
}
if om.rule.id().starts_with("kingfisher.anthropic.") {
if let Some((_, value, ..)) = captures.iter().find(|(name, ..)| name == "TOKEN") {
if !value.is_empty() {
collector.record_anthropic(value, fp.clone());
}
}
}
if om.rule.id().starts_with("kingfisher.salesforce.") {
let token = captures
.iter()
.find(|(name, ..)| name == "TOKEN")
.map(|(_, value, ..)| value.clone())
.unwrap_or_default();
let instance = captures
.iter()
.find(|(name, ..)| name == "INSTANCE")
.map(|(_, value, ..)| value.clone())
.unwrap_or_default();
if !token.is_empty() && !instance.is_empty() {
collector.record_salesforce(&token, &instance, fp.clone());
}
}
}
}
}