forked from mirrors/kingfisher
performance improvements and rule improvements
This commit is contained in:
parent
a13b175fc5
commit
c50b3ba292
12 changed files with 1069 additions and 14 deletions
|
|
@ -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.
|
||||
|
|
|
|||
12
README.md
12
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
350
src/access_map/asana.rs
Normal 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
476
src/access_map/monday.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
|
|
@ -124,4 +124,9 @@ pub enum AccessMapProvider {
|
|||
/// JFrog Xray
|
||||
#[clap(alias = "jfrog-xray")]
|
||||
Xray,
|
||||
/// monday.com
|
||||
#[clap(alias = "monday.com")]
|
||||
Monday,
|
||||
/// Asana
|
||||
Asana,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")));
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue