performance improvements and rule improvements

This commit is contained in:
Mick Grove 2026-04-19 16:33:13 -07:00
commit c50b3ba292
12 changed files with 1069 additions and 14 deletions

View file

@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file.
- Added revocation support for 7 rules across 6 providers: Discord webhooks (single-step DELETE), DigitalOcean PATs (self-revoke via OAuth), and multi-step HttpMultiStep revocation for LaunchDarkly, Resend, Linode, and Netlify (2 rules). Built-in revocation coverage is now 34 provider families with 53 revocation-enabled rules.
- Expanded Alibaba Cloud coverage with STS temporary credential detection for STS access key IDs, STS security tokens, and STS access key secrets. Built-in rule coverage is now 923 rules total.
- **Access Map:** Alibaba Cloud long-lived and STS access key pairs (validated `kingfisher.alibabacloud.2` and `kingfisher.alibabacloud.5`): caller identity via STS GetCallerIdentity; standalone `kingfisher access-map alibaba` (alias `aliyun`).
- **Access Map:** monday.com API tokens (validated `kingfisher.monday.1`) and Asana personal access / OAuth tokens (validated `kingfisher.asana.3`, `kingfisher.asana.4`, `kingfisher.asana.5`). Monday maps the caller via the `me { account, teams }` GraphQL query and enumerates accessible workspaces and boards; Asana resolves the caller via `/users/me` and enumerates accessible workspaces, organizations, projects, and team memberships. Standalone `kingfisher access-map monday` and `kingfisher access-map asana`.
- **Report viewer:** Import Gitleaks and TruffleHog JSON into the bundled local viewer with deduplication for repeated imported findings, and publish a static upload-based viewer on the docs site for GitHub Pages hosting. See `docs/USAGE.md`.
- Fixed parser-based context gating so assignment-style contextual secrets still scan in raw text when parser verification is unavailable, instead of being dropped.
- Fixed dependent-variable pairing for HTTP validation so rules use the nearest helper match in-file, and updated Pinata detection/validation to reliably catch API key IDs, API secrets, and JWTs, including key+secret validation.

View file

@ -450,17 +450,17 @@ The viewer can import Gitleaks JSON and TruffleHog JSON/JSONL in addition to nat
> **Use the access map functionality only when you are authorized to inspect the target account, as Kingfisher will issue additional network requests to determine what access the secret grants**
### Supported Access Map Providers (39)
### Supported Access Map Providers (42)
| Cloud & Infra | DevOps & CI/CD | SaaS & APIs | Data & Messaging |
|:---|:---|:---|:---|
| AWS | GitHub | Airtable | MongoDB |
| GCP | GitLab | Algolia | MySQL |
| Azure Storage | Azure DevOps | Auth0 | PostgreSQL |
| DigitalOcean | Bitbucket | HubSpot | SendGrid |
| IBM Cloud | Buildkite | Salesforce | Sendinblue / Brevo |
| Terraform Cloud | CircleCI | Shopify | Slack |
| | Harness | Zendesk | Microsoft Teams |
| Alibaba Cloud | Bitbucket | HubSpot | SendGrid |
| DigitalOcean | Buildkite | Salesforce | Sendinblue / Brevo |
| IBM Cloud | CircleCI | Shopify | Slack |
| Terraform Cloud | Harness | Zendesk | Microsoft Teams |
| | JFrog Artifactory | Stripe | |
| | JFrog Xray | Square | |
| | Jira | PayPal | |
@ -471,6 +471,8 @@ The viewer can import Gitleaks JSON and TruffleHog JSON/JSONL in addition to nat
| | | Hugging Face | |
| | | Weights & Biases | |
| | | Gitea | |
| | | monday.com | |
| | | Asana | |
## Direct Secret Validation & Revocation

View file

@ -214,7 +214,11 @@ impl RulesDatabase {
fn build_self_identifying_flags(rules: &[Arc<Rule>]) -> Vec<bool> {
rules
.iter()
.map(|rule| has_self_identifying_shape(&format_regex_pattern(&rule.syntax().pattern).to_lowercase()))
.map(|rule| {
has_self_identifying_shape(
&format_regex_pattern(&rule.syntax().pattern).to_lowercase(),
)
})
.collect()
}
}
@ -307,4 +311,3 @@ mod test_regex_cleaning {
println!("{}", data);
}
}

View file

@ -13,6 +13,7 @@ All notable changes to this project will be documented in this file.
- Added revocation support for 7 rules across 6 providers: Discord webhooks (single-step DELETE), DigitalOcean PATs (self-revoke via OAuth), and multi-step HttpMultiStep revocation for LaunchDarkly, Resend, Linode, and Netlify (2 rules). Built-in revocation coverage is now 34 provider families with 53 revocation-enabled rules.
- Expanded Alibaba Cloud coverage with STS temporary credential detection for STS access key IDs, STS security tokens, and STS access key secrets. Built-in rule coverage is now 923 rules total.
- **Access Map:** Alibaba Cloud long-lived and STS access key pairs (validated `kingfisher.alibabacloud.2` and `kingfisher.alibabacloud.5`): caller identity via STS GetCallerIdentity; standalone `kingfisher access-map alibaba` (alias `aliyun`).
- **Access Map:** monday.com API tokens (validated `kingfisher.monday.1`) and Asana personal access / OAuth tokens (validated `kingfisher.asana.3`, `kingfisher.asana.4`, `kingfisher.asana.5`). Monday maps the caller via the `me { account, teams }` GraphQL query and enumerates accessible workspaces and boards; Asana resolves the caller via `/users/me` and enumerates accessible workspaces, organizations, projects, and team memberships. Standalone `kingfisher access-map monday` and `kingfisher access-map asana`.
- **Report viewer:** Import Gitleaks and TruffleHog JSON into the bundled local viewer with deduplication for repeated imported findings, and publish a static upload-based viewer on the docs site for GitHub Pages hosting. See `docs/USAGE.md`.
- Fixed parser-based context gating so assignment-style contextual secrets still scan in raw text when parser verification is unavailable, instead of being dropped.
- Fixed dependent-variable pairing for HTTP validation so rules use the nearest helper match in-file, and updated Pinata detection/validation to reliably catch API key IDs, API secrets, and JWTs, including key+secret validation.

