forked from mirrors/kingfisher
added more access-maps
This commit is contained in:
parent
a9c5d8524f
commit
f38df8a953
4 changed files with 553 additions and 102 deletions
|
|
@ -6,7 +6,7 @@ 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: added OpenAI provider. Supports standalone `access-map openai` and automatic mapping for validated `kingfisher.openai.*` findings.
|
||||
- Access Map: added OpenAI provider. Supports standalone `access-map openai` and automatic mapping for validated `kingfisher.openai.*` findings. Enumerates organizations (from `/v1/me`), projects, and API key permission scopes by probing endpoints for restricted key detection.
|
||||
- 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`.
|
||||
|
|
|
|||
|
|
@ -46,4 +46,5 @@ rules:
|
|||
- type: WordMatch
|
||||
words:
|
||||
- '"type":"message"'
|
||||
- 'credit balance is too low'
|
||||
url: https://api.anthropic.com/v1/messages
|
||||
|
|
@ -28,6 +28,32 @@ struct AnthropicModel {
|
|||
display_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default, Clone)]
|
||||
struct AnthropicApiKey {
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
created_at: Option<String>,
|
||||
#[serde(default)]
|
||||
permissions: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default, Clone)]
|
||||
struct AnthropicApiKeysResponse {
|
||||
#[serde(default)]
|
||||
data: Vec<AnthropicApiKey>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
struct KeyIntrospection {
|
||||
permissions: Vec<String>,
|
||||
id: Option<String>,
|
||||
name: Option<String>,
|
||||
created_at: 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)
|
||||
|
|
@ -50,6 +76,12 @@ pub async fn map_access_from_token(token: &str) -> Result<AccessMapResult> {
|
|||
let mut roles = Vec::new();
|
||||
let mut permissions = PermissionSummary::default();
|
||||
let mut resources = Vec::new();
|
||||
let key_info = fetch_key_permissions(&client, token).await.unwrap_or_else(|err| {
|
||||
warn!("Anthropic access-map: key permission lookup failed: {err}");
|
||||
risk_notes.push(format!("Key permission lookup failed: {err}"));
|
||||
KeyIntrospection::default()
|
||||
});
|
||||
let mut token_scopes = key_info.permissions.clone();
|
||||
|
||||
let models = list_models(&client, token).await.unwrap_or_else(|err| {
|
||||
warn!("Anthropic access-map: model enumeration failed: {err}");
|
||||
|
|
@ -63,6 +95,20 @@ pub async fn map_access_from_token(token: &str) -> Result<AccessMapResult> {
|
|||
source: "anthropic".into(),
|
||||
permissions: vec![format!("token:{token_kind}")],
|
||||
});
|
||||
|
||||
token_scopes.sort();
|
||||
token_scopes.dedup();
|
||||
for scope in &token_scopes {
|
||||
roles.push(RoleBinding {
|
||||
name: format!("permission:{scope}"),
|
||||
source: "anthropic".into(),
|
||||
permissions: vec![format!("key:{scope}")],
|
||||
});
|
||||
match scope.as_str() {
|
||||
"full_access" => permissions.admin.push("key:full_access".to_string()),
|
||||
_ => permissions.risky.push(format!("key:{scope}")),
|
||||
}
|
||||
}
|
||||
permissions.read_only.push("models:list".to_string());
|
||||
|
||||
for model in models.iter().take(MAX_MODEL_RESOURCES) {
|
||||
|
|
@ -101,7 +147,7 @@ pub async fn map_access_from_token(token: &str) -> Result<AccessMapResult> {
|
|||
permissions.read_only.sort();
|
||||
permissions.read_only.dedup();
|
||||
|
||||
let severity = Severity::Low;
|
||||
let severity = derive_severity(&permissions);
|
||||
|
||||
Ok(AccessMapResult {
|
||||
cloud: "anthropic".into(),
|
||||
|
|
@ -119,7 +165,7 @@ pub async fn map_access_from_token(token: &str) -> Result<AccessMapResult> {
|
|||
recommendations: build_recommendations(severity),
|
||||
risk_notes,
|
||||
token_details: Some(AccessTokenDetails {
|
||||
name: None,
|
||||
name: key_info.name,
|
||||
username: None,
|
||||
account_type: Some("api_key".into()),
|
||||
company: None,
|
||||
|
|
@ -127,11 +173,11 @@ pub async fn map_access_from_token(token: &str) -> Result<AccessMapResult> {
|
|||
email: None,
|
||||
url: Some("https://console.anthropic.com/settings/keys".into()),
|
||||
token_type: Some(token_kind.to_string()),
|
||||
created_at: None,
|
||||
created_at: key_info.created_at,
|
||||
last_used_at: None,
|
||||
expires_at: None,
|
||||
user_id: None,
|
||||
scopes: Vec::new(),
|
||||
user_id: key_info.id,
|
||||
scopes: token_scopes,
|
||||
}),
|
||||
provider_metadata: None,
|
||||
fingerprint: None,
|
||||
|
|
@ -170,6 +216,106 @@ fn detect_token_type(token: &str) -> &'static str {
|
|||
}
|
||||
}
|
||||
|
||||
async fn fetch_key_permissions(client: &Client, token: &str) -> Result<KeyIntrospection> {
|
||||
if let Ok(Some(key)) = fetch_permissions_from_endpoint(
|
||||
client,
|
||||
token,
|
||||
&format!("{ANTHROPIC_API}/organizations/api_keys/me"),
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Ok(KeyIntrospection {
|
||||
permissions: key.permissions,
|
||||
id: key.id,
|
||||
name: key.name,
|
||||
created_at: key.created_at,
|
||||
});
|
||||
}
|
||||
|
||||
if let Ok(Some(key)) =
|
||||
fetch_permissions_from_endpoint(client, token, &format!("{ANTHROPIC_API}/api_keys/me"))
|
||||
.await
|
||||
{
|
||||
return Ok(KeyIntrospection {
|
||||
permissions: key.permissions,
|
||||
id: key.id,
|
||||
name: key.name,
|
||||
created_at: key.created_at,
|
||||
});
|
||||
}
|
||||
|
||||
let list_resp = client
|
||||
.get(format!("{ANTHROPIC_API}/organizations/api_keys"))
|
||||
.header("x-api-key", token)
|
||||
.header("anthropic-version", ANTHROPIC_VERSION)
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await
|
||||
.context("Anthropic access-map: failed to list API keys")?;
|
||||
|
||||
if !list_resp.status().is_success() {
|
||||
return Err(anyhow!(
|
||||
"Anthropic access-map: API key listing failed with HTTP {}",
|
||||
list_resp.status()
|
||||
));
|
||||
}
|
||||
|
||||
let body: AnthropicApiKeysResponse =
|
||||
list_resp.json().await.context("Anthropic access-map: invalid API key list JSON")?;
|
||||
|
||||
if body.data.len() == 1 {
|
||||
let key = &body.data[0];
|
||||
return Ok(KeyIntrospection {
|
||||
permissions: key.permissions.clone(),
|
||||
id: key.id.clone(),
|
||||
name: key.name.clone(),
|
||||
created_at: key.created_at.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
Err(anyhow!("Anthropic access-map: unable to map listed key permissions to this token"))
|
||||
}
|
||||
|
||||
async fn fetch_permissions_from_endpoint(
|
||||
client: &Client,
|
||||
token: &str,
|
||||
url: &str,
|
||||
) -> Result<Option<AnthropicApiKey>> {
|
||||
let resp = client
|
||||
.get(url)
|
||||
.header("x-api-key", token)
|
||||
.header("anthropic-version", ANTHROPIC_VERSION)
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("Anthropic access-map: failed to query {url}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let body: AnthropicApiKey = resp
|
||||
.json()
|
||||
.await
|
||||
.with_context(|| format!("Anthropic access-map: invalid API key JSON from {url}"))?;
|
||||
|
||||
if body.permissions.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(body))
|
||||
}
|
||||
}
|
||||
|
||||
fn derive_severity(permissions: &PermissionSummary) -> Severity {
|
||||
if !permissions.admin.is_empty() {
|
||||
return Severity::High;
|
||||
}
|
||||
if !permissions.risky.is_empty() {
|
||||
return Severity::Medium;
|
||||
}
|
||||
Severity::Low
|
||||
}
|
||||
|
||||
fn severity_to_str(severity: Severity) -> &'static str {
|
||||
match severity {
|
||||
Severity::Low => "low",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
use std::collections::BTreeSet;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use reqwest::{header, Client, StatusCode};
|
||||
use reqwest::{header, Client, Method, StatusCode};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::{cli::commands::access_map::AccessMapArgs, validation::GLOBAL_USER_AGENT};
|
||||
|
|
@ -13,7 +12,10 @@ use super::{
|
|||
};
|
||||
|
||||
const OPENAI_API: &str = "https://api.openai.com/v1";
|
||||
const MAX_MODEL_RESOURCES: usize = 50;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deserialization types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize, Default, Clone)]
|
||||
struct OpenAiMe {
|
||||
|
|
@ -23,20 +25,33 @@ struct OpenAiMe {
|
|||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
email: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default, Clone)]
|
||||
struct OpenAiModelsResponse {
|
||||
#[serde(default)]
|
||||
data: Vec<OpenAiModel>,
|
||||
#[allow(dead_code)]
|
||||
role: Option<String>,
|
||||
#[serde(default)]
|
||||
orgs: Option<OpenAiOrgsData>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default, Clone)]
|
||||
struct OpenAiModel {
|
||||
struct OpenAiOrgsData {
|
||||
#[serde(default)]
|
||||
data: Vec<OpenAiOrg>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default, Clone)]
|
||||
struct OpenAiOrg {
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
#[serde(default, rename = "owned_by")]
|
||||
owned_by: Option<String>,
|
||||
#[serde(default)]
|
||||
title: Option<String>,
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
personal: Option<bool>,
|
||||
#[serde(default)]
|
||||
is_default: Option<bool>,
|
||||
#[serde(default)]
|
||||
role: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default, Clone)]
|
||||
|
|
@ -55,6 +70,267 @@ struct OpenAiProject {
|
|||
archived: bool,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scope probing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ScopeResult {
|
||||
/// Human-readable scope name (e.g. "/v1/models").
|
||||
scope: &'static str,
|
||||
/// Individual endpoints covered by this scope.
|
||||
endpoints: Vec<&'static str>,
|
||||
/// "Read", "Write", or "Read & Write".
|
||||
permission: &'static str,
|
||||
}
|
||||
|
||||
struct EndpointProbe {
|
||||
path: &'static str,
|
||||
method: Method,
|
||||
body: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Returns true when the status indicates the scope is **not** granted.
|
||||
fn is_scope_denied(status: StatusCode) -> bool {
|
||||
status == StatusCode::FORBIDDEN || status == StatusCode::UNAUTHORIZED
|
||||
}
|
||||
|
||||
async fn probe_endpoint(client: &Client, token: &str, probe: &EndpointProbe) -> bool {
|
||||
let url = format!("{OPENAI_API}{}", probe.path);
|
||||
let mut req = client
|
||||
.request(probe.method.clone(), &url)
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header(header::ACCEPT, "application/json");
|
||||
|
||||
if let Some(body) = &probe.body {
|
||||
req = req.header(header::CONTENT_TYPE, "application/json").json(body);
|
||||
}
|
||||
|
||||
match req.send().await {
|
||||
Ok(resp) => !is_scope_denied(resp.status()),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn probe_api_scopes(client: &Client, token: &str) -> (Vec<ScopeResult>, bool) {
|
||||
let mut scopes = Vec::new();
|
||||
let mut any_denied = false;
|
||||
|
||||
// -- /v1/models (Read) --
|
||||
let models_ok = probe_endpoint(
|
||||
client,
|
||||
token,
|
||||
&EndpointProbe { path: "/models", method: Method::GET, body: None },
|
||||
)
|
||||
.await;
|
||||
if models_ok {
|
||||
scopes.push(ScopeResult {
|
||||
scope: "/v1/models",
|
||||
endpoints: vec!["/v1/models"],
|
||||
permission: "Read",
|
||||
});
|
||||
} else {
|
||||
any_denied = true;
|
||||
}
|
||||
|
||||
// -- Model capabilities (Write) – one probe covers the whole scope --
|
||||
let chat_ok = probe_endpoint(
|
||||
client,
|
||||
token,
|
||||
&EndpointProbe {
|
||||
path: "/chat/completions",
|
||||
method: Method::POST,
|
||||
body: Some(json!({"model": "_probe_"})),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
if chat_ok {
|
||||
scopes.push(ScopeResult {
|
||||
scope: "/v1/model_capabilities",
|
||||
endpoints: vec![
|
||||
"/v1/audio",
|
||||
"/v1/chat/completions",
|
||||
"/v1/embeddings",
|
||||
"/v1/images",
|
||||
"/v1/moderations",
|
||||
],
|
||||
permission: "Write",
|
||||
});
|
||||
} else {
|
||||
any_denied = true;
|
||||
}
|
||||
|
||||
// -- /v1/assistants (Read & Write) --
|
||||
let assist_read = probe_endpoint(
|
||||
client,
|
||||
token,
|
||||
&EndpointProbe { path: "/assistants", method: Method::GET, body: None },
|
||||
)
|
||||
.await;
|
||||
let assist_write = probe_endpoint(
|
||||
client,
|
||||
token,
|
||||
&EndpointProbe {
|
||||
path: "/assistants",
|
||||
method: Method::POST,
|
||||
body: Some(json!({"model": "_probe_"})),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
push_rw_scope(
|
||||
&mut scopes,
|
||||
&mut any_denied,
|
||||
"/v1/assistants",
|
||||
&["/v1/assistants"],
|
||||
assist_read,
|
||||
assist_write,
|
||||
);
|
||||
|
||||
// -- /v1/threads (Read & Write) – read via fake thread GET --
|
||||
let threads_read = probe_endpoint(
|
||||
client,
|
||||
token,
|
||||
&EndpointProbe {
|
||||
path: "/threads/thread_00000000000000000000000000",
|
||||
method: Method::GET,
|
||||
body: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let threads_write = probe_endpoint(
|
||||
client,
|
||||
token,
|
||||
&EndpointProbe {
|
||||
path: "/threads",
|
||||
method: Method::POST,
|
||||
body: Some(json!({"metadata": {"_probe": "1"}})),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
push_rw_scope(
|
||||
&mut scopes,
|
||||
&mut any_denied,
|
||||
"/v1/threads",
|
||||
&["/v1/threads"],
|
||||
threads_read,
|
||||
threads_write,
|
||||
);
|
||||
|
||||
// -- /v1/fine_tuning (Read & Write) --
|
||||
let ft_read = probe_endpoint(
|
||||
client,
|
||||
token,
|
||||
&EndpointProbe { path: "/fine_tuning/jobs", method: Method::GET, body: None },
|
||||
)
|
||||
.await;
|
||||
let ft_write = probe_endpoint(
|
||||
client,
|
||||
token,
|
||||
&EndpointProbe {
|
||||
path: "/fine_tuning/jobs",
|
||||
method: Method::POST,
|
||||
body: Some(json!({"model": "_probe_", "training_file": "_probe_"})),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
push_rw_scope(
|
||||
&mut scopes,
|
||||
&mut any_denied,
|
||||
"/v1/fine_tuning",
|
||||
&["/v1/fine_tuning"],
|
||||
ft_read,
|
||||
ft_write,
|
||||
);
|
||||
|
||||
// -- /v1/files (Read & Write) – write needs multipart so only probe read --
|
||||
let files_read = probe_endpoint(
|
||||
client,
|
||||
token,
|
||||
&EndpointProbe { path: "/files", method: Method::GET, body: None },
|
||||
)
|
||||
.await;
|
||||
push_rw_scope(
|
||||
&mut scopes,
|
||||
&mut any_denied,
|
||||
"/v1/files",
|
||||
&["/v1/files"],
|
||||
files_read,
|
||||
files_read,
|
||||
);
|
||||
|
||||
// -- /v1/evals (Read & Write) --
|
||||
let evals_read = probe_endpoint(
|
||||
client,
|
||||
token,
|
||||
&EndpointProbe { path: "/evals", method: Method::GET, body: None },
|
||||
)
|
||||
.await;
|
||||
let evals_write = probe_endpoint(
|
||||
client,
|
||||
token,
|
||||
&EndpointProbe { path: "/evals", method: Method::POST, body: Some(json!({})) },
|
||||
)
|
||||
.await;
|
||||
push_rw_scope(
|
||||
&mut scopes,
|
||||
&mut any_denied,
|
||||
"/v1/evals",
|
||||
&["/v1/evals"],
|
||||
evals_read,
|
||||
evals_write,
|
||||
);
|
||||
|
||||
// -- /v1/responses (Write) --
|
||||
let responses_ok = probe_endpoint(
|
||||
client,
|
||||
token,
|
||||
&EndpointProbe {
|
||||
path: "/responses",
|
||||
method: Method::POST,
|
||||
body: Some(json!({"model": "_probe_", "input": "x"})),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
if responses_ok {
|
||||
scopes.push(ScopeResult {
|
||||
scope: "/v1/responses",
|
||||
endpoints: vec!["/v1/responses"],
|
||||
permission: "Write",
|
||||
});
|
||||
} else {
|
||||
any_denied = true;
|
||||
}
|
||||
|
||||
(scopes, any_denied)
|
||||
}
|
||||
|
||||
fn push_rw_scope(
|
||||
scopes: &mut Vec<ScopeResult>,
|
||||
any_denied: &mut bool,
|
||||
scope: &'static str,
|
||||
endpoints: &[&'static str],
|
||||
read: bool,
|
||||
write: bool,
|
||||
) {
|
||||
let permission = match (read, write) {
|
||||
(true, true) => "Read & Write",
|
||||
(true, false) => "Read",
|
||||
(false, true) => "Write",
|
||||
(false, false) => {
|
||||
*any_denied = true;
|
||||
return;
|
||||
}
|
||||
};
|
||||
if !read || !write {
|
||||
*any_denied = true;
|
||||
}
|
||||
scopes.push(ScopeResult { scope, endpoints: endpoints.to_vec(), permission });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public entry points
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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)
|
||||
|
|
@ -78,22 +354,11 @@ pub async fn map_access_from_token(token: &str) -> Result<AccessMapResult> {
|
|||
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| {
|
||||
// -- Identity & organizations (/v1/me) --
|
||||
let me = fetch_me(&client, token).await.unwrap_or_else(|err| {
|
||||
warn!("OpenAI access-map: /me lookup failed: {err}");
|
||||
risk_notes.push(format!("Identity lookup failed: {err}"));
|
||||
risk_notes
|
||||
.push(format!("Identity lookup failed (key may be a restricted project key): {err}"));
|
||||
OpenAiMe::default()
|
||||
});
|
||||
|
||||
|
|
@ -104,44 +369,44 @@ pub async fn map_access_from_token(token: &str) -> Result<AccessMapResult> {
|
|||
permissions: vec![format!("token:{token_kind}")],
|
||||
});
|
||||
|
||||
permissions.read_only.push("models:list".to_string());
|
||||
let orgs = me.orgs.as_ref().map(|o| o.data.clone()).unwrap_or_default();
|
||||
|
||||
for org in &orgs {
|
||||
let org_id = org.id.as_deref().unwrap_or("unknown");
|
||||
let org_title = org.title.as_deref().or(org.name.as_deref()).unwrap_or("unknown");
|
||||
let org_role = org.role.as_deref().unwrap_or("unknown");
|
||||
let is_default = org.is_default.unwrap_or(false);
|
||||
let is_personal = org.personal.unwrap_or(false);
|
||||
|
||||
let label =
|
||||
if is_personal { format!("{org_title} (Personal)") } else { org_title.to_string() };
|
||||
|
||||
let risk = match org_role {
|
||||
"owner" => Severity::High,
|
||||
"reader" => Severity::Low,
|
||||
_ => Severity::Medium,
|
||||
};
|
||||
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: "organization".into(),
|
||||
name: format!("{org_id} — {label}"),
|
||||
permissions: vec![format!("role:{org_role}"), format!("default:{is_default}")],
|
||||
risk: severity_to_str(risk).to_string(),
|
||||
reason: format!("Organization membership with {org_role} role"),
|
||||
});
|
||||
|
||||
if org_role == "owner" {
|
||||
permissions.admin.push(format!("org:{org_id}:owner"));
|
||||
}
|
||||
}
|
||||
|
||||
// -- Projects --
|
||||
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
|
||||
|
|
@ -158,28 +423,55 @@ pub async fn map_access_from_token(token: &str) -> Result<AccessMapResult> {
|
|||
});
|
||||
}
|
||||
|
||||
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() {
|
||||
if !projects.is_empty() {
|
||||
permissions.read_only.push("projects:list".to_string());
|
||||
}
|
||||
|
||||
// -- API key scope probing --
|
||||
let (scope_results, is_restricted) = probe_api_scopes(&client, token).await;
|
||||
|
||||
if is_restricted {
|
||||
risk_notes.push("Restricted API key — limited permissions available".into());
|
||||
} else if !scope_results.is_empty() {
|
||||
risk_notes.push("Unrestricted API key — all scopes available".into());
|
||||
}
|
||||
|
||||
let mut scope_labels = Vec::new();
|
||||
let has_model_capabilities = scope_results.iter().any(|s| s.scope == "/v1/model_capabilities");
|
||||
|
||||
for sr in &scope_results {
|
||||
let scope_tag =
|
||||
format!("{}:{}", sr.scope, sr.permission.to_lowercase().replace(" & ", "_"));
|
||||
scope_labels.push(scope_tag.clone());
|
||||
|
||||
for ep in &sr.endpoints {
|
||||
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(),
|
||||
resource_type: "api_scope".into(),
|
||||
name: ep.to_string(),
|
||||
permissions: vec![sr.permission.to_string()],
|
||||
risk: if sr.permission.contains("Write") { "medium".into() } else { "low".into() },
|
||||
reason: format!("Endpoint accessible under scope {}", sr.scope),
|
||||
});
|
||||
model_count += 1;
|
||||
}
|
||||
|
||||
match sr.permission {
|
||||
"Read" => permissions.read_only.push(scope_tag),
|
||||
"Write" => permissions.risky.push(scope_tag),
|
||||
"Read & Write" => {
|
||||
permissions.read_only.push(format!("{}:read", sr.scope));
|
||||
permissions.risky.push(format!("{}:write", sr.scope));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
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()
|
||||
));
|
||||
}
|
||||
|
||||
// -- Identity --
|
||||
let identity_id = me
|
||||
.email
|
||||
.clone()
|
||||
.or_else(|| me.name.clone())
|
||||
.or_else(|| me.id.clone())
|
||||
.unwrap_or_else(|| "openai_api_key".to_string());
|
||||
|
||||
if resources.is_empty() {
|
||||
resources.push(ResourceExposure {
|
||||
|
|
@ -189,9 +481,25 @@ pub async fn map_access_from_token(token: &str) -> Result<AccessMapResult> {
|
|||
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());
|
||||
}
|
||||
|
||||
// -- Risk notes --
|
||||
if has_model_capabilities {
|
||||
risk_notes.push(
|
||||
"Key can make inference requests (chat completions, embeddings, images, audio, moderations)"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if scope_results.iter().any(|s| s.scope == "/v1/fine_tuning" && s.permission.contains("Write"))
|
||||
{
|
||||
risk_notes
|
||||
.push("Key can create fine-tuning jobs (potential training data exfiltration)".into());
|
||||
}
|
||||
if scope_results.iter().any(|s| s.scope == "/v1/files" && s.permission.contains("Write")) {
|
||||
risk_notes.push("Key can upload files".into());
|
||||
}
|
||||
|
||||
// -- Severity --
|
||||
permissions.admin.sort();
|
||||
permissions.admin.dedup();
|
||||
permissions.risky.sort();
|
||||
|
|
@ -199,13 +507,13 @@ pub async fn map_access_from_token(token: &str) -> Result<AccessMapResult> {
|
|||
permissions.read_only.sort();
|
||||
permissions.read_only.dedup();
|
||||
|
||||
let severity = derive_severity(&permissions, projects.len(), models.len());
|
||||
let severity = derive_severity(&permissions, &orgs, has_model_capabilities);
|
||||
|
||||
Ok(AccessMapResult {
|
||||
cloud: "openai".into(),
|
||||
identity: AccessSummary {
|
||||
id: identity_id,
|
||||
access_type: "token".into(),
|
||||
access_type: token_kind.into(),
|
||||
project: None,
|
||||
tenant: None,
|
||||
account_id: me.id.clone(),
|
||||
|
|
@ -229,30 +537,16 @@ pub async fn map_access_from_token(token: &str) -> Result<AccessMapResult> {
|
|||
last_used_at: None,
|
||||
expires_at: None,
|
||||
user_id: me.id,
|
||||
scopes: Vec::new(),
|
||||
scopes: scope_labels,
|
||||
}),
|
||||
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)
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// API helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn fetch_me(client: &Client, token: &str) -> Result<OpenAiMe> {
|
||||
let resp = client
|
||||
|
|
@ -293,6 +587,10 @@ async fn list_projects(client: &Client, token: &str) -> Result<Vec<OpenAiProject
|
|||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Classification helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn detect_token_type(token: &str) -> &'static str {
|
||||
if token.starts_with("sk-proj-") {
|
||||
"project_api_key"
|
||||
|
|
@ -305,14 +603,20 @@ fn detect_token_type(token: &str) -> &'static str {
|
|||
}
|
||||
}
|
||||
|
||||
fn derive_severity(permissions: &PermissionSummary, projects: usize, models: usize) -> Severity {
|
||||
if !permissions.admin.is_empty() {
|
||||
fn derive_severity(
|
||||
permissions: &PermissionSummary,
|
||||
orgs: &[OpenAiOrg],
|
||||
has_model_capabilities: bool,
|
||||
) -> Severity {
|
||||
let is_org_owner = orgs.iter().any(|o| o.role.as_deref() == Some("owner"));
|
||||
|
||||
if !permissions.admin.is_empty() || is_org_owner {
|
||||
return Severity::High;
|
||||
}
|
||||
if !permissions.risky.is_empty() || projects > 0 {
|
||||
if has_model_capabilities || !permissions.risky.is_empty() {
|
||||
return Severity::Medium;
|
||||
}
|
||||
if models > 0 {
|
||||
if !permissions.read_only.is_empty() {
|
||||
return Severity::Low;
|
||||
}
|
||||
Severity::Low
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue