From c50b3ba2923e505db2422e499a514db04044ddd7 Mon Sep 17 00:00:00 2001 From: Mick Grove Date: Sun, 19 Apr 2026 16:33:13 -0700 Subject: [PATCH] performance improvements and rule improvements --- CHANGELOG.md | 1 + README.md | 12 +- crates/kingfisher-rules/src/rules_database.rs | 7 +- docs-site/docs/changelog.md | 1 + docs-site/docs/features/access-map.md | 58 ++- docs/ACCESS_MAP.md | 58 ++- src/access_map.rs | 40 ++ src/access_map/asana.rs | 350 +++++++++++++ src/access_map/monday.rs | 476 ++++++++++++++++++ src/cli/commands/access_map.rs | 5 + src/matcher/mod.rs | 16 +- src/scanner/validation.rs | 59 +++ 12 files changed, 1069 insertions(+), 14 deletions(-) create mode 100644 src/access_map/asana.rs create mode 100644 src/access_map/monday.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 20451b3..a60236e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 3c01413..7b5b176 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/crates/kingfisher-rules/src/rules_database.rs b/crates/kingfisher-rules/src/rules_database.rs index e883a66..f8cc453 100644 --- a/crates/kingfisher-rules/src/rules_database.rs +++ b/crates/kingfisher-rules/src/rules_database.rs @@ -214,7 +214,11 @@ impl RulesDatabase { fn build_self_identifying_flags(rules: &[Arc]) -> Vec { 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); } } - diff --git a/docs-site/docs/changelog.md b/docs-site/docs/changelog.md index 034d588..a71f56d 100644 --- a/docs-site/docs/changelog.md +++ b/docs-site/docs/changelog.md @@ -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. diff --git a/docs-site/docs/features/access-map.md b/docs-site/docs/features/access-map.md index eef4fc5..d6b8c6a 100644 --- a/docs-site/docs/features/access-map.md +++ b/docs-site/docs/features/access-map.md @@ -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 `). +- **Token types supported**: personal or account-level API tokens accepted by the monday.com GraphQL API with the `Authorization: ` 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 `). +- **Token types supported**: tokens accepted by Asana's REST API with `Authorization: Bearer `: + - Legacy OAuth / personal access tokens (`0/...`) + - Personal Access Tokens V1 (`1/:`) + - Personal Access Tokens V2 (`2//:`) + +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=&limit=50&opt_fields=gid,name,privacy_setting,archived` for per-workspace project exposure +- `GET /users/me/teams?organization=&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. diff --git a/docs/ACCESS_MAP.md b/docs/ACCESS_MAP.md index a020259..1ccaada 100644 --- a/docs/ACCESS_MAP.md +++ b/docs/ACCESS_MAP.md @@ -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 `). +- **Token types supported**: personal or account-level API tokens accepted by the monday.com GraphQL API with the `Authorization: ` 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 `). +- **Token types supported**: tokens accepted by Asana's REST API with `Authorization: Bearer `: + - Legacy OAuth / personal access tokens (`0/...`) + - Personal Access Tokens V1 (`1/:`) + - Personal Access Tokens V2 (`2//:`) + +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=&limit=50&opt_fields=gid,name,privacy_setting,archived` for per-workspace project exposure +- `GET /users/me/teams?organization=&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. diff --git a/src/access_map.rs b/src/access_map.rs index c52fd6a..9fa4bf1 100644 --- a/src/access_map.rs +++ b/src/access_map.rs @@ -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, fingerprint: String }, /// A JFrog Xray token with optional base URL. Xray { token: String, base_url: Option, 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) -> Vec { + (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 { + 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 { + asana::map_access_from_token(token).await + } +} + // ------------------------------------------------------------------------------------------------- // Helper functions // ------------------------------------------------------------------------------------------------- diff --git a/src/access_map/asana.rs b/src/access_map/asana.rs new file mode 100644 index 0000000..8560fa7 --- /dev/null +++ b/src/access_map/asana.rs @@ -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 { + #[serde(default)] + data: Option, +} + +#[derive(Deserialize, Default)] +struct AsanaUser { + #[serde(default)] + gid: Option, + #[serde(default)] + name: Option, + #[serde(default)] + email: Option, + #[serde(default)] + resource_type: Option, + #[serde(default)] + workspaces: Vec, +} + +#[derive(Deserialize, Default, Clone)] +struct AsanaWorkspace { + #[serde(default)] + gid: Option, + #[serde(default)] + name: Option, + #[serde(default)] + is_organization: Option, +} + +#[derive(Deserialize, Default)] +struct AsanaProject { + #[serde(default)] + gid: Option, + #[serde(default)] + name: Option, + #[serde(default)] + privacy_setting: Option, + #[serde(default)] + archived: Option, +} + +#[derive(Deserialize, Default)] +struct AsanaTeam { + #[serde(default)] + gid: Option, + #[serde(default)] + name: Option, +} + +pub async fn map_access(args: &AccessMapArgs) -> Result { + 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 { + 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 { + 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 = + 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> { + 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> = + 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> { + 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> = + 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", + } +} diff --git a/src/access_map/monday.rs b/src/access_map/monday.rs new file mode 100644 index 0000000..205639e --- /dev/null +++ b/src/access_map/monday.rs @@ -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 { + #[serde(default = "Option::default")] + data: Option, + #[serde(default)] + errors: Option>, +} + +#[derive(Deserialize)] +struct MondayGraphError { + #[serde(default)] + message: Option, +} + +#[derive(Deserialize, Default)] +struct MeEnvelope { + #[serde(default)] + me: Option, +} + +#[derive(Deserialize, Default)] +struct MondayUser { + #[serde(default)] + id: Option, + #[serde(default)] + name: Option, + #[serde(default)] + email: Option, + #[serde(default)] + is_admin: Option, + #[serde(default)] + is_guest: Option, + #[serde(default)] + is_view_only: Option, + #[serde(default)] + created_at: Option, + #[serde(default)] + last_activity: Option, + #[serde(default)] + account: Option, + #[serde(default)] + teams: Vec, +} + +#[derive(Deserialize, Default)] +struct MondayAccount { + #[serde(default)] + id: Option, + #[serde(default)] + name: Option, + #[serde(default)] + slug: Option, + #[serde(default)] + plan: Option, +} + +#[derive(Deserialize, Default)] +struct MondayPlan { + #[serde(default)] + tier: Option, +} + +#[derive(Deserialize, Default)] +struct MondayTeam { + #[serde(default)] + name: Option, +} + +#[derive(Deserialize, Default)] +struct WorkspacesEnvelope { + #[serde(default)] + workspaces: Vec, +} + +#[derive(Deserialize, Default)] +struct MondayWorkspace { + #[serde(default)] + id: Option, + #[serde(default)] + name: Option, + #[serde(default)] + kind: Option, + #[serde(default)] + state: Option, +} + +#[derive(Deserialize, Default)] +struct BoardsEnvelope { + #[serde(default)] + boards: Vec, +} + +#[derive(Deserialize, Default)] +struct MondayBoard { + #[serde(default)] + id: Option, + #[serde(default)] + name: Option, + #[serde(default)] + board_kind: Option, + #[serde(default)] + state: Option, +} + +pub async fn map_access(args: &AccessMapArgs) -> Result { + 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 { + 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 { + 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 = 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::>().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> { + let query = r#" + query { + workspaces(limit: 100) { + id + name + kind + state + } + } + "#; + + let body: MondayGraphResponse = 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::>().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> { + let query = r#" + query { + boards(limit: 50) { + id + name + board_kind + state + } + } + "#; + + let body: MondayGraphResponse = 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::>().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 Deserialize<'de>>( + client: &Client, + token: &str, + query: &str, +) -> Result> { + 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(), + } +} diff --git a/src/cli/commands/access_map.rs b/src/cli/commands/access_map.rs index cd9ab25..3ea7e38 100644 --- a/src/cli/commands/access_map.rs +++ b/src/cli/commands/access_map.rs @@ -124,4 +124,9 @@ pub enum AccessMapProvider { /// JFrog Xray #[clap(alias = "jfrog-xray")] Xray, + /// monday.com + #[clap(alias = "monday.com")] + Monday, + /// Asana + Asana, } diff --git a/src/matcher/mod.rs b/src/matcher/mod.rs index cada686..c70a846 100644 --- a/src/matcher/mod.rs +++ b/src/matcher/mod.rs @@ -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!( - "" - ); + let body = format!(""); let blob = Blob::from_bytes(body.into_bytes()); let origin = OriginSet::from(Origin::from_file(PathBuf::from("page.html"))); diff --git a/src/scanner/validation.rs b/src/scanner/validation.rs index 0e5d135..88ccfc3 100644 --- a/src/scanner/validation.rs +++ b/src/scanner/validation.rs @@ -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 { 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();