View file

@ -482,8 +482,64 @@ kingfisher access-map microsoftteams ./teams.webhook --json-out teams.access-map
- Access map severity is Medium for active webhooks (write-only to one channel) and Low for inactive/removed webhooks.
- The probe request does not post any visible message; Teams responds with HTTP 400 "Text is required" for valid endpoints.
### monday.com (`monday`)
- **Credential**: a single monday.com API token (read from a file for `kingfisher access-map monday <FILE>`).
- **Token types supported**: personal or account-level API tokens accepted by the monday.com GraphQL API with the `Authorization: <TOKEN>` header (monday.com's native scheme; the JWT-style token is sent verbatim, without the `Bearer` prefix).
Kingfisher performs read-only enumeration against `https://api.monday.com/v2`:
- `me { id, name, email, is_admin, is_guest, is_view_only, created_at, last_activity, account { id, name, slug, plan { tier } }, teams { name } }` for caller identity, role, and account metadata
- `workspaces(limit: 100) { id, name, kind, state }` for workspace-level resource exposure
- `boards(limit: 50) { id, name, board_kind, state }` for board-level resource exposure
Severity is Critical for account administrators, High for standard members with broad workspace/board visibility (>5 workspaces or >20 boards), Medium for standard members with any workspace/board access, and Low for guest/viewer tokens or empty accounts.
#### Standalone example (monday.com)
```bash
printf '%s' 'eyJhbGciOi...' > ./monday.token
kingfisher access-map monday ./monday.token --json-out monday.access-map.json
```
#### Notes (monday.com)
- Access map currently uses `https://api.monday.com/v2` (GraphQL v2) as the API base.
- monday.com API tokens do not carry granular scopes; permissions follow the underlying user's role (admin/member/viewer/guest).
- `provider_metadata.version` carries the monday.com plan tier when exposed by the account.
- Recorded during `scan --access-map` for validated `kingfisher.monday.1` findings.
### Asana (`asana`)
- **Credential**: a single Asana access token (read from a file for `kingfisher access-map asana <FILE>`).
- **Token types supported**: tokens accepted by Asana's REST API with `Authorization: Bearer <TOKEN>`:
- Legacy OAuth / personal access tokens (`0/...`)
- Personal Access Tokens V1 (`1/<user_gid>:<secret>`)
- Personal Access Tokens V2 (`2/<app_gid>/<user_gid>:<secret>`)
Kingfisher performs read-only enumeration against `https://app.asana.com/api/1.0`:
- `GET /users/me?opt_fields=gid,name,email,resource_type,workspaces.gid,workspaces.name,workspaces.is_organization,workspaces.resource_type` for caller identity and accessible workspaces/organizations
- `GET /projects?workspace=<gid>&limit=50&opt_fields=gid,name,privacy_setting,archived` for per-workspace project exposure
- `GET /users/me/teams?organization=<gid>&opt_fields=gid,name` for team memberships in each organization workspace
Severity is High when the token reaches an organization workspace with more than 20 visible projects, Medium when it reaches an organization workspace or has broad project visibility (>5 projects), and Low for single-workspace or empty tokens.
#### Standalone example (Asana)
```bash
printf '%s' '2/12345.../abcdef...' > ./asana.token
kingfisher access-map asana ./asana.token --json-out asana.access-map.json
```
#### Notes (Asana)
- Asana access tokens do not expose granular scopes. Access follows the underlying user's membership in each workspace, organization, and team.
- `token_details.token_type` is classified from the token prefix (`personal_access_token_v2`, `personal_access_token_v1`, `oauth_or_legacy_pat`, or generic `asana_token`).
- Recorded during `scan --access-map` for validated `kingfisher.asana.3`, `kingfisher.asana.4`, and `kingfisher.asana.5` findings only. `kingfisher.asana.1` is a client ID and `kingfisher.asana.2` is a client secret (requiring the client ID for an OAuth exchange), so neither is used on its own to enumerate user-level resources.
## 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, Buildkite, Harness, OpenAI, Anthropic, Salesforce, Weights & Biases, and Microsoft Teams 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, Salesforce, Weights & Biases, Microsoft Teams, monday.com, and Asana credentials discovered during scans with `--access-map` are automatically collected and mapped, matching the existing behavior for other platforms.

View file

@ -477,8 +477,64 @@ kingfisher access-map microsoftteams ./teams.webhook --json-out teams.access-map
- Access map severity is Medium for active webhooks (write-only to one channel) and Low for inactive/removed webhooks.
- The probe request does not post any visible message; Teams responds with HTTP 400 "Text is required" for valid endpoints.
### monday.com (`monday`)
- **Credential**: a single monday.com API token (read from a file for `kingfisher access-map monday <FILE>`).
- **Token types supported**: personal or account-level API tokens accepted by the monday.com GraphQL API with the `Authorization: <TOKEN>` header (the JWT-style token is sent verbatim, without the `Bearer` prefix — this matches monday.com's native scheme).
Kingfisher performs read-only enumeration against `https://api.monday.com/v2`:
- `me { ..., account { id, name, slug, plan { tier } }, teams { name } }` for caller identity, role, and account metadata
- `workspaces(limit: 100) { id, name, kind, state }` for workspace-level resource exposure
- `boards(limit: 50) { id, name, board_kind, state }` for board-level resource exposure
Severity is Critical for account administrators, High for standard members with broad workspace/board visibility (>5 workspaces or >20 boards), Medium for standard members with any workspace/board access, and Low for guest/viewer tokens or empty accounts.
#### Standalone example (monday.com)
```bash
printf '%s' 'eyJhbGciOi...' > ./monday.token
kingfisher access-map monday ./monday.token --json-out monday.access-map.json
```
#### Notes (monday.com)
- Access map currently uses `https://api.monday.com/v2` (GraphQL v2) as the API base.
- monday.com API tokens do not carry granular scopes; permissions follow the underlying user's role (admin/member/viewer/guest).
- `provider_metadata.version` carries the monday.com plan tier when exposed by the account.
- Recorded during `scan --access-map` for validated `kingfisher.monday.1` findings.
### Asana (`asana`)
- **Credential**: a single Asana access token (read from a file for `kingfisher access-map asana <FILE>`).
- **Token types supported**: tokens accepted by Asana's REST API with `Authorization: Bearer <TOKEN>`:
- Legacy OAuth / personal access tokens (`0/...`)
- Personal Access Tokens V1 (`1/<user_gid>:<secret>`)
- Personal Access Tokens V2 (`2/<app_gid>/<user_gid>:<secret>`)
Kingfisher performs read-only enumeration against `https://app.asana.com/api/1.0`:
- `GET /users/me?opt_fields=gid,name,email,resource_type,workspaces.gid,workspaces.name,workspaces.is_organization,workspaces.resource_type` for caller identity and accessible workspaces/organizations
- `GET /projects?workspace=<gid>&limit=50&opt_fields=gid,name,privacy_setting,archived` for per-workspace project exposure
- `GET /users/me/teams?organization=<gid>&opt_fields=gid,name` for team memberships in each organization workspace
Severity is High when the token reaches an organization workspace with more than 20 visible projects, Medium when it reaches an organization workspace or has broad project visibility (>5 projects), and Low for single-workspace or empty tokens.
#### Standalone example (Asana)
```bash
printf '%s' '2/12345.../abcdef...' > ./asana.token
kingfisher access-map asana ./asana.token --json-out asana.access-map.json
```
#### Notes (Asana)
- Asana access tokens do not expose granular scopes. Access follows the underlying user's membership in each workspace, organization, and team.
- `token_details.token_type` is classified from the token prefix (`personal_access_token_v2`, `personal_access_token_v1`, `oauth_or_legacy_pat`, or generic `asana_token`).
- Recorded during `scan --access-map` for validated `kingfisher.asana.3`, `kingfisher.asana.4`, and `kingfisher.asana.5` findings only. `kingfisher.asana.1` is a client ID and `kingfisher.asana.2` is a client secret (requiring the client ID for an OAuth exchange), so neither is used on its own to enumerate user-level resources.
## 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, Buildkite, Harness, OpenAI, Anthropic, Salesforce, Weights & Biases, and Microsoft Teams 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, Salesforce, Weights & Biases, Microsoft Teams, monday.com, and Asana credentials discovered during scans with `--access-map` are automatically collected and mapped, matching the existing behavior for other platforms.

View file

@ -9,6 +9,7 @@ mod algolia;
mod alibaba;
mod anthropic;
mod artifactory;
mod asana;
mod auth0;
mod aws;
mod azure;
@ -28,6 +29,7 @@ mod huggingface;
mod ibm_cloud;
mod jira;
mod microsoft_teams;
mod monday;
pub(crate) mod mongodb;
pub(crate) mod mysql;
mod openai;
@ -106,6 +108,8 @@ pub async fn run(args: AccessMapArgs) -> Result<()> {
AccessMapProvider::Zendesk => zendesk::map_access(&args).await?,
AccessMapProvider::Artifactory => artifactory::map_access(&args).await?,
AccessMapProvider::Xray => xray::map_access(&args).await?,
AccessMapProvider::Monday => monday::map_access(&args).await?,
AccessMapProvider::Asana => asana::map_access(&args).await?,
};
let json = serde_json::to_string_pretty(&result)?;
@ -217,6 +221,10 @@ pub enum AccessMapRequest {
Artifactory { token: String, base_url: Option<String>, fingerprint: String },
/// A JFrog Xray token with optional base URL.
Xray { token: String, base_url: Option<String>, fingerprint: String },
/// A monday.com API token.
Monday { token: String, fingerprint: String },
/// An Asana personal access token / OAuth token.
Asana { token: String, fingerprint: String },
}
/// Structured output describing the resolved identity and its risk profile.
@ -549,6 +557,12 @@ pub async fn map_requests(requests: Vec<AccessMapRequest>) -> Vec<AccessMapResul
fingerprint,
)
}
AccessMapRequest::Monday { token, fingerprint } => {
(map_token(&MondayMapper, &token).await, fingerprint)
}
AccessMapRequest::Asana { token, fingerprint } => {
(map_token(&AsanaMapper, &token).await, fingerprint)
}
};
mapped.fingerprint = Some(fp);
@ -862,6 +876,32 @@ impl TokenAccessMapper for SquareMapper {
}
}
/// monday.com access mapper.
pub struct MondayMapper;
impl TokenAccessMapper for MondayMapper {
fn cloud_name(&self) -> &'static str {
"monday"
}
async fn map_access_from_token(&self, token: &str) -> Result<AccessMapResult> {
monday::map_access_from_token(token).await
}
}
/// Asana access mapper.
pub struct AsanaMapper;
impl TokenAccessMapper for AsanaMapper {
fn cloud_name(&self) -> &'static str {
"asana"
}
async fn map_access_from_token(&self, token: &str) -> Result<AccessMapResult> {
asana::map_access_from_token(token).await
}
}
// -------------------------------------------------------------------------------------------------
// Helper functions
// -------------------------------------------------------------------------------------------------

350
src/access_map/asana.rs Normal file
View file

@ -0,0 +1,350 @@
use anyhow::{Context, Result, anyhow};
use reqwest::{Client, header};
use serde::Deserialize;
use tracing::warn;
use crate::{cli::commands::access_map::AccessMapArgs, validation::GLOBAL_USER_AGENT};
use super::{
AccessMapResult, AccessSummary, AccessTokenDetails, PermissionSummary, ResourceExposure,
RoleBinding, Severity, build_recommendations,
};
const ASANA_API: &str = "https://app.asana.com/api/1.0";
#[derive(Deserialize)]
struct AsanaEnvelope<T> {
#[serde(default)]
data: Option<T>,
}
#[derive(Deserialize, Default)]
struct AsanaUser {
#[serde(default)]
gid: Option<String>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
email: Option<String>,
#[serde(default)]
resource_type: Option<String>,
#[serde(default)]
workspaces: Vec<AsanaWorkspace>,
}
#[derive(Deserialize, Default, Clone)]
struct AsanaWorkspace {
#[serde(default)]
gid: Option<String>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
is_organization: Option<bool>,
}
#[derive(Deserialize, Default)]
struct AsanaProject {
#[serde(default)]
gid: Option<String>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
privacy_setting: Option<String>,
#[serde(default)]
archived: Option<bool>,
}
#[derive(Deserialize, Default)]
struct AsanaTeam {
#[serde(default)]
gid: 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 Asana token from {}", path.display()))?;
raw.trim().to_string()
} else {
return Err(anyhow!("Asana 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 Asana HTTP client")?;
let user = fetch_me(&client, token).await?;
let username = user
.name
.clone()
.or_else(|| user.email.clone())
.unwrap_or_else(|| "asana_user".to_string());
let workspaces = user.workspaces.clone();
let primary_workspace = workspaces.first().cloned();
let identity = AccessSummary {
id: username.clone(),
access_type: user.resource_type.clone().unwrap_or_else(|| "user".to_string()),
project: primary_workspace.as_ref().and_then(|ws| ws.name.clone()),
tenant: primary_workspace.as_ref().and_then(|ws| ws.gid.clone()),
account_id: user.gid.clone(),
};
let mut roles = Vec::new();
let mut permissions = PermissionSummary::default();
let mut resources = Vec::new();
let mut risk_notes = Vec::new();
roles.push(RoleBinding {
name: "workspace_member".into(),
source: "asana".into(),
permissions: vec!["workspace:member".into()],
});
permissions.risky.push("workspace:member".into());
for workspace in &workspaces {
let ws_gid = workspace.gid.clone().unwrap_or_else(|| "unknown".into());
let ws_name = workspace.name.clone().unwrap_or_else(|| ws_gid.clone());
let is_org = workspace.is_organization.unwrap_or(false);
let resource_label = if is_org { "organization" } else { "workspace" };
resources.push(ResourceExposure {
resource_type: resource_label.into(),
name: ws_name.clone(),
permissions: vec![format!("{resource_label}:member")],
risk: severity_to_str(if is_org { Severity::Medium } else { Severity::Low })
.to_string(),
reason: if is_org {
format!("Asana organization {ws_name} accessible with this token")
} else {
format!("Asana workspace {ws_name} accessible with this token")
},
});
let projects = list_projects(&client, token, &ws_gid).await.unwrap_or_else(|err| {
warn!("Asana access-map: project enumeration failed for workspace {ws_gid}: {err}");
Vec::new()
});
for project in &projects {
let project_name = project
.name
.clone()
.or_else(|| project.gid.clone())
.unwrap_or_else(|| "unknown_project".to_string());
let privacy = project.privacy_setting.as_deref().unwrap_or("unknown");
let archived = project.archived.unwrap_or(false);
let risk = match privacy {
"public_to_workspace" => Severity::Medium,
"private_to_team" => Severity::Low,
_ => Severity::Low,
};
let mut perm_labels = vec![format!("project:{privacy}")];
if archived {
perm_labels.push("archived".into());
}
resources.push(ResourceExposure {
resource_type: "project".into(),
name: format!("{ws_name}/{project_name}"),
permissions: perm_labels,
risk: severity_to_str(risk).to_string(),
reason: format!(
"Asana project in workspace {ws_name} accessible with this token ({privacy})"
),
});
}
if is_org {
let teams = list_teams(&client, token, &ws_gid).await.unwrap_or_else(|err| {
warn!("Asana access-map: team enumeration failed for workspace {ws_gid}: {err}");
Vec::new()
});
for team in &teams {
let team_name = team
.name
.clone()
.or_else(|| team.gid.clone())
.unwrap_or_else(|| "unknown_team".to_string());
roles.push(RoleBinding {
name: format!("team:{team_name}"),
source: "asana".into(),
permissions: Vec::new(),
});
}
}
}
permissions.admin.sort();
permissions.admin.dedup();
permissions.risky.sort();
permissions.risky.dedup();
permissions.read_only.sort();
permissions.read_only.dedup();
let severity = derive_severity(&workspaces, &resources);
if workspaces.is_empty() {
resources.push(ResourceExposure {
resource_type: "account".into(),
name: username.clone(),
permissions: Vec::new(),
risk: severity_to_str(Severity::Low).to_string(),
reason: "Asana account associated with the token".into(),
});
risk_notes.push("Token did not enumerate any workspaces".into());
}
let token_type = classify_token(token);
Ok(AccessMapResult {
cloud: "asana".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: user.resource_type.clone(),
company: primary_workspace.as_ref().and_then(|ws| ws.name.clone()),
location: None,
email: user.email.clone(),
url: Some("https://app.asana.com".into()),
token_type: Some(token_type.to_string()),
created_at: None,
last_used_at: None,
expires_at: None,
user_id: user.gid.clone(),
scopes: Vec::new(),
}),
provider_metadata: None,
fingerprint: None,
})
}
async fn fetch_me(client: &Client, token: &str) -> Result<AsanaUser> {
let url = format!(
"{ASANA_API}/users/me?opt_fields=gid,name,email,resource_type,workspaces.gid,workspaces.name,workspaces.is_organization,workspaces.resource_type"
);
let resp = client
.get(url)
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::ACCEPT, "application/json")
.send()
.await
.context("Asana access-map: failed to fetch /users/me")?;
if !resp.status().is_success() {
return Err(anyhow!(
"Asana access-map: /users/me lookup failed with HTTP {}",
resp.status()
));
}
let envelope: AsanaEnvelope<AsanaUser> =
resp.json().await.context("Asana access-map: invalid /users/me JSON")?;
envelope.data.ok_or_else(|| anyhow!("Asana access-map: /users/me returned no data"))
}
async fn list_projects(
client: &Client,
token: &str,
workspace_gid: &str,
) -> Result<Vec<AsanaProject>> {
let url = format!(
"{ASANA_API}/projects?workspace={workspace_gid}&limit=50&opt_fields=gid,name,privacy_setting,archived"
);
let resp = client
.get(url)
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::ACCEPT, "application/json")
.send()
.await
.context("Asana access-map: failed to list projects")?;
if !resp.status().is_success() {
warn!("Asana access-map: project enumeration failed with HTTP {}", resp.status());
return Ok(Vec::new());
}
let envelope: AsanaEnvelope<Vec<AsanaProject>> =
resp.json().await.context("Asana access-map: invalid projects JSON")?;
Ok(envelope.data.unwrap_or_default())
}
async fn list_teams(client: &Client, token: &str, workspace_gid: &str) -> Result<Vec<AsanaTeam>> {
let url =
format!("{ASANA_API}/users/me/teams?organization={workspace_gid}&opt_fields=gid,name");
let resp = client
.get(url)
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::ACCEPT, "application/json")
.send()
.await
.context("Asana access-map: failed to list teams")?;
if !resp.status().is_success() {
warn!("Asana access-map: team enumeration failed with HTTP {}", resp.status());
return Ok(Vec::new());
}
let envelope: AsanaEnvelope<Vec<AsanaTeam>> =
resp.json().await.context("Asana access-map: invalid teams JSON")?;
Ok(envelope.data.unwrap_or_default())
}
fn classify_token(token: &str) -> &'static str {
if token.starts_with("2/") {
"personal_access_token_v2"
} else if token.starts_with("1/") {
"personal_access_token_v1"
} else if token.starts_with("0/") {
"oauth_or_legacy_pat"
} else {
"asana_token"
}
}
fn derive_severity(workspaces: &[AsanaWorkspace], resources: &[ResourceExposure]) -> Severity {
let has_org = workspaces.iter().any(|ws| ws.is_organization.unwrap_or(false));
let project_count = resources.iter().filter(|r| r.resource_type == "project").count();
if has_org && project_count > 20 {
return Severity::High;
}
if has_org || project_count > 5 {
return Severity::Medium;
}
if !workspaces.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",
}
}

476
src/access_map/monday.rs Normal file
View file

@ -0,0 +1,476 @@
use anyhow::{Context, Result, anyhow};
use reqwest::{Client, header};
use serde::Deserialize;
use serde_json::json;
use tracing::warn;
use crate::{cli::commands::access_map::AccessMapArgs, validation::GLOBAL_USER_AGENT};
use super::{
AccessMapResult, AccessSummary, AccessTokenDetails, PermissionSummary, ProviderMetadata,
ResourceExposure, RoleBinding, Severity, build_recommendations,
};
const MONDAY_API: &str = "https://api.monday.com/v2";
#[derive(Deserialize)]
struct MondayGraphResponse<T> {
#[serde(default = "Option::default")]
data: Option<T>,
#[serde(default)]
errors: Option<Vec<MondayGraphError>>,
}
#[derive(Deserialize)]
struct MondayGraphError {
#[serde(default)]
message: Option<String>,
}
#[derive(Deserialize, Default)]
struct MeEnvelope {
#[serde(default)]
me: Option<MondayUser>,
}
#[derive(Deserialize, Default)]
struct MondayUser {
#[serde(default)]
id: Option<serde_json::Value>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
email: Option<String>,
#[serde(default)]
is_admin: Option<bool>,
#[serde(default)]
is_guest: Option<bool>,
#[serde(default)]
is_view_only: Option<bool>,
#[serde(default)]
created_at: Option<String>,
#[serde(default)]
last_activity: Option<String>,
#[serde(default)]
account: Option<MondayAccount>,
#[serde(default)]
teams: Vec<MondayTeam>,
}
#[derive(Deserialize, Default)]
struct MondayAccount {
#[serde(default)]
id: Option<serde_json::Value>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
slug: Option<String>,
#[serde(default)]
plan: Option<MondayPlan>,
}
#[derive(Deserialize, Default)]
struct MondayPlan {
#[serde(default)]
tier: Option<String>,
}
#[derive(Deserialize, Default)]
struct MondayTeam {
#[serde(default)]
name: Option<String>,
}
#[derive(Deserialize, Default)]
struct WorkspacesEnvelope {
#[serde(default)]
workspaces: Vec<MondayWorkspace>,
}
#[derive(Deserialize, Default)]
struct MondayWorkspace {
#[serde(default)]
id: Option<serde_json::Value>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
kind: Option<String>,
#[serde(default)]
state: Option<String>,
}
#[derive(Deserialize, Default)]
struct BoardsEnvelope {
#[serde(default)]
boards: Vec<MondayBoard>,
}
#[derive(Deserialize, Default)]
struct MondayBoard {
#[serde(default)]
id: Option<serde_json::Value>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
board_kind: Option<String>,
#[serde(default)]
state: 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 monday.com token from {}", path.display()))?;
raw.trim().to_string()
} else {
return Err(anyhow!("monday.com 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 monday.com HTTP client")?;
let me = fetch_me(&client, token).await?;
let username =
me.name.clone().or_else(|| me.email.clone()).unwrap_or_else(|| "monday_user".to_string());
let user_id = me.id.as_ref().map(value_to_string);
let account_slug = me.account.as_ref().and_then(|a| a.slug.clone());
let account_id = me.account.as_ref().and_then(|a| a.id.as_ref().map(value_to_string));
let account_name = me.account.as_ref().and_then(|a| a.name.clone());
let plan_tier = me.account.as_ref().and_then(|a| a.plan.as_ref().and_then(|p| p.tier.clone()));
let access_type = if me.is_guest.unwrap_or(false) {
"guest"
} else if me.is_view_only.unwrap_or(false) {
"viewer"
} else if me.is_admin.unwrap_or(false) {
"admin"
} else {
"user"
};
let identity = AccessSummary {
id: username.clone(),
access_type: access_type.to_string(),
project: account_name.clone().or_else(|| account_slug.clone()),
tenant: account_slug.clone(),
account_id: account_id.clone(),
};
let mut roles = Vec::new();
let mut permissions = PermissionSummary::default();
let mut resources = Vec::new();
let mut risk_notes = Vec::new();
if me.is_admin.unwrap_or(false) {
let role = RoleBinding {
name: "account_admin".into(),
source: "monday".into(),
permissions: vec!["account:admin".into()],
};
roles.push(role);
permissions.admin.push("account:admin".into());
risk_notes.push("Token is attached to a monday.com account administrator".into());
} else if me.is_guest.unwrap_or(false) {
roles.push(RoleBinding {
name: "guest".into(),
source: "monday".into(),
permissions: vec!["account:guest".into()],
});
permissions.read_only.push("account:guest".into());
} else if me.is_view_only.unwrap_or(false) {
roles.push(RoleBinding {
name: "viewer".into(),
source: "monday".into(),
permissions: vec!["account:viewer".into()],
});
permissions.read_only.push("account:viewer".into());
} else {
roles.push(RoleBinding {
name: "member".into(),
source: "monday".into(),
permissions: vec!["account:member".into()],
});
permissions.risky.push("account:member".into());
}
for team in &me.teams {
let team_name = team.name.clone().unwrap_or_else(|| "unknown_team".into());
roles.push(RoleBinding {
name: format!("team:{team_name}"),
source: "monday".into(),
permissions: Vec::new(),
});
}
let workspaces = list_workspaces(&client, token).await.unwrap_or_else(|err| {
warn!("monday.com access-map: workspace enumeration failed: {err}");
Vec::new()
});
for workspace in &workspaces {
let ws_name = workspace
.name
.clone()
.or_else(|| workspace.id.as_ref().map(value_to_string))
.unwrap_or_else(|| "unknown_workspace".to_string());
let kind = workspace.kind.as_deref().unwrap_or("unknown");
let state = workspace.state.as_deref().unwrap_or("active");
let risk = match kind {
"open" => Severity::Medium,
"closed" => Severity::Low,
_ => Severity::Low,
};
resources.push(ResourceExposure {
resource_type: "workspace".into(),
name: ws_name,
permissions: vec![format!("workspace:{kind}"), format!("state:{state}")],
risk: severity_to_str(risk).to_string(),
reason: format!("monday.com {kind} workspace accessible with this token"),
});
}
let boards = list_boards(&client, token).await.unwrap_or_else(|err| {
warn!("monday.com access-map: board enumeration failed: {err}");
Vec::new()
});
for board in &boards {
let board_name = board
.name
.clone()
.or_else(|| board.id.as_ref().map(value_to_string))
.unwrap_or_else(|| "unknown_board".to_string());
let kind = board.board_kind.as_deref().unwrap_or("unknown");
let state = board.state.as_deref().unwrap_or("active");
let risk = match kind {
"private" | "share" => Severity::Medium,
_ => Severity::Low,
};
resources.push(ResourceExposure {
resource_type: "board".into(),
name: board_name,
permissions: vec![format!("board:{kind}"), format!("state:{state}")],
risk: severity_to_str(risk).to_string(),
reason: format!("monday.com {kind} board accessible with this token"),
});
}
permissions.admin.sort();
permissions.admin.dedup();
permissions.risky.sort();
permissions.risky.dedup();
permissions.read_only.sort();
permissions.read_only.dedup();
let severity = derive_severity(&me, &workspaces, &boards);
if workspaces.is_empty() && boards.is_empty() {
if !me.is_admin.unwrap_or(false) {
resources.push(ResourceExposure {
resource_type: "account".into(),
name: account_name.clone().unwrap_or_else(|| username.clone()),
permissions: Vec::new(),
risk: severity_to_str(Severity::Low).to_string(),
reason: "monday.com account associated with the token".into(),
});
}
risk_notes.push(
"Token did not enumerate any workspaces or boards (limited scope or empty account)"
.into(),
);
}
let token_type = if me.is_admin.unwrap_or(false) {
Some("admin_api_token".into())
} else if me.is_guest.unwrap_or(false) {
Some("guest_api_token".into())
} else {
Some("api_token".into())
};
Ok(AccessMapResult {
cloud: "monday".into(),
identity,
roles,
permissions,
resources,
severity,
recommendations: build_recommendations(severity),
risk_notes,
token_details: Some(AccessTokenDetails {
name: me.name.clone(),
username: me.email.clone(),
account_type: Some(access_type.to_string()),
company: account_name,
location: None,
email: me.email.clone(),
url: account_slug.map(|slug| format!("https://{slug}.monday.com")),
token_type,
created_at: me.created_at.clone(),
last_used_at: me.last_activity.clone(),
expires_at: None,
user_id,
scopes: Vec::new(),
}),
provider_metadata: Some(ProviderMetadata { version: plan_tier, enterprise: None }),
fingerprint: None,
})
}
async fn fetch_me(client: &Client, token: &str) -> Result<MondayUser> {
let query = r#"
query {
me {
id
name
email
is_admin
is_guest
is_view_only
enabled
created_at
last_activity
account { id name slug plan { tier } }
teams { id name }
}
}
"#;
let body: MondayGraphResponse<MeEnvelope> = send_query(client, token, query).await?;
if let Some(errors) = body.errors.as_ref().filter(|e| !e.is_empty()) {
let message =
errors.iter().filter_map(|e| e.message.clone()).collect::<Vec<_>>().join("; ");
return Err(anyhow!("monday.com access-map: me query failed: {message}"));
}
body.data
.and_then(|d| d.me)
.ok_or_else(|| anyhow!("monday.com access-map: me query returned no data"))
}
async fn list_workspaces(client: &Client, token: &str) -> Result<Vec<MondayWorkspace>> {
let query = r#"
query {
workspaces(limit: 100) {
id
name
kind
state
}
}
"#;
let body: MondayGraphResponse<WorkspacesEnvelope> = send_query(client, token, query).await?;
if let Some(errors) = body.errors.as_ref().filter(|e| !e.is_empty()) {
let message =
errors.iter().filter_map(|e| e.message.clone()).collect::<Vec<_>>().join("; ");
warn!("monday.com access-map: workspaces query reported errors: {message}");
return Ok(Vec::new());
}
Ok(body.data.map(|d| d.workspaces).unwrap_or_default())
}
async fn list_boards(client: &Client, token: &str) -> Result<Vec<MondayBoard>> {
let query = r#"
query {
boards(limit: 50) {
id
name
board_kind
state
}
}
"#;
let body: MondayGraphResponse<BoardsEnvelope> = send_query(client, token, query).await?;
if let Some(errors) = body.errors.as_ref().filter(|e| !e.is_empty()) {
let message =
errors.iter().filter_map(|e| e.message.clone()).collect::<Vec<_>>().join("; ");
warn!("monday.com access-map: boards query reported errors: {message}");
return Ok(Vec::new());
}
Ok(body.data.map(|d| d.boards).unwrap_or_default())
}
async fn send_query<T: for<'de> Deserialize<'de>>(
client: &Client,
token: &str,
query: &str,
) -> Result<MondayGraphResponse<T>> {
let resp = client
.post(MONDAY_API)
.header(header::AUTHORIZATION, token)
.header(header::CONTENT_TYPE, "application/json")
.header(header::ACCEPT, "application/json")
.json(&json!({ "query": query }))
.send()
.await
.context("monday.com access-map: request failed")?;
if !resp.status().is_success() {
return Err(anyhow!(
"monday.com access-map: GraphQL request failed with HTTP {}",
resp.status()
));
}
resp.json().await.context("monday.com access-map: invalid GraphQL response JSON")
}
fn derive_severity(
user: &MondayUser,
workspaces: &[MondayWorkspace],
boards: &[MondayBoard],
) -> Severity {
if user.is_admin.unwrap_or(false) {
return Severity::Critical;
}
let workspace_count = workspaces.len();
let board_count = boards.len();
if user.is_guest.unwrap_or(false) || user.is_view_only.unwrap_or(false) {
return Severity::Low;
}
if workspace_count > 5 || board_count > 20 {
return Severity::High;
}
if workspace_count > 0 || board_count > 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",
}
}
fn value_to_string(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Number(n) => n.to_string(),
other => other.to_string(),
}
}

