added more access-maps

This commit is contained in:
Mick Grove 2026-02-19 19:36:43 -08:00
commit f38df8a953
4 changed files with 553 additions and 102 deletions

View file

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

View file

@ -46,4 +46,5 @@ rules:
- type: WordMatch
words:
- '"type":"message"'
- 'credit balance is too low'
url: https://api.anthropic.com/v1/messages

View file

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

View file

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