View file

@ -124,4 +124,9 @@ pub enum AccessMapProvider {
/// JFrog Xray
#[clap(alias = "jfrog-xray")]
Xray,
/// monday.com
#[clap(alias = "monday.com")]
Monday,
/// Asana
Asana,
}

View file

@ -1166,7 +1166,11 @@ line2
ScanResult::New(matches) => matches,
_ => panic!("unexpected scan result"),
};
assert_eq!(found.len(), 1, "raw regex matches should remain findings without classifier gating");
assert_eq!(
found.len(),
1,
"raw regex matches should remain findings without classifier gating"
);
Ok(())
}
@ -1204,7 +1208,11 @@ line2
ScanResult::New(matches) => matches,
_ => panic!("unexpected scan result"),
};
assert_eq!(found.len(), 1, "strict contextual rules should still be reported without classifier gating");
assert_eq!(
found.len(),
1,
"strict contextual rules should still be reported without classifier gating"
);
Ok(())
}
@ -1405,9 +1413,7 @@ line2
let mut matcher =
Matcher::new(&rules_db, scanner_pool, &seen, None, false, None, &[], false, true)?;
let body = format!(
"<html><body><!-- auth0 secret {token} --></body></html>"
);
let body = format!("<html><body><!-- auth0 secret {token} --></body></html>");
let blob = Blob::from_bytes(body.into_bytes());
let origin = OriginSet::from(Origin::from_file(PathBuf::from("page.html")));

View file

@ -392,6 +392,20 @@ impl AccessMapCollector {
});
}
pub fn record_monday(&self, token: &str, fingerprint: String) {
let key = xxhash_rust::xxh3::xxh3_64(format!("monday|{token}").as_bytes());
self.inner
.entry(key)
.or_insert_with(|| AccessMapRequest::Monday { token: token.to_string(), fingerprint });
}
pub fn record_asana(&self, token: &str, fingerprint: String) {
let key = xxhash_rust::xxh3::xxh3_64(format!("asana|{token}").as_bytes());
self.inner
.entry(key)
.or_insert_with(|| AccessMapRequest::Asana { token: token.to_string(), fingerprint });
}
pub fn into_requests(self) -> Vec<AccessMapRequest> {
self.inner.iter().map(|entry| entry.value().clone()).collect()
}
@ -1431,6 +1445,26 @@ fn maybe_record_access_map(om: &OwnedBlobMatch, collector: Option<&AccessMapColl
collector.record_zendesk(&token, &subdomain, fp.clone());
}
}
if om.rule.id().starts_with("kingfisher.monday.") {
if let Some((_, value, ..)) = captures.iter().find(|(name, ..)| name == "TOKEN") {
if !value.is_empty() {
collector.record_monday(value, fp.clone());
}
}
}
// Only Asana rules whose TOKEN capture is a standalone access/PAT:
// .3 (legacy 0/...), .4 (V1 1/...), .5 (V2 2/...). Rule .1 is a client ID
// and .2 is a client secret that cannot be used alone to enumerate resources.
if matches!(
om.rule.id(),
"kingfisher.asana.3" | "kingfisher.asana.4" | "kingfisher.asana.5"
) {
if let Some((_, value, ..)) = captures.iter().find(|(name, ..)| name == "TOKEN") {
if !value.is_empty() {
collector.record_asana(value, fp.clone());
}
}
}
}
}
}
@ -1496,6 +1530,31 @@ mod tests {
assert!(is_counted_validation_status(StatusCode::UNAUTHORIZED));
}
#[test]
fn access_map_collector_dedupes_monday_and_asana_tokens() {
let collector = AccessMapCollector::default();
collector.record_monday("monday-token-1", "fp-1".into());
collector.record_monday("monday-token-1", "fp-2".into());
collector.record_asana("2/asana-token-1", "fp-3".into());
collector.record_asana("2/asana-token-1", "fp-4".into());
let mut requests = collector.into_requests();
requests.sort_by_key(|r| match r {
AccessMapRequest::Monday { .. } => 0,
AccessMapRequest::Asana { .. } => 1,
_ => 2,
});
assert_eq!(requests.len(), 2);
match &requests[0] {
AccessMapRequest::Monday { token, .. } => assert_eq!(token, "monday-token-1"),
other => panic!("unexpected request: {other:?}"),
}
match &requests[1] {
AccessMapRequest::Asana { token, .. } => assert_eq!(token, "2/asana-token-1"),
other => panic!("unexpected request: {other:?}"),
}
}
#[test]
fn access_map_collector_dedupes_alibaba_credentials() {
let collector = AccessMapCollector::default();