forked from mirrors/kingfisher
added more access-maps
This commit is contained in:
parent
59afdc6043
commit
19fe52a9bf
36 changed files with 8307 additions and 21 deletions
|
|
@ -131,11 +131,16 @@ Use this when creating or updating rules in `crates/kingfisher-rules/data/rules/
|
|||
- After changes, run the narrowest relevant tests first, then broader checks when practical.
|
||||
- If validation commands cannot be run, report exactly what was skipped and why.
|
||||
- Prefer `kingfisher scan --format toon` when invoking Kingfisher from an LLM or agent workflow; keep `pretty` for interactive human CLI use unless the task explicitly calls for a different format.
|
||||
- After markdown/doc changes, verify local documentation links when practical.
|
||||
|
||||
## Documentation Pointers
|
||||
- `docs/USAGE.md`
|
||||
- `docs/ADVANCED.md`
|
||||
- `docs/ARCHITECTURE.md`
|
||||
- `docs/ACCESS_MAP.md`
|
||||
- `docs/DEPLOYMENT.md`
|
||||
- `docs/RULES.md`
|
||||
- `docs/INSTALLATION.md`
|
||||
- `docs/INTEGRATIONS.md`
|
||||
- `docs/LIBRARY.md`
|
||||
- `docs/PYPI.md`
|
||||
|
|
|
|||
|
|
@ -2,6 +2,11 @@
|
|||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.93.0]
|
||||
- **Access Map: added 21 new blast radius providers**, bringing the total to 39. New providers: Airtable, Algolia, Artifactory, Auth0, CircleCI, DigitalOcean, Fastly, HubSpot, IBM Cloud, Jira, MySQL, PayPal, Plaid, SendGrid, Sendinblue/Brevo, Shopify, Square, Stripe, Terraform Cloud, JFrog Xray, and Zendesk. Each provider maps leaked credentials to their effective identity, permissions, and exposed resources.
|
||||
- Added Mermaid architecture documentation in `docs/ARCHITECTURE.md`, covering the main Kingfisher components, command paths, and scan flow at a high level.
|
||||
- Expanded `docs/LIBRARY.md` with Mermaid diagrams showing the relationships and internal structure of `kingfisher-core`, `kingfisher-rules`, and `kingfisher-scanner`.
|
||||
|
||||
## [v1.92.0]
|
||||
- Added new built-in rules for Etsy, Flutterwave, Freemius, JFrog, Kraken, KuCoin, Trello, Octopus Deploy, OpenShift, Private AI, SettleMint, Sidekiq, and Polymarket.
|
||||
- Added live HTTP validation for Etsy, JFrog, Octopus Deploy, OpenShift, and Private AI where provider documentation supported reliable token-only checks.
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ http = "1.4"
|
|||
|
||||
[package]
|
||||
name = "kingfisher"
|
||||
version = "1.92.0"
|
||||
version = "1.93.0"
|
||||
description = "MongoDB's blazingly fast and accurate secret scanning and validation tool"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
|
|
|||
56
README.md
56
README.md
|
|
@ -47,7 +47,7 @@ Kingfisher is a high-performance, open source secret detection tool for source c
|
|||
- **Extensible rules**: hundreds of built-in detectors plus YAML-defined custom rules ([docs/RULES.md](/docs/RULES.md))
|
||||
- **Validate & Revoke**: live validation of discovered secrets, plus direct revocation for supported platforms (GitHub, GitLab, Slack, AWS, GCP, and more) ([docs/USAGE.md](/docs/USAGE.md))
|
||||
- **Revocation support matrix**: current built-in revocation coverage across providers and rule IDs ([docs/REVOCATION_PROVIDERS.md](/docs/REVOCATION_PROVIDERS.md))
|
||||
- **Blast Radius Mapping**: instantly map leaked keys to their effective cloud identities and exposed resources with `--access-map`. Supports AWS, GCP, Azure, GitHub, GitLab, Slack, Microsoft Teams, and more.
|
||||
- **Blast Radius Mapping**: instantly map leaked keys to their effective cloud identities and exposed resources with `--access-map`. Supports 39 providers (see table below).
|
||||
- **Broad AI SaaS coverage**: finds and validates tokens for OpenAI, Anthropic, Google Gemini, Cohere, AWS Bedrock, Voyage AI, Mistral, Stability AI, Replicate, xAI (Grok), Ollama, Langchain, Perplexity, Weights & Biases, Cerebras, Friendli, Fireworks.ai, NVIDIA NIM, Together.ai, Zhipu, and many more
|
||||
- **Compressed Files**: Supports extracting and scanning compressed files for secrets
|
||||
- **SQLite Database Scanning**: Automatically extracts and scans SQLite database contents for secrets stored in table rows
|
||||
|
|
@ -74,7 +74,7 @@ NOTE: Replay has been slowed down for demo
|
|||

|
||||
|
||||
## Report Viewer Demo
|
||||
Explore Kingfisher's built-in report viewer and its `--access-map`, which can show what the token (AWS, GCP, Azure, GitHub, GitLab, Slack, Microsoft Teams, and more) can actually access.
|
||||
Explore Kingfisher's built-in report viewer and its `--access-map`, which maps the blast radius of discovered credentials across 39 supported providers.
|
||||
|
||||
Note: when you pass `--view-report`, Kingfisher starts a web server on port `7890` (default) and opens it in your default browser. By default it binds to `127.0.0.1` for security. You'll see this near the end of the scan output, and **Kingfisher will keep running** until you stop it.
|
||||
|
||||
|
|
@ -338,18 +338,20 @@ gh attestation verify kingfisher-linux-x64.tgz --repo mongodb/kingfisher
|
|||
|
||||
# Detection Rules
|
||||
|
||||
Kingfisher ships with [hundreds of rules](crates/kingfisher-rules/data/rules/) that cover everything from classic cloud keys to the latest AI SaaS tokens. Below is an overview:
|
||||
Kingfisher ships with [600+ built-in rules](crates/kingfisher-rules/data/rules/) covering cloud keys, AI tokens, CI/CD secrets, database credentials, and SaaS API keys. Below is an overview — see the full list in [crates/kingfisher-rules/data/rules/](crates/kingfisher-rules/data/rules/):
|
||||
|
||||
| Category | What we catch |
|
||||
|----------|---------------|
|
||||
| **AI SaaS APIs** | OpenAI, Anthropic, Google Gemini, Cohere, Mistral, Stability AI, Replicate, xAI (Grok), Ollama, Langchain, Perplexity, Weights & Biases, Cerebras, Friendli, Fireworks.ai, NVIDIA NIM, together.ai, Zhipu, and more |
|
||||
| **Cloud Providers** | AWS, Azure, GCP, Alibaba Cloud, DigitalOcean, IBM Cloud, Cloudflare, Temporal Cloud, and more |
|
||||
| **Dev & CI/CD** | GitHub/GitLab tokens, CircleCI, TravisCI, TeamCity, Docker Hub, npm, PyPI, Vercel, and more |
|
||||
| **Messaging & Comms** | Slack, Discord, Microsoft Teams, Twilio, Mailgun, SendGrid, Mailchimp, and more |
|
||||
| **Databases & Data Ops** | MongoDB Atlas, PlanetScale, Postgres DSNs, Grafana Cloud, Datadog, Dynatrace, and more |
|
||||
| **Payments & Billing** | Stripe, PayPal, Square, GoCardless, and more |
|
||||
| **Security & DevSecOps** | Snyk, Dependency-Track, CodeClimate, Codacy, OpsGenie, PagerDuty, and more |
|
||||
| **Misc. SaaS & Tools** | 1Password, Adobe, Atlassian/Jira, Asana, Netlify, Baremetrics, and more |
|
||||
| **Cloud Providers** | AWS, GCP, Azure (Storage, DevOps, OpenAI, Speech, Translator), Alibaba Cloud, DigitalOcean, IBM Cloud, Cloudflare, Heroku, Fly.io, Railway, Render, Temporal Cloud, and more |
|
||||
| **AI & ML** | OpenAI, Anthropic, Google Gemini, Azure OpenAI, Cohere, Mistral, DeepSeek, Groq, xAI (Grok), Stability AI, Replicate, ElevenLabs, Ollama, Langchain, Perplexity, Weights & Biases, NVIDIA NIM, Fireworks.ai, Together.ai, Cerebras, Friendli, Hugging Face, Pinecone, Cursor, Zhipu, and more |
|
||||
| **Dev & CI/CD** | GitHub, GitLab, Bitbucket, Buildkite, CircleCI, TravisCI, TeamCity, Jenkins, Drone CI, Harness, Docker Hub, npm, PyPI, RubyGems, Crates.io, NuGet, Vercel, Netlify, Pulumi, Terraform, and more |
|
||||
| **Databases** | PostgreSQL, MySQL, MongoDB, Redis, PlanetScale, Supabase, Neon, ClickHouse, DataStax Astra, Firebase, JDBC, ODBC, and more |
|
||||
| **Messaging & Email** | Slack, Discord, Microsoft Teams, Telegram, Twilio, SendGrid, Mailgun, Mailchimp, Mailjet, Postmark, Brevo (Sendinblue), Resend, and more |
|
||||
| **Observability** | Datadog, Grafana, New Relic, Sentry, Dynatrace, Honeycomb, PagerDuty, OpsGenie, Sumo Logic, Better Stack, and more |
|
||||
| **Payments & Fintech** | Stripe, PayPal, Square, GoCardless, Flutterwave, Razorpay, Plaid, Coinbase, and more |
|
||||
| **Security & Identity** | Snyk, Auth0, Okta, Clerk, LaunchDarkly, 1Password, JFrog Artifactory/Xray, SonarCloud, Endor Labs, Dependency-Track, StackHawk, and more |
|
||||
| **CRM & Business SaaS** | Salesforce, HubSpot, Jira, Confluence, Asana, Linear, Monday.com, Zendesk, Intercom, Shopify, and more |
|
||||
| **Crypto Material** | Private keys (PEM, PGP/GPG, SSH), JWTs, age encryption keys, WireGuard keys, and more |
|
||||
|
||||
## Write Custom Rules
|
||||
|
||||
|
|
@ -412,10 +414,10 @@ kingfisher scan /path/to/repo --format sarif --output findings.sarif
|
|||
|
||||
Finding a leaked credential is only the first step. The critical question isn't just "Is this a secret?"—it's "What can an attacker do with it?"
|
||||
|
||||
Kingfisher's `--access-map` feature transforms secret detection from a simple alert into a comprehensive threat assessment. Instead of leaving you with a cryptic API key, Kingfisher actively authenticates against your cloud provider (AWS, GCP, Azure Storage, Azure DevOps, GitHub, GitLab, Slack, or Microsoft Teams) to map the full extent of the credential's power.
|
||||
Kingfisher's `--access-map` feature transforms secret detection from a simple alert into a comprehensive threat assessment. Instead of leaving you with a cryptic API key, Kingfisher actively authenticates against the provider to map the full extent of the credential's power.
|
||||
|
||||
* Instant Identity Resolution: Immediately identify who the key belongs to—whether it's a specific IAM user, an assumed role, or a service account.
|
||||
* Visualize the Blast Radius: See exactly which resources (S3 buckets, EC2 instances, projects, storage containers) are exposed and at risk.
|
||||
* **Instant Identity Resolution**: Immediately identify who the key belongs to—whether it's a specific IAM user, an assumed role, or a service account.
|
||||
* **Visualize the Blast Radius**: See exactly which resources (S3 buckets, EC2 instances, projects, storage containers) are exposed and at risk.
|
||||
|
||||
```bash
|
||||
# Generate access map during scan
|
||||
|
|
@ -427,6 +429,28 @@ kingfisher view kingfisher.json
|
|||
|
||||
> **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)
|
||||
|
||||
| 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 |
|
||||
| | JFrog Artifactory | Stripe | |
|
||||
| | JFrog Xray | Square | |
|
||||
| | Jira | PayPal | |
|
||||
| | | Plaid | |
|
||||
| | | Fastly | |
|
||||
| | | OpenAI | |
|
||||
| | | Anthropic | |
|
||||
| | | Hugging Face | |
|
||||
| | | Weights & Biases | |
|
||||
| | | Gitea | |
|
||||
|
||||
## Direct Secret Validation & Revocation
|
||||
|
||||
```bash
|
||||
|
|
@ -683,7 +707,9 @@ kingfisher scan /tmp/repo --branch feature-1 \
|
|||
|----------|-------------|
|
||||
| [INSTALLATION.md](docs/INSTALLATION.md) | Complete installation guide including pre-commit hooks setup for git, pre-commit framework, and Husky |
|
||||
| [INTEGRATIONS.md](docs/INTEGRATIONS.md) | Platform-specific scanning guide (GitHub, GitLab, AWS S3, Docker, Jira, Confluence, Slack, etc.) |
|
||||
| [ACCESS_MAP.md](docs/ACCESS_MAP.md) | Access map: supported tokens and credential formats (GitHub/GitLab/Slack/AWS/GCP/Azure Storage/Postgres/MongoDB/Microsoft Teams) |
|
||||
| [ACCESS_MAP.md](docs/ACCESS_MAP.md) | Access map: supported tokens and credential formats (39 providers including AWS, GCP, Azure, Stripe, Jira, and more) |
|
||||
| [ARCHITECTURE.md](docs/ARCHITECTURE.md) | High-level Mermaid architecture diagram of the CLI, scanner pipeline, validation, access map, and outputs |
|
||||
| [DEPLOYMENT.md](docs/DEPLOYMENT.md) | Deployment models for self-serve CLI use, CI/pre-commit enforcement, centralized scanning, and embedded library integrations |
|
||||
| [ADVANCED.md](docs/ADVANCED.md) | Advanced features: baselines, confidence levels, validation tuning, CI scanning, and more |
|
||||
| [RULES.md](docs/RULES.md) | Writing custom detection rules, pattern requirements, and checksum intelligence |
|
||||
| [REVOCATION_PROVIDERS.md](docs/REVOCATION_PROVIDERS.md) | Built-in revocation coverage by provider and rule ID |
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ Strongly recommended fields:
|
|||
- `cargo test -p kingfisher-rules`
|
||||
- Broader regression check:
|
||||
- `cargo test --workspace --all-targets`
|
||||
- **Warning-free build**: `cargo check` (or `make darwin` / `make linux`) must produce zero warnings. Address all `dead_code`, `unused_*`, and other warnings before submitting. Use `#[allow(dead_code)]` on individual struct fields kept for deserialization completeness, and remove truly unused code.
|
||||
- Behavioral check against sample content:
|
||||
- `kingfisher scan ./testdata --rule <rule-family-or-id> --rule-stats`
|
||||
- Validation check (when validation is present):
|
||||
|
|
|
|||
42
crates/kingfisher-scanner/AGENTS.md
Normal file
42
crates/kingfisher-scanner/AGENTS.md
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# AGENTS.md
|
||||
|
||||
Guidance for working in `crates/kingfisher-scanner/`.
|
||||
|
||||
## Scope
|
||||
|
||||
- Applies to `crates/kingfisher-scanner/` and all files under it.
|
||||
- This file overrides broader repository guidance for this subtree.
|
||||
|
||||
## Purpose
|
||||
|
||||
- Preserve `kingfisher-scanner` as a focused, embeddable Rust API for secret scanning.
|
||||
|
||||
## Design Expectations
|
||||
|
||||
- Keep the public API centered on `Scanner`, `ScannerConfig`, `Finding`, and `ScannerPool`.
|
||||
- Prefer small, composable changes over broad API reshaping.
|
||||
- Avoid pulling binary-crate concerns into this crate unless they are truly reusable.
|
||||
- Re-export shared types from `kingfisher-core` and `kingfisher-rules` only when it materially improves embedding ergonomics.
|
||||
|
||||
## Feature Flags
|
||||
|
||||
- Validation code must remain feature-gated.
|
||||
- When adding validation support, wire it through the narrowest appropriate feature (`validation-http`, `validation-aws`, `validation-gcp`, `validation-database`, etc.).
|
||||
- Do not make optional validation dependencies unconditional unless there is a strong compatibility reason.
|
||||
|
||||
## API Stability
|
||||
|
||||
- Treat changes to exported structs, methods, and re-exports as user-facing changes.
|
||||
- Update `docs/LIBRARY.md` and crate-level docs when the public API or recommended usage changes.
|
||||
- Keep examples in the crate README and `docs/LIBRARY.md` consistent with the current API.
|
||||
|
||||
## Performance
|
||||
|
||||
- Preserve the crate's focus on efficient multi-pattern scanning.
|
||||
- Be cautious with allocations, duplicate conversions, and cross-thread contention in hot paths.
|
||||
- Keep scanner-pool and primitive changes benchmark-minded, even when benchmarks are not run in the current task.
|
||||
|
||||
## Validation
|
||||
|
||||
- Run the narrowest relevant tests first for scanner changes.
|
||||
- If public API behavior changes, prefer adding or updating focused tests in this crate or the external-consumer integration coverage.
|
||||
|
|
@ -11,6 +11,53 @@ There are two ways to produce access maps:
|
|||
|
||||
> Access mapping runs additional network requests. Only use it when you are authorized to inspect the target account/workspace.
|
||||
|
||||
## How Access Map Works
|
||||
|
||||
### Standalone Flow
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
CLI[kingfisher access-map] --> Args[Provider and credential input]
|
||||
Args --> Dispatch[Provider dispatch]
|
||||
Dispatch --> Provider[Provider mapper]
|
||||
Provider --> APIs[Provider APIs]
|
||||
APIs --> Result[AccessMapResult]
|
||||
Result --> JSON[JSON stdout or file]
|
||||
Result --> HTML[Optional HTML report]
|
||||
```
|
||||
|
||||
### Scan-Time Flow
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Scan[kingfisher scan --access-map] --> Detect[Detect findings]
|
||||
Detect --> Validate[Validate supported credentials]
|
||||
Validate --> Collect[AccessMapCollector]
|
||||
Collect --> Requests[AccessMapRequest values]
|
||||
Requests --> Map[access_map::map_requests]
|
||||
Map --> Results[AccessMapResult values]
|
||||
Results --> Report[Report and viewer output]
|
||||
```
|
||||
|
||||
### Provider Dispatch Model
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Request[Access map request] --> Kind{Credential kind}
|
||||
|
||||
Kind --> Token[Single token providers]
|
||||
Kind --> Complex[Structured credential providers]
|
||||
|
||||
Token --> Trait[TokenAccessMapper]
|
||||
Trait --> Modules[GitHub GitLab Slack Gitea Bitbucket and similar providers]
|
||||
|
||||
Complex --> Custom[Custom provider mapping]
|
||||
Custom --> ComplexModules[AWS GCP Azure Postgres MongoDB and other multi-field providers]
|
||||
|
||||
Modules --> Result[AccessMapResult]
|
||||
ComplexModules --> Result
|
||||
```
|
||||
|
||||
## What “supported tokens” means
|
||||
|
||||
Access map only runs for credential types Kingfisher knows how to authenticate with and enumerate. In the codebase, these map to `AccessMapRequest` variants recorded from validated findings (see `src/scanner/validation.rs`).
|
||||
|
|
|
|||
46
docs/AGENTS.md
Normal file
46
docs/AGENTS.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# AGENTS.md
|
||||
|
||||
Guidance for editing documentation under `docs/`.
|
||||
|
||||
## Scope
|
||||
|
||||
- Applies to `docs/` and all files under it.
|
||||
- This file overrides broader repository guidance for documentation work in this subtree.
|
||||
|
||||
## Purpose
|
||||
|
||||
- Keep documentation accurate, link-safe, and aligned with the current CLI, library APIs, and repository structure.
|
||||
|
||||
## Documentation Conventions
|
||||
|
||||
- Prefer concise, task-oriented docs over long narrative prose.
|
||||
- Use relative links for repo-local documentation (`INSTALLATION.md`, `../README.md`, etc.).
|
||||
- When adding a new top-level doc that users should discover, update the README documentation table.
|
||||
- Keep command examples consistent with current CLI syntax and option names.
|
||||
- When documenting output formats, prefer `toon` for agent/LLM-oriented examples unless human-interactive formatting is the point.
|
||||
|
||||
## Link Hygiene
|
||||
|
||||
- Check local markdown links after substantial doc edits.
|
||||
- Prefer fixing broken links by creating or restoring the intended target when the topic is still relevant.
|
||||
- If a document is intentionally removed, update all inbound links in README and related docs in the same change.
|
||||
|
||||
## Diagrams
|
||||
|
||||
- Keep Mermaid diagrams simple enough to render reliably in GitHub/Cursor markdown viewers.
|
||||
- Prefer short labels and fewer crossing arrows over exhaustive detail.
|
||||
- If one diagram becomes hard to read, split it into a small number of focused diagrams.
|
||||
|
||||
## Content Alignment
|
||||
|
||||
- Installation flows belong in `INSTALLATION.md`.
|
||||
- Platform-specific usage belongs in `INTEGRATIONS.md`.
|
||||
- Advanced runtime flags and tuning belong in `ADVANCED.md`.
|
||||
- Library embedding guidance belongs in `LIBRARY.md`.
|
||||
- Rule-authoring and validation schema guidance belongs in `RULES.md`.
|
||||
- Architecture overviews belong in `ARCHITECTURE.md`.
|
||||
|
||||
## Validation
|
||||
|
||||
- For doc-only changes, verify link targets and obvious command/example consistency.
|
||||
- If examples depend on current crate/module names, confirm they still exist before updating prose.
|
||||
125
docs/ARCHITECTURE.md
Normal file
125
docs/ARCHITECTURE.md
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
# Kingfisher Architecture
|
||||
|
||||
This document focuses on the runtime architecture of Kingfisher as implemented in this repository today.
|
||||
|
||||
It shows:
|
||||
|
||||
- a high-level component map of the main crates, modules, command paths, and outputs
|
||||
- the execution flow for `kingfisher scan`
|
||||
|
||||
## Component Map
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
User[User or CI] --> CLI[kingfisher CLI] --> Main[Dispatch and runtime]
|
||||
|
||||
subgraph Commands[Commands]
|
||||
ScanCmd[scan]
|
||||
ValidateCmd[validate]
|
||||
RevokeCmd[revoke]
|
||||
AccessMapCmd[access-map]
|
||||
ViewCmd[view]
|
||||
RulesCmd[rules]
|
||||
end
|
||||
|
||||
Main --> ScanCmd
|
||||
Main --> ValidateCmd
|
||||
Main --> RevokeCmd
|
||||
Main --> AccessMapCmd
|
||||
Main --> ViewCmd
|
||||
Main --> RulesCmd
|
||||
|
||||
subgraph Inputs[Inputs]
|
||||
FS[Files and dirs]
|
||||
Git[Git repos and history]
|
||||
Hosts[Git hosts]
|
||||
Docs[Jira Confluence Slack Teams]
|
||||
Remote[S3 GCS Docker]
|
||||
end
|
||||
|
||||
subgraph Pipeline[Scan pipeline]
|
||||
Runner[Scan runner]
|
||||
Enumerate[Enumerate and fetch]
|
||||
Process[Process blobs]
|
||||
Match[Match secrets]
|
||||
Store[FindingsStore]
|
||||
Filter[Dedup baseline safelist]
|
||||
Validate[Validate]
|
||||
Map[Access map]
|
||||
Report[Report]
|
||||
Viewer[Viewer]
|
||||
end
|
||||
|
||||
subgraph Crates[Reusable crates]
|
||||
Core[kingfisher-core]
|
||||
Rules[kingfisher-rules]
|
||||
ScannerLib[kingfisher-scanner]
|
||||
end
|
||||
|
||||
subgraph Engines[Engines]
|
||||
Vector[vectorscan]
|
||||
ScanPool[scanner pool]
|
||||
Tree[tree-sitter]
|
||||
Liquid[Liquid templates]
|
||||
end
|
||||
|
||||
APIs[Provider APIs]
|
||||
Output[Terminal and report files]
|
||||
Browser[Browser UI]
|
||||
|
||||
ScanCmd --> Runner --> Enumerate --> Process --> Match --> Store --> Filter
|
||||
Filter --> Validate
|
||||
Filter --> Report
|
||||
Validate --> Map
|
||||
Validate --> Report
|
||||
Map --> Report
|
||||
Report --> Output
|
||||
Report --> Viewer --> Browser
|
||||
|
||||
FS --> Enumerate
|
||||
Git --> Enumerate
|
||||
Hosts --> Enumerate
|
||||
Docs --> Enumerate
|
||||
Remote --> Enumerate
|
||||
|
||||
Core --> Process
|
||||
Core --> Match
|
||||
Rules --> Match
|
||||
ScannerLib --> Match
|
||||
ScannerLib --> Validate
|
||||
|
||||
Match --> Vector --> ScanPool
|
||||
Match --> Tree
|
||||
Validate --> Liquid
|
||||
Validate --> APIs
|
||||
|
||||
ValidateCmd --> Liquid
|
||||
ValidateCmd --> APIs
|
||||
RevokeCmd --> Liquid
|
||||
RevokeCmd --> APIs
|
||||
AccessMapCmd --> APIs
|
||||
ViewCmd --> Viewer
|
||||
```
|
||||
|
||||
## What Lives Where
|
||||
|
||||
- `src/main.rs`: top-level command dispatch, Tokio runtime setup, allocator selection (mimalloc/jemalloc/system), update checks, and command routing.
|
||||
- `src/scanner/runner.rs`: the orchestration hub for `scan`, including repo enumeration, clone streaming, artifact fetching, validation setup, sequential or parallel scan execution (threshold: >10 git repos triggers parallel mode), reporting, and summary generation.
|
||||
- `src/scanner/*`: input enumeration (`enumerate.rs`), repository handling and artifact fetching (`repos.rs`), blob processing (`processing.rs`), validation coordination (`validation.rs`), scan summaries (`summary.rs`), Docker image scanning (`docker.rs`), and utilities (`util.rs`).
|
||||
- `src/matcher/*`: the main detection engine (`mod.rs`), including vectorscan callbacks, regex helpers, Base64 discovery (`base64_decode.rs`), capture group handling (`captures.rs`), dedup support (`dedup.rs`), filtering (`filter.rs`), and finding fingerprinting (`fingerprint.rs`).
|
||||
- `src/parser.rs`: tree-sitter integration for language-aware parsing, supporting 17+ languages (Bash, C, C#, C++, CSS, Go, HTML, Java, JavaScript, PHP, Python, Ruby, Rust, TOML, TypeScript, YAML, and regex).
|
||||
- `src/scanner_pool.rs`: thread-local vectorscan `BlockScanner` pool, providing safe reuse of compiled pattern databases across scan threads.
|
||||
- `src/reporter.rs` and `src/reporter/*`: report rendering for pretty, JSON, BSON, TOON, SARIF, and HTML outputs, plus the data model used by the viewer.
|
||||
- `src/direct_validate.rs`: direct validation of a known secret without going through pattern matching. Supports HTTP, AWS, Azure, GCP, JDBC, MongoDB, MySQL, PostgreSQL, JWT, and Coinbase validators, with Liquid template integration for custom validation logic.
|
||||
- `src/direct_revoke.rs`: direct revocation of a known secret without going through the scan pipeline. Uses Liquid templates for revocation configurations and supports multi-step HTTP revocation flows.
|
||||
- `src/access_map.rs` and `src/access_map/*`: standalone blast-radius mapping with 24 provider implementations including AWS, Azure, GCP, GitHub, GitLab, Slack, Bitbucket, Gitea, Hugging Face, Buildkite, Anthropic, OpenAI, and more.
|
||||
|
||||
## Notes And Boundaries
|
||||
|
||||
- The main CLI scan path is implemented primarily in the application modules under `src/`, not in `kingfisher-scanner`.
|
||||
- `kingfisher-scanner` is still important: it provides the embeddable scanner API plus shared validation and primitive functionality reused by the application.
|
||||
- Direct `validate`, `revoke`, and standalone `access-map` are sibling command paths. They are not downstream stages of `FindingsStore`.
|
||||
- Reporting is downstream from the datastore, which lets Kingfisher emit multiple output formats and drive the local viewer from the same finding set.
|
||||
- The matching layer is intentionally hybrid: vectorscan provides high-throughput SIMD-accelerated pattern detection, while regex helpers, Base64 support, and tree-sitter verification improve accuracy and reduce false positives.
|
||||
- `FindingsStore` uses an in-memory store with a Bloom filter for deduplication, replacing the earlier SQLite-based storage model.
|
||||
- Validation and revocation templates are rendered via Liquid, allowing rule authors to define HTTP request sequences, variable extraction, and multi-step flows in YAML without touching Rust code.
|
||||
104
docs/DEPLOYMENT.md
Normal file
104
docs/DEPLOYMENT.md
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
# Deployment Strategies
|
||||
|
||||
[← Back to README](../README.md)
|
||||
|
||||
This guide summarizes practical ways to deploy Kingfisher in teams, CI systems, and shared security workflows.
|
||||
|
||||
## Deployment Models
|
||||
|
||||
### Self-Serve CLI
|
||||
|
||||
Best for developers, security engineers, and incident responders who want a local tool.
|
||||
|
||||
- Install via Homebrew, PyPI, Docker, or release binaries.
|
||||
- Run scans directly against local repositories, remote git hosts, cloud storage, chat exports, and other supported inputs.
|
||||
- Use `--format toon`, `json`, `sarif`, or `html` depending on whether the consumer is a human, CI system, or another tool.
|
||||
|
||||
Good fit:
|
||||
|
||||
- local triage
|
||||
- ad hoc repo reviews
|
||||
- one-off credential validation or revocation
|
||||
- pre-commit and developer workstation enforcement
|
||||
|
||||
See:
|
||||
|
||||
- [INSTALLATION.md](INSTALLATION.md)
|
||||
- [USAGE.md](USAGE.md)
|
||||
- [INTEGRATIONS.md](INTEGRATIONS.md)
|
||||
|
||||
### CI and Pre-Commit
|
||||
|
||||
Best for preventing new secrets from landing in repositories.
|
||||
|
||||
- Run `kingfisher scan` in CI against the working tree or a branch diff.
|
||||
- Use pre-commit hooks for developer-side enforcement before code is pushed.
|
||||
- Emit SARIF when integrating with code scanning or security dashboards.
|
||||
|
||||
Common patterns:
|
||||
|
||||
- scan the entire repository on protected branches
|
||||
- scan only changed content in pull request workflows
|
||||
- fail builds on findings or validated findings depending on policy
|
||||
|
||||
See:
|
||||
|
||||
- [INSTALLATION.md](INSTALLATION.md)
|
||||
- [ADVANCED.md](ADVANCED.md)
|
||||
|
||||
### Centralized Security Scanning
|
||||
|
||||
Best for security teams scanning many repositories or data sources from a controlled environment.
|
||||
|
||||
- Run Kingfisher from a dedicated automation host, container job, or scheduled workflow.
|
||||
- Store platform credentials in your existing secret manager and inject them at runtime.
|
||||
- Prefer structured outputs like JSON, SARIF, or HTML for downstream ingestion and review.
|
||||
- Use `--access-map` when you are authorized to assess blast radius for validated credentials.
|
||||
|
||||
Typical centralized inputs:
|
||||
|
||||
- GitHub, GitLab, Gitea, Bitbucket, Azure Repos, Hugging Face
|
||||
- Jira, Confluence, Slack, Microsoft Teams
|
||||
- S3, GCS, and Docker images
|
||||
|
||||
See:
|
||||
|
||||
- [INTEGRATIONS.md](INTEGRATIONS.md)
|
||||
- [ACCESS_MAP.md](ACCESS_MAP.md)
|
||||
- [ARCHITECTURE.md](ARCHITECTURE.md)
|
||||
|
||||
### Embedded Library Usage
|
||||
|
||||
Best when you want Kingfisher scanning inside another Rust application or service.
|
||||
|
||||
- Use `kingfisher-core` for shared content and location types.
|
||||
- Use `kingfisher-rules` to load or compile rules.
|
||||
- Use `kingfisher-scanner` for the embeddable scanning API.
|
||||
|
||||
This model is useful for:
|
||||
|
||||
- internal developer platforms
|
||||
- custom ingestion pipelines
|
||||
- security automation services
|
||||
- specialized report generation
|
||||
|
||||
See:
|
||||
|
||||
- [LIBRARY.md](LIBRARY.md)
|
||||
|
||||
## Operational Guidance
|
||||
|
||||
- Start with self-serve or CI deployment before building centralized automation.
|
||||
- Prefer scoped credentials for integrations and validation.
|
||||
- Use structured output formats when results are consumed by other systems.
|
||||
- Treat `--access-map`, validation, and revocation as privileged operations and run them only where authorized.
|
||||
- Keep rules and binaries updated together so documentation, features, and provider coverage stay aligned.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [INSTALLATION.md](INSTALLATION.md)
|
||||
- [USAGE.md](USAGE.md)
|
||||
- [ADVANCED.md](ADVANCED.md)
|
||||
- [INTEGRATIONS.md](INTEGRATIONS.md)
|
||||
- [ACCESS_MAP.md](ACCESS_MAP.md)
|
||||
- [LIBRARY.md](LIBRARY.md)
|
||||
102
docs/LIBRARY.md
102
docs/LIBRARY.md
|
|
@ -7,17 +7,33 @@ Kingfisher's functionality is available as a set of Rust library crates that can
|
|||
## Crate Overview
|
||||
|
||||
| Crate | Description |
|
||||
|-------|-------------|
|
||||
| ----- | ----------- |
|
||||
| `kingfisher-core` | Core types: `Blob`, `BlobId`, `Location`, `Origin`, entropy calculation |
|
||||
| `kingfisher-rules` | Rule definitions, YAML parsing, compiled rule database, builtin rules |
|
||||
| `kingfisher-scanner` | High-level scanning API with `Scanner` and `Finding` types |
|
||||
|
||||
### Crate Relationships
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
App[Your Rust application]
|
||||
Core[kingfisher-core]
|
||||
Rules[kingfisher-rules]
|
||||
Scanner[kingfisher-scanner]
|
||||
|
||||
App --> Core
|
||||
App --> Rules
|
||||
App --> Scanner
|
||||
Scanner --> Core
|
||||
Scanner --> Rules
|
||||
```
|
||||
|
||||
### Optional Features
|
||||
|
||||
The `kingfisher-scanner` crate supports optional validation features:
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| ------- | ----------- |
|
||||
| `validation` | Core validation support (includes HTTP validation) |
|
||||
| `validation-http` | HTTP-based validation for API tokens |
|
||||
| `validation-aws` | AWS credential validation via STS GetCallerIdentity |
|
||||
|
|
@ -103,9 +119,34 @@ fn scan_content(content: &[u8]) -> anyhow::Result<()> {
|
|||
|
||||
Core types and utilities for working with scannable content.
|
||||
|
||||
### Core Structure
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Core[kingfisher-core]
|
||||
Blob[blob module]
|
||||
Location[location module]
|
||||
Origin[origin module]
|
||||
Content[content_type module]
|
||||
Entropy[entropy module]
|
||||
GitMeta[git_commit_metadata module]
|
||||
Escape[bstring_escape module]
|
||||
Error[error module]
|
||||
|
||||
Core --> Blob
|
||||
Core --> Location
|
||||
Core --> Origin
|
||||
Core --> Content
|
||||
Core --> Entropy
|
||||
Core --> GitMeta
|
||||
Core --> Escape
|
||||
Core --> Error
|
||||
```
|
||||
|
||||
### Blob - Content Abstraction
|
||||
|
||||
`Blob` represents content that can be scanned. It supports:
|
||||
|
||||
- **File-backed content** with memory mapping for large files
|
||||
- **In-memory content** for programmatic use
|
||||
- **Borrowed content** for zero-copy scanning
|
||||
|
|
@ -192,9 +233,33 @@ let origin = Origin::GitRepo(GitRepoOrigin {
|
|||
|
||||
Rule definitions, YAML parsing, and the compiled rule database.
|
||||
|
||||
### Rules Structure
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Rules[kingfisher-rules]
|
||||
RuleMod[rule module]
|
||||
RulesMod[rules module]
|
||||
Db[rules_database module]
|
||||
Defaults[defaults module]
|
||||
Liquid[liquid_filters module]
|
||||
|
||||
Rules --> RuleMod
|
||||
Rules --> RulesMod
|
||||
Rules --> Db
|
||||
Rules --> Defaults
|
||||
Rules --> Liquid
|
||||
|
||||
RuleMod --> Syntax[Rule and RuleSyntax]
|
||||
RulesMod --> Collections[Rules collection and loading]
|
||||
Db --> Compiled[Compiled RulesDatabase]
|
||||
Defaults --> Builtins[Builtin rules]
|
||||
Liquid --> Filters[Template filters]
|
||||
```
|
||||
|
||||
### Loading Builtin Rules
|
||||
|
||||
Kingfisher comes with 400+ builtin rules for common secret types:
|
||||
Kingfisher comes with 600+ builtin rules for common secret types:
|
||||
|
||||
```rust
|
||||
use kingfisher_rules::{get_builtin_rules, Confidence};
|
||||
|
|
@ -314,6 +379,7 @@ let template = parser.parse("{{ secret | sha256 }}")?;
|
|||
```
|
||||
|
||||
Available filters:
|
||||
|
||||
- **Encoding**: `b64enc`, `b64dec`, `b64url_enc`, `url_encode`, `json_escape`
|
||||
- **Hashing**: `sha256`, `crc32`, `crc32_dec`, `crc32_hex`, `crc32_le_b64`
|
||||
- **HMAC**: `hmac_sha256`, `hmac_sha384`, `hmac_sha1`, `hmac_sha256_b64key`
|
||||
|
|
@ -328,6 +394,34 @@ Available filters:
|
|||
|
||||
High-level scanning API that combines core types and rules.
|
||||
|
||||
### Scanner Structure
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Scanner[kingfisher-scanner]
|
||||
ScanMod[scanner module]
|
||||
FindingMod[finding module]
|
||||
PoolMod[scanner_pool module]
|
||||
Prim[primitives module]
|
||||
Validation[validation module]
|
||||
Core[kingfisher-core]
|
||||
Rules[kingfisher-rules]
|
||||
|
||||
Scanner --> ScanMod
|
||||
Scanner --> FindingMod
|
||||
Scanner --> PoolMod
|
||||
Scanner --> Prim
|
||||
Scanner --> Validation
|
||||
Scanner --> Core
|
||||
Scanner --> Rules
|
||||
|
||||
ScanMod --> API[Scanner and ScannerConfig]
|
||||
FindingMod --> Finding[Finding types]
|
||||
PoolMod --> Pool[ScannerPool]
|
||||
Prim --> Helpers[Matching helpers]
|
||||
Validation --> Validators[Optional validators]
|
||||
```
|
||||
|
||||
### Scanner Configuration
|
||||
|
||||
```rust
|
||||
|
|
@ -627,7 +721,7 @@ kingfisher-scanner = { git = "https://github.com/mongodb/kingfisher", features =
|
|||
### Available Features
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| ------- | ----------- |
|
||||
| `validation` | Core validation support with HTTP validation |
|
||||
| `validation-http` | HTTP-based validation for API tokens |
|
||||
| `validation-aws` | AWS credential validation via STS |
|
||||
|
|
|
|||
|
|
@ -4,26 +4,47 @@ use serde::Serialize;
|
|||
|
||||
use crate::cli::commands::access_map::{AccessMapArgs, AccessMapProvider};
|
||||
|
||||
mod airtable;
|
||||
mod algolia;
|
||||
mod anthropic;
|
||||
mod artifactory;
|
||||
mod auth0;
|
||||
mod aws;
|
||||
mod azure;
|
||||
mod azure_devops;
|
||||
mod bitbucket;
|
||||
mod buildkite;
|
||||
mod circleci;
|
||||
mod digitalocean;
|
||||
mod fastly;
|
||||
mod gcp;
|
||||
mod gitea;
|
||||
mod github;
|
||||
mod gitlab;
|
||||
mod harness;
|
||||
mod hubspot;
|
||||
mod huggingface;
|
||||
mod ibm_cloud;
|
||||
mod jira;
|
||||
mod microsoft_teams;
|
||||
pub(crate) mod mongodb;
|
||||
pub(crate) mod mysql;
|
||||
mod openai;
|
||||
mod paypal;
|
||||
mod plaid;
|
||||
pub(crate) mod postgres;
|
||||
mod report;
|
||||
mod salesforce;
|
||||
mod sendgrid;
|
||||
mod sendinblue;
|
||||
mod shopify;
|
||||
mod slack;
|
||||
mod square;
|
||||
mod stripe;
|
||||
mod terraform;
|
||||
mod weightsandbiases;
|
||||
mod xray;
|
||||
mod zendesk;
|
||||
|
||||
/// Trait for access map providers that map a single token to an access profile.
|
||||
///
|
||||
|
|
@ -62,6 +83,27 @@ pub async fn run(args: AccessMapArgs) -> Result<()> {
|
|||
AccessMapProvider::Salesforce => salesforce::map_access(&args).await?,
|
||||
AccessMapProvider::Weightsandbiases => weightsandbiases::map_access(&args).await?,
|
||||
AccessMapProvider::Microsoftteams => microsoft_teams::map_access(&args).await?,
|
||||
AccessMapProvider::Airtable => airtable::map_access(&args).await?,
|
||||
AccessMapProvider::Circleci => circleci::map_access(&args).await?,
|
||||
AccessMapProvider::Digitalocean => digitalocean::map_access(&args).await?,
|
||||
AccessMapProvider::Fastly => fastly::map_access(&args).await?,
|
||||
AccessMapProvider::Hubspot => hubspot::map_access(&args).await?,
|
||||
AccessMapProvider::Ibmcloud => ibm_cloud::map_access(&args).await?,
|
||||
AccessMapProvider::Sendgrid => sendgrid::map_access(&args).await?,
|
||||
AccessMapProvider::Sendinblue => sendinblue::map_access(&args).await?,
|
||||
AccessMapProvider::Stripe => stripe::map_access(&args).await?,
|
||||
AccessMapProvider::Terraform => terraform::map_access(&args).await?,
|
||||
AccessMapProvider::Square => square::map_access(&args).await?,
|
||||
AccessMapProvider::Jira => jira::map_access(&args).await?,
|
||||
AccessMapProvider::Mysql => mysql::map_access(&args).await?,
|
||||
AccessMapProvider::Algolia => algolia::map_access(&args).await?,
|
||||
AccessMapProvider::Auth0 => auth0::map_access(&args).await?,
|
||||
AccessMapProvider::Paypal => paypal::map_access(&args).await?,
|
||||
AccessMapProvider::Plaid => plaid::map_access(&args).await?,
|
||||
AccessMapProvider::Shopify => shopify::map_access(&args).await?,
|
||||
AccessMapProvider::Zendesk => zendesk::map_access(&args).await?,
|
||||
AccessMapProvider::Artifactory => artifactory::map_access(&args).await?,
|
||||
AccessMapProvider::Xray => xray::map_access(&args).await?,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string_pretty(&result)?;
|
||||
|
|
@ -124,6 +166,48 @@ pub enum AccessMapRequest {
|
|||
WeightsAndBiases { token: String, fingerprint: String },
|
||||
/// A Microsoft Teams Incoming Webhook URL.
|
||||
MicrosoftTeams { webhook_url: String, fingerprint: String },
|
||||
/// An Airtable API token.
|
||||
Airtable { token: String, fingerprint: String },
|
||||
/// A CircleCI API token.
|
||||
CircleCI { token: String, fingerprint: String },
|
||||
/// A DigitalOcean API token.
|
||||
DigitalOcean { token: String, fingerprint: String },
|
||||
/// A Fastly API token.
|
||||
Fastly { token: String, fingerprint: String },
|
||||
/// A HubSpot API token.
|
||||
HubSpot { token: String, fingerprint: String },
|
||||
/// An IBM Cloud API key.
|
||||
IbmCloud { token: String, fingerprint: String },
|
||||
/// A SendGrid API token.
|
||||
SendGrid { token: String, fingerprint: String },
|
||||
/// A Brevo (Sendinblue) API token.
|
||||
Sendinblue { token: String, fingerprint: String },
|
||||
/// A Stripe API key.
|
||||
Stripe { token: String, fingerprint: String },
|
||||
/// A Terraform Cloud API token.
|
||||
Terraform { token: String, fingerprint: String },
|
||||
/// A Square API token.
|
||||
Square { token: String, fingerprint: String },
|
||||
/// A Jira API token with base URL.
|
||||
Jira { token: String, base_url: String, fingerprint: String },
|
||||
/// A MySQL connection URI.
|
||||
MySQL { uri: String, fingerprint: String },
|
||||
/// An Algolia app_id + api_key pair.
|
||||
Algolia { app_id: String, api_key: String, fingerprint: String },
|
||||
/// Auth0 client credentials (client_id + client_secret + domain).
|
||||
Auth0 { client_id: String, client_secret: String, domain: String, fingerprint: String },
|
||||
/// PayPal client credentials (client_id + client_secret).
|
||||
PayPal { client_id: String, client_secret: String, fingerprint: String },
|
||||
/// Plaid API credentials (client_id + secret).
|
||||
Plaid { client_id: String, secret: String, fingerprint: String },
|
||||
/// A Shopify access token with store subdomain.
|
||||
Shopify { token: String, subdomain: String, fingerprint: String },
|
||||
/// A Zendesk API token with subdomain.
|
||||
Zendesk { token: String, subdomain: String, fingerprint: String },
|
||||
/// A JFrog Artifactory token with optional base URL.
|
||||
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 },
|
||||
}
|
||||
|
||||
/// Structured output describing the resolved identity and its risk profile.
|
||||
|
|
@ -345,6 +429,107 @@ pub async fn map_requests(requests: Vec<AccessMapRequest>) -> Vec<AccessMapResul
|
|||
.unwrap_or_else(|err| build_failed_result("microsoft_teams", "webhook", err)),
|
||||
fingerprint,
|
||||
),
|
||||
AccessMapRequest::Airtable { token, fingerprint } => {
|
||||
(map_token(&AirtableMapper, &token).await, fingerprint)
|
||||
}
|
||||
AccessMapRequest::CircleCI { token, fingerprint } => {
|
||||
(map_token(&CircleCiMapper, &token).await, fingerprint)
|
||||
}
|
||||
AccessMapRequest::DigitalOcean { token, fingerprint } => {
|
||||
(map_token(&DigitalOceanMapper, &token).await, fingerprint)
|
||||
}
|
||||
AccessMapRequest::Fastly { token, fingerprint } => {
|
||||
(map_token(&FastlyMapper, &token).await, fingerprint)
|
||||
}
|
||||
AccessMapRequest::HubSpot { token, fingerprint } => {
|
||||
(map_token(&HubSpotMapper, &token).await, fingerprint)
|
||||
}
|
||||
AccessMapRequest::IbmCloud { token, fingerprint } => {
|
||||
(map_token(&IbmCloudMapper, &token).await, fingerprint)
|
||||
}
|
||||
AccessMapRequest::SendGrid { token, fingerprint } => {
|
||||
(map_token(&SendGridMapper, &token).await, fingerprint)
|
||||
}
|
||||
AccessMapRequest::Sendinblue { token, fingerprint } => {
|
||||
(map_token(&SendinblueMapper, &token).await, fingerprint)
|
||||
}
|
||||
AccessMapRequest::Stripe { token, fingerprint } => {
|
||||
(map_token(&StripeMapper, &token).await, fingerprint)
|
||||
}
|
||||
AccessMapRequest::Terraform { token, fingerprint } => {
|
||||
(map_token(&TerraformMapper, &token).await, fingerprint)
|
||||
}
|
||||
AccessMapRequest::Square { token, fingerprint } => {
|
||||
(map_token(&SquareMapper, &token).await, fingerprint)
|
||||
}
|
||||
AccessMapRequest::Jira { token, base_url, fingerprint } => (
|
||||
jira::map_access_from_token_and_url(&token, &base_url)
|
||||
.await
|
||||
.unwrap_or_else(|err| build_failed_result("jira", "token", err)),
|
||||
fingerprint,
|
||||
),
|
||||
AccessMapRequest::MySQL { uri, fingerprint } => (
|
||||
mysql::map_access_from_uri(&uri)
|
||||
.await
|
||||
.unwrap_or_else(|err| build_failed_result("mysql", "uri", err)),
|
||||
fingerprint,
|
||||
),
|
||||
AccessMapRequest::Algolia { app_id, api_key, fingerprint } => (
|
||||
algolia::map_access_from_credentials(&app_id, &api_key)
|
||||
.await
|
||||
.unwrap_or_else(|err| build_failed_result("algolia", &app_id, err)),
|
||||
fingerprint,
|
||||
),
|
||||
AccessMapRequest::Auth0 { client_id, client_secret, domain, fingerprint } => (
|
||||
auth0::map_access_from_credentials(&client_id, &client_secret, &domain)
|
||||
.await
|
||||
.unwrap_or_else(|err| build_failed_result("auth0", &client_id, err)),
|
||||
fingerprint,
|
||||
),
|
||||
AccessMapRequest::PayPal { client_id, client_secret, fingerprint } => (
|
||||
paypal::map_access_from_credentials(&client_id, &client_secret)
|
||||
.await
|
||||
.unwrap_or_else(|err| build_failed_result("paypal", &client_id, err)),
|
||||
fingerprint,
|
||||
),
|
||||
AccessMapRequest::Plaid { client_id, secret, fingerprint } => (
|
||||
plaid::map_access_from_credentials(&client_id, &secret)
|
||||
.await
|
||||
.unwrap_or_else(|err| build_failed_result("plaid", &client_id, err)),
|
||||
fingerprint,
|
||||
),
|
||||
AccessMapRequest::Shopify { token, subdomain, fingerprint } => (
|
||||
shopify::map_access_from_token_and_subdomain(&token, &subdomain)
|
||||
.await
|
||||
.unwrap_or_else(|err| build_failed_result("shopify", &subdomain, err)),
|
||||
fingerprint,
|
||||
),
|
||||
AccessMapRequest::Zendesk { token, subdomain, fingerprint } => (
|
||||
zendesk::map_access_from_token_and_subdomain(&token, &subdomain)
|
||||
.await
|
||||
.unwrap_or_else(|err| build_failed_result("zendesk", &subdomain, err)),
|
||||
fingerprint,
|
||||
),
|
||||
AccessMapRequest::Artifactory { token, base_url, fingerprint } => {
|
||||
let res: Result<AccessMapResult> = match base_url {
|
||||
Some(url) => artifactory::map_access_from_token_and_url(&token, &url).await,
|
||||
None => artifactory::map_access_from_token(&token).await,
|
||||
};
|
||||
(
|
||||
res.unwrap_or_else(|err| build_failed_result("artifactory", "token", err)),
|
||||
fingerprint,
|
||||
)
|
||||
}
|
||||
AccessMapRequest::Xray { token, base_url, fingerprint } => {
|
||||
let res: Result<AccessMapResult> = match base_url {
|
||||
Some(url) => xray::map_access_from_token_and_url(&token, &url).await,
|
||||
None => xray::map_access_from_token(&token).await,
|
||||
};
|
||||
(
|
||||
res.unwrap_or_else(|err| build_failed_result("jfrog_xray", "token", err)),
|
||||
fingerprint,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
mapped.fingerprint = Some(fp);
|
||||
|
|
@ -515,6 +700,149 @@ impl TokenAccessMapper for WeightsAndBiasesMapper {
|
|||
}
|
||||
}
|
||||
|
||||
/// Airtable access mapper.
|
||||
pub struct AirtableMapper;
|
||||
|
||||
impl TokenAccessMapper for AirtableMapper {
|
||||
fn cloud_name(&self) -> &'static str {
|
||||
"airtable"
|
||||
}
|
||||
|
||||
async fn map_access_from_token(&self, token: &str) -> Result<AccessMapResult> {
|
||||
airtable::map_access_from_token(token).await
|
||||
}
|
||||
}
|
||||
|
||||
/// CircleCI access mapper.
|
||||
pub struct CircleCiMapper;
|
||||
|
||||
impl TokenAccessMapper for CircleCiMapper {
|
||||
fn cloud_name(&self) -> &'static str {
|
||||
"circleci"
|
||||
}
|
||||
|
||||
async fn map_access_from_token(&self, token: &str) -> Result<AccessMapResult> {
|
||||
circleci::map_access_from_token(token).await
|
||||
}
|
||||
}
|
||||
|
||||
/// DigitalOcean access mapper.
|
||||
pub struct DigitalOceanMapper;
|
||||
|
||||
impl TokenAccessMapper for DigitalOceanMapper {
|
||||
fn cloud_name(&self) -> &'static str {
|
||||
"digitalocean"
|
||||
}
|
||||
|
||||
async fn map_access_from_token(&self, token: &str) -> Result<AccessMapResult> {
|
||||
digitalocean::map_access_from_token(token).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Fastly access mapper.
|
||||
pub struct FastlyMapper;
|
||||
|
||||
impl TokenAccessMapper for FastlyMapper {
|
||||
fn cloud_name(&self) -> &'static str {
|
||||
"fastly"
|
||||
}
|
||||
|
||||
async fn map_access_from_token(&self, token: &str) -> Result<AccessMapResult> {
|
||||
fastly::map_access_from_token(token).await
|
||||
}
|
||||
}
|
||||
|
||||
/// HubSpot access mapper.
|
||||
pub struct HubSpotMapper;
|
||||
|
||||
impl TokenAccessMapper for HubSpotMapper {
|
||||
fn cloud_name(&self) -> &'static str {
|
||||
"hubspot"
|
||||
}
|
||||
|
||||
async fn map_access_from_token(&self, token: &str) -> Result<AccessMapResult> {
|
||||
hubspot::map_access_from_token(token).await
|
||||
}
|
||||
}
|
||||
|
||||
/// IBM Cloud access mapper.
|
||||
pub struct IbmCloudMapper;
|
||||
|
||||
impl TokenAccessMapper for IbmCloudMapper {
|
||||
fn cloud_name(&self) -> &'static str {
|
||||
"ibm_cloud"
|
||||
}
|
||||
|
||||
async fn map_access_from_token(&self, token: &str) -> Result<AccessMapResult> {
|
||||
ibm_cloud::map_access_from_token(token).await
|
||||
}
|
||||
}
|
||||
|
||||
/// SendGrid access mapper.
|
||||
pub struct SendGridMapper;
|
||||
|
||||
impl TokenAccessMapper for SendGridMapper {
|
||||
fn cloud_name(&self) -> &'static str {
|
||||
"sendgrid"
|
||||
}
|
||||
|
||||
async fn map_access_from_token(&self, token: &str) -> Result<AccessMapResult> {
|
||||
sendgrid::map_access_from_token(token).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Sendinblue (Brevo) access mapper.
|
||||
pub struct SendinblueMapper;
|
||||
|
||||
impl TokenAccessMapper for SendinblueMapper {
|
||||
fn cloud_name(&self) -> &'static str {
|
||||
"sendinblue"
|
||||
}
|
||||
|
||||
async fn map_access_from_token(&self, token: &str) -> Result<AccessMapResult> {
|
||||
sendinblue::map_access_from_token(token).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Stripe access mapper.
|
||||
pub struct StripeMapper;
|
||||
|
||||
impl TokenAccessMapper for StripeMapper {
|
||||
fn cloud_name(&self) -> &'static str {
|
||||
"stripe"
|
||||
}
|
||||
|
||||
async fn map_access_from_token(&self, token: &str) -> Result<AccessMapResult> {
|
||||
stripe::map_access_from_token(token).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Terraform Cloud access mapper.
|
||||
pub struct TerraformMapper;
|
||||
|
||||
impl TokenAccessMapper for TerraformMapper {
|
||||
fn cloud_name(&self) -> &'static str {
|
||||
"terraform"
|
||||
}
|
||||
|
||||
async fn map_access_from_token(&self, token: &str) -> Result<AccessMapResult> {
|
||||
terraform::map_access_from_token(token).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Square access mapper.
|
||||
pub struct SquareMapper;
|
||||
|
||||
impl TokenAccessMapper for SquareMapper {
|
||||
fn cloud_name(&self) -> &'static str {
|
||||
"square"
|
||||
}
|
||||
|
||||
async fn map_access_from_token(&self, token: &str) -> Result<AccessMapResult> {
|
||||
square::map_access_from_token(token).await
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Helper functions
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
|
|
|||
44
src/access_map/AGENTS.md
Normal file
44
src/access_map/AGENTS.md
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
# AGENTS.md
|
||||
|
||||
Guidance for working in `src/access_map/`.
|
||||
|
||||
## Scope
|
||||
|
||||
- Applies to `src/access_map/` and all files under it.
|
||||
- This file overrides broader repository guidance for this subtree.
|
||||
|
||||
## Purpose
|
||||
|
||||
- Keep access-map providers consistent, read-only by default, and aligned with the shared result model.
|
||||
|
||||
## Provider Layout
|
||||
|
||||
- Prefer one provider per file.
|
||||
- Keep provider-specific HTTP/API logic inside the provider module.
|
||||
- Shared rendering belongs in `report.rs`; shared types and dispatch stay in `src/access_map.rs`.
|
||||
|
||||
## Behavioral Rules
|
||||
|
||||
- Access map should inspect blast radius, not modify remote state.
|
||||
- Prefer read-only enumeration and metadata lookups.
|
||||
- If a provider requires best-effort probing because APIs are limited, document that behavior clearly in code comments or docs.
|
||||
- Return partial but useful results instead of failing hard when a provider can still identify the principal or some resources.
|
||||
|
||||
## Result Shape
|
||||
|
||||
- Populate `AccessMapResult` consistently: identity, roles, permissions, resources, severity, recommendations, and risk notes.
|
||||
- Use provider-specific metadata only when it adds clear value and fits the shared schema.
|
||||
- Keep severity and recommendation logic understandable and comparable across providers.
|
||||
|
||||
## Wiring Checklist
|
||||
|
||||
- Add the provider module here.
|
||||
- Wire the provider into `src/access_map.rs`.
|
||||
- Wire the CLI enum/aliases in `src/cli/commands/access_map.rs`.
|
||||
- Update `docs/ACCESS_MAP.md` and any relevant README/docs mentions.
|
||||
- If scan-time auto-collection should support the provider, verify the validation-to-access-map path too.
|
||||
|
||||
## Testing
|
||||
|
||||
- Prefer focused tests around result shaping, severity classification, and graceful handling of partial permissions.
|
||||
- Avoid introducing provider implementations that require destructive credentials or side effects for normal verification.
|
||||
258
src/access_map/airtable.rs
Normal file
258
src/access_map/airtable.rs
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
use anyhow::{anyhow, Context, Result};
|
||||
use reqwest::{header, Client};
|
||||
use serde::Deserialize;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::{cli::commands::access_map::AccessMapArgs, validation::GLOBAL_USER_AGENT};
|
||||
|
||||
use super::{
|
||||
build_recommendations, AccessMapResult, AccessSummary, AccessTokenDetails, PermissionSummary,
|
||||
ResourceExposure, RoleBinding, Severity,
|
||||
};
|
||||
|
||||
const AIRTABLE_API: &str = "https://api.airtable.com";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AirtableWhoami {
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
#[serde(default)]
|
||||
email: Option<String>,
|
||||
#[serde(default)]
|
||||
scopes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AirtableBasesResponse {
|
||||
#[serde(default)]
|
||||
bases: Vec<AirtableBase>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AirtableBase {
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(rename = "permissionLevel", default)]
|
||||
permission_level: 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 Airtable token from {}", path.display()))?;
|
||||
raw.trim().to_string()
|
||||
} else {
|
||||
return Err(anyhow!("Airtable 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 Airtable HTTP client")?;
|
||||
|
||||
let whoami = fetch_whoami(&client, token).await?;
|
||||
|
||||
let username = whoami
|
||||
.email
|
||||
.clone()
|
||||
.unwrap_or_else(|| whoami.id.clone().unwrap_or_else(|| "airtable_user".to_string()));
|
||||
|
||||
let identity = AccessSummary {
|
||||
id: username.clone(),
|
||||
access_type: "user".into(),
|
||||
project: None,
|
||||
tenant: None,
|
||||
account_id: whoami.id.clone(),
|
||||
};
|
||||
|
||||
let mut risk_notes = Vec::new();
|
||||
let mut resources = Vec::new();
|
||||
let mut permissions = PermissionSummary::default();
|
||||
let mut roles = Vec::new();
|
||||
|
||||
for scope in &whoami.scopes {
|
||||
let role = RoleBinding {
|
||||
name: format!("scope:{scope}"),
|
||||
source: "airtable".into(),
|
||||
permissions: vec![scope.clone()],
|
||||
};
|
||||
roles.push(role);
|
||||
|
||||
match classify_scope(scope) {
|
||||
ScopeRisk::Admin => permissions.admin.push(scope.clone()),
|
||||
ScopeRisk::Write => permissions.risky.push(scope.clone()),
|
||||
ScopeRisk::Read => permissions.read_only.push(scope.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
let bases = list_bases(&client, token).await.unwrap_or_else(|err| {
|
||||
warn!("Airtable access-map: base enumeration failed: {err}");
|
||||
Vec::new()
|
||||
});
|
||||
|
||||
for base in &bases {
|
||||
let base_name = base
|
||||
.name
|
||||
.clone()
|
||||
.or_else(|| base.id.clone())
|
||||
.unwrap_or_else(|| "unknown_base".to_string());
|
||||
|
||||
let perm_level = base.permission_level.as_deref().unwrap_or("unknown");
|
||||
|
||||
let risk = match perm_level {
|
||||
"create" => Severity::High,
|
||||
"edit" => Severity::Medium,
|
||||
"read" | "comment" => Severity::Low,
|
||||
_ => Severity::Medium,
|
||||
};
|
||||
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: "base".into(),
|
||||
name: base_name.clone(),
|
||||
permissions: vec![format!("permissionLevel:{perm_level}")],
|
||||
risk: severity_to_str(risk).to_string(),
|
||||
reason: format!("Airtable base accessible with {perm_level} permission"),
|
||||
});
|
||||
}
|
||||
|
||||
permissions.admin.sort();
|
||||
permissions.admin.dedup();
|
||||
permissions.risky.sort();
|
||||
permissions.risky.dedup();
|
||||
permissions.read_only.sort();
|
||||
permissions.read_only.dedup();
|
||||
|
||||
let severity = derive_severity(&whoami.scopes, &bases);
|
||||
|
||||
if bases.is_empty() && whoami.scopes.is_empty() {
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: "account".into(),
|
||||
name: username.clone(),
|
||||
permissions: Vec::new(),
|
||||
risk: severity_to_str(Severity::Low).to_string(),
|
||||
reason: "Airtable account associated with the token".into(),
|
||||
});
|
||||
risk_notes.push("Token did not enumerate any bases or scopes".into());
|
||||
}
|
||||
|
||||
Ok(AccessMapResult {
|
||||
cloud: "airtable".into(),
|
||||
identity,
|
||||
roles,
|
||||
permissions,
|
||||
resources,
|
||||
severity,
|
||||
recommendations: build_recommendations(severity),
|
||||
risk_notes,
|
||||
token_details: Some(AccessTokenDetails {
|
||||
name: None,
|
||||
username: whoami.email.clone(),
|
||||
account_type: None,
|
||||
company: None,
|
||||
location: None,
|
||||
email: whoami.email.clone(),
|
||||
url: None,
|
||||
token_type: None,
|
||||
created_at: None,
|
||||
last_used_at: None,
|
||||
expires_at: None,
|
||||
user_id: whoami.id,
|
||||
scopes: whoami.scopes,
|
||||
}),
|
||||
provider_metadata: None,
|
||||
fingerprint: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch_whoami(client: &Client, token: &str) -> Result<AirtableWhoami> {
|
||||
let resp = client
|
||||
.get(format!("{AIRTABLE_API}/v0/meta/whoami"))
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await
|
||||
.context("Airtable access-map: failed to fetch whoami")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(anyhow!(
|
||||
"Airtable access-map: whoami lookup failed with HTTP {}",
|
||||
resp.status()
|
||||
));
|
||||
}
|
||||
|
||||
resp.json().await.context("Airtable access-map: invalid whoami JSON")
|
||||
}
|
||||
|
||||
async fn list_bases(client: &Client, token: &str) -> Result<Vec<AirtableBase>> {
|
||||
let resp = client
|
||||
.get(format!("{AIRTABLE_API}/v0/meta/bases"))
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await
|
||||
.context("Airtable access-map: failed to list bases")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
warn!("Airtable access-map: base enumeration failed with HTTP {}", resp.status());
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let body: AirtableBasesResponse =
|
||||
resp.json().await.context("Airtable access-map: invalid bases JSON")?;
|
||||
Ok(body.bases)
|
||||
}
|
||||
|
||||
enum ScopeRisk {
|
||||
Admin,
|
||||
Write,
|
||||
Read,
|
||||
}
|
||||
|
||||
fn classify_scope(scope: &str) -> ScopeRisk {
|
||||
match scope {
|
||||
"schema:bases:write" | "user.email:read" => ScopeRisk::Admin,
|
||||
"data.records:write" | "data.recordComments:write" => ScopeRisk::Write,
|
||||
_ if scope.contains(":write") => ScopeRisk::Write,
|
||||
_ => ScopeRisk::Read,
|
||||
}
|
||||
}
|
||||
|
||||
fn has_admin_scope(scopes: &[String]) -> bool {
|
||||
scopes.iter().any(|s| matches!(s.as_str(), "schema:bases:write" | "user.email:read"))
|
||||
}
|
||||
|
||||
fn has_write_scope(scopes: &[String]) -> bool {
|
||||
scopes.iter().any(|s| s.contains(":write"))
|
||||
}
|
||||
|
||||
fn derive_severity(scopes: &[String], bases: &[AirtableBase]) -> Severity {
|
||||
if has_admin_scope(scopes) {
|
||||
return Severity::Critical;
|
||||
}
|
||||
|
||||
let has_write = has_write_scope(scopes);
|
||||
if has_write && bases.len() > 5 {
|
||||
return Severity::High;
|
||||
}
|
||||
|
||||
if has_write {
|
||||
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",
|
||||
}
|
||||
}
|
||||
256
src/access_map/algolia.rs
Normal file
256
src/access_map/algolia.rs
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
use anyhow::{anyhow, Context, Result};
|
||||
use reqwest::{header, Client};
|
||||
use serde::Deserialize;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::cli::commands::access_map::AccessMapArgs;
|
||||
use crate::validation::GLOBAL_USER_AGENT;
|
||||
|
||||
use super::{
|
||||
build_recommendations, AccessMapResult, AccessSummary, AccessTokenDetails, PermissionSummary,
|
||||
ResourceExposure, RoleBinding, Severity,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API response types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AlgoliaKeyInfo {
|
||||
#[allow(dead_code)]
|
||||
#[serde(default)]
|
||||
value: String,
|
||||
#[serde(default)]
|
||||
acl: Vec<String>,
|
||||
#[serde(default)]
|
||||
indexes: Vec<String>,
|
||||
#[serde(default)]
|
||||
validity: i64,
|
||||
#[serde(default)]
|
||||
description: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AlgoliaIndexList {
|
||||
#[serde(default)]
|
||||
items: Vec<AlgoliaIndex>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AlgoliaIndex {
|
||||
#[serde(default)]
|
||||
name: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ACL classification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn classify_acl(acl: &str) -> AclCategory {
|
||||
match acl {
|
||||
"admin" | "editSettings" | "listIndexes" | "deleteIndex" => AclCategory::Admin,
|
||||
"addObject" | "deleteObject" | "browse" => AclCategory::Risky,
|
||||
"search" | "analytics" | "recommendation" | "usage" => AclCategory::Read,
|
||||
_ => AclCategory::Read,
|
||||
}
|
||||
}
|
||||
|
||||
enum AclCategory {
|
||||
Admin,
|
||||
Risky,
|
||||
Read,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public entry points
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn map_access(args: &AccessMapArgs) -> Result<AccessMapResult> {
|
||||
let path = args.credential_path.as_deref().ok_or_else(|| {
|
||||
anyhow!("Algolia access-map requires a credential file with app_id and api_key")
|
||||
})?;
|
||||
let raw = std::fs::read_to_string(path).with_context(|| {
|
||||
format!("Failed to read Algolia credential file from {}", path.display())
|
||||
})?;
|
||||
let json: serde_json::Value = serde_json::from_str(&raw)
|
||||
.context("Algolia credential file must be valid JSON with app_id and api_key")?;
|
||||
|
||||
let app_id = json
|
||||
.get("app_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow!("Algolia credential JSON missing 'app_id'"))?;
|
||||
let api_key = json
|
||||
.get("api_key")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow!("Algolia credential JSON missing 'api_key'"))?;
|
||||
|
||||
map_access_from_credentials(app_id, api_key).await
|
||||
}
|
||||
|
||||
pub async fn map_access_from_credentials(app_id: &str, api_key: &str) -> Result<AccessMapResult> {
|
||||
let client = Client::builder()
|
||||
.user_agent(GLOBAL_USER_AGENT.as_str())
|
||||
.build()
|
||||
.context("Failed to build Algolia HTTP client")?;
|
||||
|
||||
let base = format!("https://{app_id}-dsn.algolia.net");
|
||||
let mut risk_notes: Vec<String> = Vec::new();
|
||||
let mut permissions = PermissionSummary::default();
|
||||
|
||||
// Fetch key info
|
||||
let key_info = fetch_key_info(&client, &base, app_id, api_key).await?;
|
||||
|
||||
// Classify ACLs
|
||||
for acl in &key_info.acl {
|
||||
match classify_acl(acl) {
|
||||
AclCategory::Admin => permissions.admin.push(format!("acl:{acl}")),
|
||||
AclCategory::Risky => permissions.risky.push(format!("acl:{acl}")),
|
||||
AclCategory::Read => permissions.read_only.push(format!("acl:{acl}")),
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch indexes
|
||||
let indexes = fetch_indexes(&client, &base, app_id, api_key).await.unwrap_or_else(|err| {
|
||||
warn!("Algolia access-map: index listing failed: {err}");
|
||||
risk_notes.push(format!("Index listing failed: {err}"));
|
||||
Vec::new()
|
||||
});
|
||||
|
||||
// Determine severity
|
||||
let has_admin = !permissions.admin.is_empty();
|
||||
let has_risky = !permissions.risky.is_empty();
|
||||
let severity = if has_admin {
|
||||
Severity::Critical
|
||||
} else if has_risky {
|
||||
Severity::High
|
||||
} else {
|
||||
Severity::Medium
|
||||
};
|
||||
|
||||
let wildcard_indexes = key_info.indexes.contains(&"*".to_string());
|
||||
if wildcard_indexes {
|
||||
risk_notes.push("API key has wildcard index access (all indexes)".to_string());
|
||||
}
|
||||
if key_info.validity == 0 {
|
||||
risk_notes.push("API key has no validity limit (does not expire)".to_string());
|
||||
}
|
||||
|
||||
let roles = vec![RoleBinding {
|
||||
name: "algolia_api_key".into(),
|
||||
source: "algolia".into(),
|
||||
permissions: key_info.acl.iter().map(|a| format!("acl:{a}")).collect(),
|
||||
}];
|
||||
|
||||
let mut resources = vec![ResourceExposure {
|
||||
resource_type: "algolia_application".into(),
|
||||
name: app_id.to_string(),
|
||||
permissions: key_info.acl.iter().map(|a| format!("acl:{a}")).collect(),
|
||||
risk: severity_to_str(severity).to_string(),
|
||||
reason: "Algolia application reachable with this API key".to_string(),
|
||||
}];
|
||||
|
||||
for idx in &indexes {
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: "algolia_index".into(),
|
||||
name: idx.clone(),
|
||||
permissions: vec!["index:accessible".into()],
|
||||
risk: severity_to_str(Severity::Medium).to_string(),
|
||||
reason: "Index accessible via this API key".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
permissions.admin.sort();
|
||||
permissions.admin.dedup();
|
||||
permissions.risky.sort();
|
||||
permissions.risky.dedup();
|
||||
permissions.read_only.sort();
|
||||
permissions.read_only.dedup();
|
||||
|
||||
Ok(AccessMapResult {
|
||||
cloud: "algolia".into(),
|
||||
identity: AccessSummary {
|
||||
id: format!("{app_id}:{}", &api_key[..api_key.len().min(8)]),
|
||||
access_type: "api_key".into(),
|
||||
project: Some(app_id.to_string()),
|
||||
tenant: None,
|
||||
account_id: Some(app_id.to_string()),
|
||||
},
|
||||
roles,
|
||||
permissions,
|
||||
resources,
|
||||
severity,
|
||||
recommendations: build_recommendations(severity),
|
||||
risk_notes,
|
||||
token_details: Some(AccessTokenDetails {
|
||||
name: if key_info.description.is_empty() { None } else { Some(key_info.description) },
|
||||
token_type: Some("api_key".into()),
|
||||
scopes: key_info.acl,
|
||||
..Default::default()
|
||||
}),
|
||||
provider_metadata: None,
|
||||
fingerprint: None,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn fetch_key_info(
|
||||
client: &Client,
|
||||
base: &str,
|
||||
app_id: &str,
|
||||
api_key: &str,
|
||||
) -> Result<AlgoliaKeyInfo> {
|
||||
let resp = client
|
||||
.get(format!("{base}/1/keys/{api_key}"))
|
||||
.header("X-Algolia-Application-Id", app_id)
|
||||
.header("X-Algolia-API-Key", api_key)
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await
|
||||
.context("Algolia access-map: failed to query key info")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(anyhow!(
|
||||
"Algolia access-map: key info endpoint returned HTTP {}",
|
||||
resp.status()
|
||||
));
|
||||
}
|
||||
|
||||
resp.json::<AlgoliaKeyInfo>().await.context("Algolia access-map: invalid key info JSON")
|
||||
}
|
||||
|
||||
async fn fetch_indexes(
|
||||
client: &Client,
|
||||
base: &str,
|
||||
app_id: &str,
|
||||
api_key: &str,
|
||||
) -> Result<Vec<String>> {
|
||||
let resp = client
|
||||
.get(format!("{base}/1/indexes"))
|
||||
.header("X-Algolia-Application-Id", app_id)
|
||||
.header("X-Algolia-API-Key", api_key)
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await
|
||||
.context("Algolia access-map: failed to list indexes")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(anyhow!("Algolia access-map: index listing returned HTTP {}", resp.status()));
|
||||
}
|
||||
|
||||
let body: AlgoliaIndexList =
|
||||
resp.json().await.context("Algolia access-map: invalid index list JSON")?;
|
||||
|
||||
Ok(body.items.into_iter().map(|i| i.name).filter(|n| !n.is_empty()).collect())
|
||||
}
|
||||
|
||||
fn severity_to_str(severity: Severity) -> &'static str {
|
||||
match severity {
|
||||
Severity::Low => "low",
|
||||
Severity::Medium => "medium",
|
||||
Severity::High => "high",
|
||||
Severity::Critical => "critical",
|
||||
}
|
||||
}
|
||||
351
src/access_map/artifactory.rs
Normal file
351
src/access_map/artifactory.rs
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
use anyhow::{anyhow, Context, Result};
|
||||
use reqwest::{header, Client};
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::{cli::commands::access_map::AccessMapArgs, validation::GLOBAL_USER_AGENT};
|
||||
|
||||
use super::{
|
||||
build_recommendations, AccessMapResult, AccessSummary, AccessTokenDetails, PermissionSummary,
|
||||
ResourceExposure, RoleBinding, Severity,
|
||||
};
|
||||
|
||||
const MAX_REPO_RESOURCES: usize = 100;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ArtifactoryUser {
|
||||
name: Option<String>,
|
||||
email: Option<String>,
|
||||
admin: Option<bool>,
|
||||
#[allow(dead_code)]
|
||||
#[serde(rename = "profileUpdatable")]
|
||||
profile_updatable: Option<bool>,
|
||||
#[serde(default)]
|
||||
groups: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ArtifactoryRepo {
|
||||
key: Option<String>,
|
||||
#[serde(rename = "type")]
|
||||
repo_type: Option<String>,
|
||||
#[serde(rename = "packageType")]
|
||||
package_type: Option<String>,
|
||||
}
|
||||
|
||||
/// Entry point when invoked via the CLI `access-map jfrog-art` subcommand.
|
||||
pub async fn map_access(args: &AccessMapArgs) -> Result<AccessMapResult> {
|
||||
let path = args.credential_path.as_deref().ok_or_else(|| {
|
||||
anyhow!("Artifactory access-map requires a credential file with token (and optionally base_url)")
|
||||
})?;
|
||||
let raw = std::fs::read_to_string(path).with_context(|| {
|
||||
format!("Failed to read Artifactory credential file from {}", path.display())
|
||||
})?;
|
||||
let (token, base_url) = parse_artifactory_credentials(&raw)?;
|
||||
match base_url {
|
||||
Some(url) => map_access_from_token_and_url(&token, &url).await,
|
||||
None => map_access_from_token(&token).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps an Artifactory token without a known base URL.
|
||||
/// Attempts common JFrog cloud URL patterns.
|
||||
pub async fn map_access_from_token(token: &str) -> Result<AccessMapResult> {
|
||||
// Without a base URL we cannot discover the instance.
|
||||
// Build a minimal result indicating the token is valid but instance unknown.
|
||||
let client = Client::builder()
|
||||
.user_agent(GLOBAL_USER_AGENT.as_str())
|
||||
.build()
|
||||
.context("Failed to build Artifactory HTTP client")?;
|
||||
|
||||
// Try the JFrog cloud ping endpoint as a basic validation
|
||||
let ping_ok = ping_artifactory(&client, token, "https://access.jfrog.io").await;
|
||||
|
||||
let mut risk_notes = Vec::new();
|
||||
let mut permissions = PermissionSummary::default();
|
||||
|
||||
if ping_ok {
|
||||
permissions.read_only.push("system:ping".to_string());
|
||||
risk_notes.push(
|
||||
"Token responded to JFrog cloud ping; base_url unknown so full mapping not possible"
|
||||
.to_string(),
|
||||
);
|
||||
} else {
|
||||
risk_notes.push(
|
||||
"Token did not respond to JFrog cloud ping; provide base_url for full mapping"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let severity = Severity::Medium;
|
||||
Ok(AccessMapResult {
|
||||
cloud: "artifactory".into(),
|
||||
identity: AccessSummary {
|
||||
id: "unknown_artifactory_user".into(),
|
||||
access_type: "token".into(),
|
||||
project: None,
|
||||
tenant: None,
|
||||
account_id: None,
|
||||
},
|
||||
roles: Vec::new(),
|
||||
permissions,
|
||||
resources: Vec::new(),
|
||||
severity,
|
||||
recommendations: build_recommendations(severity),
|
||||
risk_notes,
|
||||
token_details: Some(AccessTokenDetails {
|
||||
token_type: Some("bearer_token".into()),
|
||||
..Default::default()
|
||||
}),
|
||||
provider_metadata: None,
|
||||
fingerprint: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Maps an Artifactory token with a known base URL to an access profile.
|
||||
pub async fn map_access_from_token_and_url(token: &str, base_url: &str) -> Result<AccessMapResult> {
|
||||
let base_url = base_url.trim().trim_end_matches('/');
|
||||
if base_url.is_empty() {
|
||||
return Err(anyhow!("Artifactory access-map requires a non-empty base URL"));
|
||||
}
|
||||
|
||||
let client = Client::builder()
|
||||
.user_agent(GLOBAL_USER_AGENT.as_str())
|
||||
.build()
|
||||
.context("Failed to build Artifactory HTTP client")?;
|
||||
|
||||
let mut risk_notes = Vec::new();
|
||||
let mut permissions = PermissionSummary::default();
|
||||
|
||||
// Fetch current user info
|
||||
let user = fetch_current_user(&client, token, base_url).await?;
|
||||
|
||||
let username = user.name.clone().unwrap_or_default();
|
||||
let user_email = user.email.clone();
|
||||
let is_admin = user.admin.unwrap_or(false);
|
||||
let groups = user.groups.clone();
|
||||
|
||||
let has_deployer = groups.iter().any(|g| g.to_lowercase().contains("deploy"));
|
||||
|
||||
// Classify
|
||||
if is_admin {
|
||||
permissions.admin.push("artifactory:admin".to_string());
|
||||
permissions.admin.push("repositories:manage".to_string());
|
||||
permissions.admin.push("security:manage".to_string());
|
||||
permissions.admin.push("system:configure".to_string());
|
||||
risk_notes.push("Admin flag is set - full Artifactory control".to_string());
|
||||
}
|
||||
|
||||
if has_deployer {
|
||||
permissions.risky.push("artifacts:deploy".to_string());
|
||||
permissions.risky.push("artifacts:delete".to_string());
|
||||
risk_notes.push("User is in a deployer group - supply chain risk".to_string());
|
||||
}
|
||||
|
||||
for group in &groups {
|
||||
permissions.read_only.push(format!("group:{group}"));
|
||||
}
|
||||
|
||||
// Fetch repositories
|
||||
let repos = fetch_repositories(&client, token, base_url).await.unwrap_or_else(|err| {
|
||||
warn!("Artifactory access-map: repository listing failed: {err}");
|
||||
risk_notes.push(format!("Repository listing failed: {err}"));
|
||||
Vec::new()
|
||||
});
|
||||
|
||||
if !repos.is_empty() {
|
||||
permissions.read_only.push("repositories:list".to_string());
|
||||
}
|
||||
|
||||
permissions.admin.sort();
|
||||
permissions.admin.dedup();
|
||||
permissions.risky.sort();
|
||||
permissions.risky.dedup();
|
||||
permissions.read_only.sort();
|
||||
permissions.read_only.dedup();
|
||||
|
||||
let severity = if is_admin {
|
||||
Severity::Critical
|
||||
} else if has_deployer {
|
||||
Severity::High
|
||||
} else {
|
||||
Severity::Medium
|
||||
};
|
||||
|
||||
let roles = vec![RoleBinding {
|
||||
name: if is_admin { "artifactory:admin".into() } else { "artifactory:user".into() },
|
||||
source: "artifactory".into(),
|
||||
permissions: groups.iter().map(|g| format!("group:{g}")).collect(),
|
||||
}];
|
||||
|
||||
let mut resources = vec![ResourceExposure {
|
||||
resource_type: "artifactory_instance".into(),
|
||||
name: base_url.to_string(),
|
||||
permissions: if is_admin { vec!["admin".into()] } else { vec!["authenticated".into()] },
|
||||
risk: severity_to_str(severity).to_string(),
|
||||
reason: "Artifactory instance accessible with this token".to_string(),
|
||||
}];
|
||||
|
||||
for repo in repos.iter().take(MAX_REPO_RESOURCES) {
|
||||
let repo_key = repo.key.clone().unwrap_or_default();
|
||||
let repo_type = repo.repo_type.clone().unwrap_or_default();
|
||||
let pkg_type = repo.package_type.clone().unwrap_or_default();
|
||||
|
||||
let repo_risk = if has_deployer || is_admin { Severity::High } else { Severity::Low };
|
||||
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: format!("repository:{repo_type}"),
|
||||
name: repo_key,
|
||||
permissions: vec![format!("package_type:{pkg_type}")],
|
||||
risk: severity_to_str(repo_risk).to_string(),
|
||||
reason: if has_deployer || is_admin {
|
||||
"Repository with deploy/admin access - supply chain risk".to_string()
|
||||
} else {
|
||||
"Repository visible to this token".to_string()
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if repos.len() > MAX_REPO_RESOURCES {
|
||||
risk_notes.push(format!(
|
||||
"Repository list truncated to first {MAX_REPO_RESOURCES} entries ({} total)",
|
||||
repos.len()
|
||||
));
|
||||
}
|
||||
|
||||
let identity_id = user_email.clone().unwrap_or_else(|| username.clone());
|
||||
|
||||
Ok(AccessMapResult {
|
||||
cloud: "artifactory".into(),
|
||||
identity: AccessSummary {
|
||||
id: if identity_id.is_empty() { "artifactory_token".to_string() } else { identity_id },
|
||||
access_type: if is_admin { "admin".into() } else { "user".into() },
|
||||
project: None,
|
||||
tenant: None,
|
||||
account_id: None,
|
||||
},
|
||||
roles,
|
||||
permissions,
|
||||
resources,
|
||||
severity,
|
||||
recommendations: build_recommendations(severity),
|
||||
risk_notes,
|
||||
token_details: Some(AccessTokenDetails {
|
||||
name: Some(username),
|
||||
username: None,
|
||||
account_type: Some(if is_admin { "admin".into() } else { "user".into() }),
|
||||
company: None,
|
||||
location: None,
|
||||
email: user_email,
|
||||
url: Some(base_url.to_string()),
|
||||
token_type: Some("bearer_token".into()),
|
||||
created_at: None,
|
||||
last_used_at: None,
|
||||
expires_at: None,
|
||||
user_id: None,
|
||||
scopes: groups,
|
||||
}),
|
||||
provider_metadata: None,
|
||||
fingerprint: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_artifactory_credentials(raw: &str) -> Result<(String, Option<String>)> {
|
||||
if let Ok(json) = serde_json::from_str::<Value>(raw) {
|
||||
let token = json
|
||||
.get("token")
|
||||
.or_else(|| json.get("access_token"))
|
||||
.or_else(|| json.get("api_key"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim().to_string());
|
||||
let base_url = json
|
||||
.get("base_url")
|
||||
.or_else(|| json.get("url"))
|
||||
.or_else(|| json.get("instance_url"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim().to_string());
|
||||
|
||||
if let Some(token) = token {
|
||||
return Ok((token, base_url));
|
||||
}
|
||||
}
|
||||
|
||||
let lines: Vec<&str> = raw
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty() && !line.starts_with('#'))
|
||||
.collect();
|
||||
match lines.len() {
|
||||
1 => Ok((lines[0].to_string(), None)),
|
||||
n if n >= 2 => Ok((lines[0].to_string(), Some(lines[1].to_string()))),
|
||||
_ => Err(anyhow!(
|
||||
"Artifactory credential format not recognized. Provide JSON with token (+ optional base_url), or lines."
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn ping_artifactory(client: &Client, token: &str, base_url: &str) -> bool {
|
||||
let resp = client
|
||||
.get(format!("{base_url}/artifactory/api/system/ping"))
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
matches!(resp, Ok(r) if r.status().is_success())
|
||||
}
|
||||
|
||||
async fn fetch_current_user(
|
||||
client: &Client,
|
||||
token: &str,
|
||||
base_url: &str,
|
||||
) -> Result<ArtifactoryUser> {
|
||||
let resp = client
|
||||
.get(format!("{base_url}/artifactory/api/security/current"))
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await
|
||||
.context("Artifactory access-map: failed to query security/current endpoint")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(anyhow!(
|
||||
"Artifactory access-map: security/current endpoint returned HTTP {}",
|
||||
resp.status()
|
||||
));
|
||||
}
|
||||
|
||||
resp.json().await.context("Artifactory access-map: invalid security/current JSON")
|
||||
}
|
||||
|
||||
async fn fetch_repositories(
|
||||
client: &Client,
|
||||
token: &str,
|
||||
base_url: &str,
|
||||
) -> Result<Vec<ArtifactoryRepo>> {
|
||||
let resp = client
|
||||
.get(format!("{base_url}/artifactory/api/repositories"))
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await
|
||||
.context("Artifactory access-map: failed to query repositories endpoint")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(anyhow!(
|
||||
"Artifactory access-map: repositories endpoint returned HTTP {}",
|
||||
resp.status()
|
||||
));
|
||||
}
|
||||
|
||||
resp.json().await.context("Artifactory access-map: invalid repositories JSON")
|
||||
}
|
||||
|
||||
fn severity_to_str(severity: Severity) -> &'static str {
|
||||
match severity {
|
||||
Severity::Low => "low",
|
||||
Severity::Medium => "medium",
|
||||
Severity::High => "high",
|
||||
Severity::Critical => "critical",
|
||||
}
|
||||
}
|
||||
341
src/access_map/auth0.rs
Normal file
341
src/access_map/auth0.rs
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
use anyhow::{anyhow, Context, Result};
|
||||
use reqwest::{header, Client};
|
||||
use serde::Deserialize;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::cli::commands::access_map::AccessMapArgs;
|
||||
use crate::validation::GLOBAL_USER_AGENT;
|
||||
|
||||
use super::{
|
||||
build_recommendations, AccessMapResult, AccessSummary, AccessTokenDetails, PermissionSummary,
|
||||
ResourceExposure, RoleBinding, Severity,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API response types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TokenResponse {
|
||||
access_token: String,
|
||||
#[serde(default)]
|
||||
token_type: String,
|
||||
#[serde(default)]
|
||||
scope: String,
|
||||
#[serde(default)]
|
||||
expires_in: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Auth0Client {
|
||||
#[serde(default)]
|
||||
client_id: String,
|
||||
#[serde(default)]
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
app_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Auth0ResourceServer {
|
||||
#[serde(default)]
|
||||
identifier: String,
|
||||
#[serde(default)]
|
||||
name: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scope classification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn classify_scope(scope: &str) -> ScopeCategory {
|
||||
if scope.starts_with("create:") || scope.starts_with("delete:") {
|
||||
return ScopeCategory::Admin;
|
||||
}
|
||||
if scope == "update:users" || scope == "update:clients" {
|
||||
return ScopeCategory::Admin;
|
||||
}
|
||||
if scope.starts_with("read:users")
|
||||
|| scope.starts_with("read:clients")
|
||||
|| scope.starts_with("update:")
|
||||
{
|
||||
return ScopeCategory::Risky;
|
||||
}
|
||||
if scope.starts_with("read:stats") || scope.starts_with("read:logs") {
|
||||
return ScopeCategory::Read;
|
||||
}
|
||||
// Default: treat unknown read: as read, unknown others as risky
|
||||
if scope.starts_with("read:") {
|
||||
ScopeCategory::Read
|
||||
} else {
|
||||
ScopeCategory::Risky
|
||||
}
|
||||
}
|
||||
|
||||
enum ScopeCategory {
|
||||
Admin,
|
||||
Risky,
|
||||
Read,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public entry points
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn map_access(args: &AccessMapArgs) -> Result<AccessMapResult> {
|
||||
let path = args.credential_path.as_deref().ok_or_else(|| {
|
||||
anyhow!(
|
||||
"Auth0 access-map requires a credential file with client_id, client_secret, and domain"
|
||||
)
|
||||
})?;
|
||||
let raw = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("Failed to read Auth0 credential file from {}", path.display()))?;
|
||||
let json: serde_json::Value = serde_json::from_str(&raw).context(
|
||||
"Auth0 credential file must be valid JSON with client_id, client_secret, and domain",
|
||||
)?;
|
||||
|
||||
let client_id = json
|
||||
.get("client_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow!("Auth0 credential JSON missing 'client_id'"))?;
|
||||
let client_secret = json
|
||||
.get("client_secret")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow!("Auth0 credential JSON missing 'client_secret'"))?;
|
||||
let domain = json
|
||||
.get("domain")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow!("Auth0 credential JSON missing 'domain'"))?;
|
||||
|
||||
map_access_from_credentials(client_id, client_secret, domain).await
|
||||
}
|
||||
|
||||
pub async fn map_access_from_credentials(
|
||||
client_id: &str,
|
||||
client_secret: &str,
|
||||
domain: &str,
|
||||
) -> Result<AccessMapResult> {
|
||||
let client = Client::builder()
|
||||
.user_agent(GLOBAL_USER_AGENT.as_str())
|
||||
.build()
|
||||
.context("Failed to build Auth0 HTTP client")?;
|
||||
|
||||
let domain = normalize_domain(domain);
|
||||
let mut risk_notes: Vec<String> = Vec::new();
|
||||
let mut permissions = PermissionSummary::default();
|
||||
|
||||
// Step 1: exchange credentials for management API token
|
||||
let token_resp = fetch_token(&client, client_id, client_secret, &domain).await?;
|
||||
let scopes: Vec<String> =
|
||||
token_resp.scope.split_whitespace().map(String::from).filter(|s| !s.is_empty()).collect();
|
||||
|
||||
// Classify scopes
|
||||
for scope in &scopes {
|
||||
match classify_scope(scope) {
|
||||
ScopeCategory::Admin => permissions.admin.push(scope.clone()),
|
||||
ScopeCategory::Risky => permissions.risky.push(scope.clone()),
|
||||
ScopeCategory::Read => permissions.read_only.push(scope.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: list clients
|
||||
let clients =
|
||||
fetch_clients(&client, &token_resp.access_token, &domain).await.unwrap_or_else(|err| {
|
||||
warn!("Auth0 access-map: client listing failed: {err}");
|
||||
risk_notes.push(format!("Client listing failed: {err}"));
|
||||
Vec::new()
|
||||
});
|
||||
|
||||
// Step 3: list resource servers
|
||||
let resource_servers = fetch_resource_servers(&client, &token_resp.access_token, &domain)
|
||||
.await
|
||||
.unwrap_or_else(|err| {
|
||||
warn!("Auth0 access-map: resource server listing failed: {err}");
|
||||
risk_notes.push(format!("Resource server listing failed: {err}"));
|
||||
Vec::new()
|
||||
});
|
||||
|
||||
// Determine severity
|
||||
let has_admin = !permissions.admin.is_empty();
|
||||
let has_risky = !permissions.risky.is_empty();
|
||||
let severity = if has_admin {
|
||||
Severity::Critical
|
||||
} else if has_risky {
|
||||
Severity::High
|
||||
} else {
|
||||
Severity::Medium
|
||||
};
|
||||
|
||||
if !clients.is_empty() {
|
||||
risk_notes.push(format!("Management API can enumerate {} client(s)", clients.len()));
|
||||
}
|
||||
|
||||
let roles = vec![RoleBinding {
|
||||
name: "auth0_client_credentials".into(),
|
||||
source: "auth0".into(),
|
||||
permissions: scopes.clone(),
|
||||
}];
|
||||
|
||||
let mut resources = vec![ResourceExposure {
|
||||
resource_type: "auth0_tenant".into(),
|
||||
name: domain.clone(),
|
||||
permissions: scopes.clone(),
|
||||
risk: severity_to_str(severity).to_string(),
|
||||
reason: "Auth0 tenant reachable with these client credentials".to_string(),
|
||||
}];
|
||||
|
||||
for c in &clients {
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: "auth0_client".into(),
|
||||
name: if c.name.is_empty() { c.client_id.clone() } else { c.name.clone() },
|
||||
permissions: vec![format!("app_type:{}", c.app_type)],
|
||||
risk: severity_to_str(Severity::Medium).to_string(),
|
||||
reason: "Auth0 client application visible to management API".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
for rs in &resource_servers {
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: "auth0_resource_server".into(),
|
||||
name: if rs.name.is_empty() { rs.identifier.clone() } else { rs.name.clone() },
|
||||
permissions: vec!["resource_server:visible".into()],
|
||||
risk: severity_to_str(Severity::Medium).to_string(),
|
||||
reason: "Auth0 resource server (API) visible to management API".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
permissions.admin.sort();
|
||||
permissions.admin.dedup();
|
||||
permissions.risky.sort();
|
||||
permissions.risky.dedup();
|
||||
permissions.read_only.sort();
|
||||
permissions.read_only.dedup();
|
||||
|
||||
Ok(AccessMapResult {
|
||||
cloud: "auth0".into(),
|
||||
identity: AccessSummary {
|
||||
id: format!("{client_id}@{domain}"),
|
||||
access_type: "client_credentials".into(),
|
||||
project: None,
|
||||
tenant: Some(domain.clone()),
|
||||
account_id: None,
|
||||
},
|
||||
roles,
|
||||
permissions,
|
||||
resources,
|
||||
severity,
|
||||
recommendations: build_recommendations(severity),
|
||||
risk_notes,
|
||||
token_details: Some(AccessTokenDetails {
|
||||
name: Some(client_id.to_string()),
|
||||
token_type: Some(token_resp.token_type),
|
||||
scopes,
|
||||
expires_at: if token_resp.expires_in > 0 {
|
||||
Some(format!("{}s from issuance", token_resp.expires_in))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
..Default::default()
|
||||
}),
|
||||
provider_metadata: None,
|
||||
fingerprint: None,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn normalize_domain(domain: &str) -> String {
|
||||
let mut d = domain.trim().trim_matches('/').to_string();
|
||||
if d.starts_with("https://") {
|
||||
d = d.trim_start_matches("https://").to_string();
|
||||
} else if d.starts_with("http://") {
|
||||
d = d.trim_start_matches("http://").to_string();
|
||||
}
|
||||
d = d.split('/').next().unwrap_or_default().to_string();
|
||||
d
|
||||
}
|
||||
|
||||
async fn fetch_token(
|
||||
client: &Client,
|
||||
client_id: &str,
|
||||
client_secret: &str,
|
||||
domain: &str,
|
||||
) -> Result<TokenResponse> {
|
||||
let body = serde_json::json!({
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"audience": format!("https://{domain}/api/v2/"),
|
||||
"grant_type": "client_credentials",
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post(format!("https://{domain}/oauth/token"))
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.context("Auth0 access-map: failed to exchange client credentials")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(anyhow!("Auth0 access-map: token exchange failed with HTTP {}", resp.status()));
|
||||
}
|
||||
|
||||
resp.json::<TokenResponse>().await.context("Auth0 access-map: invalid token response JSON")
|
||||
}
|
||||
|
||||
async fn fetch_clients(
|
||||
client: &Client,
|
||||
access_token: &str,
|
||||
domain: &str,
|
||||
) -> Result<Vec<Auth0Client>> {
|
||||
let resp = client
|
||||
.get(format!("https://{domain}/api/v2/clients"))
|
||||
.header(header::AUTHORIZATION, format!("Bearer {access_token}"))
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await
|
||||
.context("Auth0 access-map: failed to list clients")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(anyhow!("Auth0 access-map: client listing returned HTTP {}", resp.status()));
|
||||
}
|
||||
|
||||
resp.json::<Vec<Auth0Client>>().await.context("Auth0 access-map: invalid client list JSON")
|
||||
}
|
||||
|
||||
async fn fetch_resource_servers(
|
||||
client: &Client,
|
||||
access_token: &str,
|
||||
domain: &str,
|
||||
) -> Result<Vec<Auth0ResourceServer>> {
|
||||
let resp = client
|
||||
.get(format!("https://{domain}/api/v2/resource-servers"))
|
||||
.header(header::AUTHORIZATION, format!("Bearer {access_token}"))
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await
|
||||
.context("Auth0 access-map: failed to list resource servers")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(anyhow!(
|
||||
"Auth0 access-map: resource server listing returned HTTP {}",
|
||||
resp.status()
|
||||
));
|
||||
}
|
||||
|
||||
resp.json::<Vec<Auth0ResourceServer>>()
|
||||
.await
|
||||
.context("Auth0 access-map: invalid resource server list JSON")
|
||||
}
|
||||
|
||||
fn severity_to_str(severity: Severity) -> &'static str {
|
||||
match severity {
|
||||
Severity::Low => "low",
|
||||
Severity::Medium => "medium",
|
||||
Severity::High => "high",
|
||||
Severity::Critical => "critical",
|
||||
}
|
||||
}
|
||||
272
src/access_map/circleci.rs
Normal file
272
src/access_map/circleci.rs
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
use anyhow::{anyhow, Context, Result};
|
||||
use reqwest::{header, Client};
|
||||
use serde::Deserialize;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::{cli::commands::access_map::AccessMapArgs, validation::GLOBAL_USER_AGENT};
|
||||
|
||||
use super::{
|
||||
build_recommendations, AccessMapResult, AccessSummary, AccessTokenDetails, PermissionSummary,
|
||||
ResourceExposure, RoleBinding, Severity,
|
||||
};
|
||||
|
||||
const CIRCLECI_API: &str = "https://circleci.com";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CircleCiUser {
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
#[serde(default)]
|
||||
login: Option<String>,
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
email: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CircleCiCollaboration {
|
||||
#[serde(default)]
|
||||
vcs_type: Option<String>,
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
slug: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CircleCiPipelineResponse {
|
||||
#[serde(default)]
|
||||
items: Vec<CircleCiPipeline>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CircleCiPipeline {
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
#[serde(default)]
|
||||
project_slug: Option<String>,
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
created_at: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn map_access(args: &AccessMapArgs) -> Result<AccessMapResult> {
|
||||
let token = if let Some(path) = args.credential_path.as_deref() {
|
||||
let raw = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("Failed to read CircleCI token from {}", path.display()))?;
|
||||
raw.trim().to_string()
|
||||
} else {
|
||||
return Err(anyhow!("CircleCI 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 CircleCI HTTP client")?;
|
||||
|
||||
let user = fetch_user(&client, token).await?;
|
||||
|
||||
let username = user
|
||||
.login
|
||||
.clone()
|
||||
.or_else(|| user.name.clone())
|
||||
.or_else(|| user.email.clone())
|
||||
.unwrap_or_else(|| "circleci_user".to_string());
|
||||
|
||||
let identity = AccessSummary {
|
||||
id: username.clone(),
|
||||
access_type: "user".into(),
|
||||
project: None,
|
||||
tenant: None,
|
||||
account_id: user.id.clone(),
|
||||
};
|
||||
|
||||
let mut risk_notes = Vec::new();
|
||||
let mut resources = Vec::new();
|
||||
let mut permissions = PermissionSummary::default();
|
||||
let mut roles = Vec::new();
|
||||
|
||||
let collaborations = list_collaborations(&client, token).await.unwrap_or_else(|err| {
|
||||
warn!("CircleCI access-map: collaboration enumeration failed: {err}");
|
||||
Vec::new()
|
||||
});
|
||||
|
||||
for collab in &collaborations {
|
||||
let org_name = collab
|
||||
.slug
|
||||
.clone()
|
||||
.or_else(|| collab.name.clone())
|
||||
.unwrap_or_else(|| "unknown_org".to_string());
|
||||
|
||||
let vcs = collab.vcs_type.as_deref().unwrap_or("unknown");
|
||||
|
||||
let role = RoleBinding {
|
||||
name: format!("collaborator:{org_name}"),
|
||||
source: "circleci".into(),
|
||||
permissions: vec![format!("vcs:{vcs}"), "collaboration:member".to_string()],
|
||||
};
|
||||
roles.push(role);
|
||||
|
||||
permissions.risky.push(format!("collaboration:{org_name}"));
|
||||
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: "organization".into(),
|
||||
name: org_name.clone(),
|
||||
permissions: vec![format!("vcs:{vcs}"), "collaboration:member".to_string()],
|
||||
risk: severity_to_str(if collaborations.len() > 3 {
|
||||
Severity::High
|
||||
} else {
|
||||
Severity::Medium
|
||||
})
|
||||
.to_string(),
|
||||
reason: format!("CircleCI organization collaboration via {vcs}"),
|
||||
});
|
||||
}
|
||||
|
||||
let pipelines = list_pipelines(&client, token).await.unwrap_or_else(|err| {
|
||||
warn!("CircleCI access-map: pipeline enumeration failed: {err}");
|
||||
Vec::new()
|
||||
});
|
||||
|
||||
for pipeline in &pipelines {
|
||||
let pipeline_name = pipeline
|
||||
.project_slug
|
||||
.clone()
|
||||
.or_else(|| pipeline.id.clone())
|
||||
.unwrap_or_else(|| "unknown_pipeline".to_string());
|
||||
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: "pipeline".into(),
|
||||
name: pipeline_name.clone(),
|
||||
permissions: vec!["pipeline:read".to_string()],
|
||||
risk: severity_to_str(Severity::Low).to_string(),
|
||||
reason: "Recent pipeline accessible with this token".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
permissions.admin.sort();
|
||||
permissions.admin.dedup();
|
||||
permissions.risky.sort();
|
||||
permissions.risky.dedup();
|
||||
permissions.read_only.sort();
|
||||
permissions.read_only.dedup();
|
||||
|
||||
let severity = derive_severity(&collaborations);
|
||||
|
||||
if collaborations.is_empty() && pipelines.is_empty() {
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: "account".into(),
|
||||
name: username.clone(),
|
||||
permissions: Vec::new(),
|
||||
risk: severity_to_str(Severity::Low).to_string(),
|
||||
reason: "CircleCI account associated with the token".into(),
|
||||
});
|
||||
risk_notes.push("Token did not enumerate any collaborations or pipelines".into());
|
||||
}
|
||||
|
||||
Ok(AccessMapResult {
|
||||
cloud: "circleci".into(),
|
||||
identity,
|
||||
roles,
|
||||
permissions,
|
||||
resources,
|
||||
severity,
|
||||
recommendations: build_recommendations(severity),
|
||||
risk_notes,
|
||||
token_details: Some(AccessTokenDetails {
|
||||
name: user.name.clone(),
|
||||
username: user.login.clone(),
|
||||
account_type: None,
|
||||
company: None,
|
||||
location: None,
|
||||
email: user.email.clone(),
|
||||
url: None,
|
||||
token_type: None,
|
||||
created_at: None,
|
||||
last_used_at: None,
|
||||
expires_at: None,
|
||||
user_id: user.id,
|
||||
scopes: Vec::new(),
|
||||
}),
|
||||
provider_metadata: None,
|
||||
fingerprint: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch_user(client: &Client, token: &str) -> Result<CircleCiUser> {
|
||||
let resp = client
|
||||
.get(format!("{CIRCLECI_API}/api/v2/me"))
|
||||
.header("Circle-Token", token)
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await
|
||||
.context("CircleCI access-map: failed to fetch user info")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(anyhow!("CircleCI access-map: user lookup failed with HTTP {}", resp.status()));
|
||||
}
|
||||
|
||||
resp.json().await.context("CircleCI access-map: invalid user JSON")
|
||||
}
|
||||
|
||||
async fn list_collaborations(client: &Client, token: &str) -> Result<Vec<CircleCiCollaboration>> {
|
||||
let resp = client
|
||||
.get(format!("{CIRCLECI_API}/api/v2/me/collaborations"))
|
||||
.header("Circle-Token", token)
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await
|
||||
.context("CircleCI access-map: failed to list collaborations")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
warn!("CircleCI access-map: collaboration enumeration failed with HTTP {}", resp.status());
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
resp.json().await.context("CircleCI access-map: invalid collaborations JSON")
|
||||
}
|
||||
|
||||
async fn list_pipelines(client: &Client, token: &str) -> Result<Vec<CircleCiPipeline>> {
|
||||
let resp = client
|
||||
.get(format!("{CIRCLECI_API}/api/v2/pipeline?mine=true"))
|
||||
.header("Circle-Token", token)
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await
|
||||
.context("CircleCI access-map: failed to list pipelines")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
warn!("CircleCI access-map: pipeline enumeration failed with HTTP {}", resp.status());
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let body: CircleCiPipelineResponse =
|
||||
resp.json().await.context("CircleCI access-map: invalid pipelines JSON")?;
|
||||
Ok(body.items)
|
||||
}
|
||||
|
||||
fn derive_severity(collaborations: &[CircleCiCollaboration]) -> Severity {
|
||||
if collaborations.len() > 5 {
|
||||
return Severity::High;
|
||||
}
|
||||
|
||||
if !collaborations.is_empty() {
|
||||
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",
|
||||
}
|
||||
}
|
||||
473
src/access_map/digitalocean.rs
Normal file
473
src/access_map/digitalocean.rs
Normal file
|
|
@ -0,0 +1,473 @@
|
|||
use anyhow::{anyhow, Context, Result};
|
||||
use reqwest::{header, Client};
|
||||
use serde::Deserialize;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::{cli::commands::access_map::AccessMapArgs, validation::GLOBAL_USER_AGENT};
|
||||
|
||||
use super::{
|
||||
build_recommendations, AccessMapResult, AccessSummary, AccessTokenDetails, PermissionSummary,
|
||||
ResourceExposure, RoleBinding, Severity,
|
||||
};
|
||||
|
||||
const DIGITALOCEAN_API: &str = "https://api.digitalocean.com";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AccountResponse {
|
||||
#[serde(default)]
|
||||
account: Option<DigitalOceanAccount>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DigitalOceanAccount {
|
||||
#[serde(default)]
|
||||
email: Option<String>,
|
||||
#[serde(default)]
|
||||
uuid: Option<String>,
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
status: Option<String>,
|
||||
#[serde(default)]
|
||||
team: Option<DigitalOceanTeam>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DigitalOceanTeam {
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
uuid: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ProjectsResponse {
|
||||
#[serde(default)]
|
||||
projects: Vec<DigitalOceanProject>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DigitalOceanProject {
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
is_default: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DropletsResponse {
|
||||
#[serde(default)]
|
||||
droplets: Vec<DigitalOceanDroplet>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DigitalOceanDroplet {
|
||||
#[serde(default)]
|
||||
id: Option<u64>,
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
status: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DatabasesResponse {
|
||||
#[serde(default)]
|
||||
databases: Vec<DigitalOceanDatabase>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DigitalOceanDatabase {
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
engine: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct KubernetesClustersResponse {
|
||||
#[serde(default)]
|
||||
kubernetes_clusters: Vec<DigitalOceanKubernetesCluster>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DigitalOceanKubernetesCluster {
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
region: 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 DigitalOcean token from {}", path.display())
|
||||
})?;
|
||||
raw.trim().to_string()
|
||||
} else {
|
||||
return Err(anyhow!(
|
||||
"DigitalOcean 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 DigitalOcean HTTP client")?;
|
||||
|
||||
let account = fetch_account(&client, token).await?;
|
||||
|
||||
let username = account
|
||||
.email
|
||||
.clone()
|
||||
.unwrap_or_else(|| account.uuid.clone().unwrap_or_else(|| "do_user".to_string()));
|
||||
|
||||
let team_name = account.team.as_ref().and_then(|t| t.name.clone());
|
||||
|
||||
let identity = AccessSummary {
|
||||
id: username.clone(),
|
||||
access_type: "user".into(),
|
||||
project: None,
|
||||
tenant: team_name.clone(),
|
||||
account_id: account.uuid.clone(),
|
||||
};
|
||||
|
||||
let mut risk_notes = Vec::new();
|
||||
let mut resources = Vec::new();
|
||||
let mut permissions = PermissionSummary::default();
|
||||
let mut roles = Vec::new();
|
||||
let mut has_droplets = false;
|
||||
let mut has_databases = false;
|
||||
let mut has_kubernetes = false;
|
||||
|
||||
// Enumerate projects
|
||||
let projects = list_projects(&client, token).await.unwrap_or_else(|err| {
|
||||
warn!("DigitalOcean access-map: project enumeration failed: {err}");
|
||||
Vec::new()
|
||||
});
|
||||
|
||||
for project in &projects {
|
||||
let project_name = project
|
||||
.name
|
||||
.clone()
|
||||
.or_else(|| project.id.clone())
|
||||
.unwrap_or_else(|| "unknown_project".to_string());
|
||||
|
||||
let is_default = project.is_default.unwrap_or(false);
|
||||
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: "project".into(),
|
||||
name: project_name.clone(),
|
||||
permissions: vec!["project:read".to_string()],
|
||||
risk: severity_to_str(Severity::Low).to_string(),
|
||||
reason: if is_default {
|
||||
"Default DigitalOcean project".to_string()
|
||||
} else {
|
||||
"DigitalOcean project accessible with this token".to_string()
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
permissions.read_only.push("project:list".to_string());
|
||||
|
||||
// Enumerate droplets
|
||||
let droplets = list_droplets(&client, token).await.unwrap_or_else(|err| {
|
||||
warn!("DigitalOcean access-map: droplet enumeration failed: {err}");
|
||||
Vec::new()
|
||||
});
|
||||
|
||||
if !droplets.is_empty() {
|
||||
has_droplets = true;
|
||||
permissions.risky.push("droplet:list".to_string());
|
||||
|
||||
let role = RoleBinding {
|
||||
name: "droplet:access".into(),
|
||||
source: "digitalocean".into(),
|
||||
permissions: vec!["droplet:read".to_string(), "droplet:write".to_string()],
|
||||
};
|
||||
roles.push(role);
|
||||
}
|
||||
|
||||
for droplet in &droplets {
|
||||
let droplet_name = droplet.name.clone().unwrap_or_else(|| {
|
||||
droplet.id.map(|id| id.to_string()).unwrap_or_else(|| "unknown_droplet".to_string())
|
||||
});
|
||||
|
||||
let status = droplet.status.as_deref().unwrap_or("unknown");
|
||||
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: "droplet".into(),
|
||||
name: droplet_name.clone(),
|
||||
permissions: vec!["droplet:read".to_string()],
|
||||
risk: severity_to_str(Severity::High).to_string(),
|
||||
reason: format!("Compute droplet (status: {status}) accessible with this token"),
|
||||
});
|
||||
}
|
||||
|
||||
// Enumerate databases
|
||||
let databases = list_databases(&client, token).await.unwrap_or_else(|err| {
|
||||
warn!("DigitalOcean access-map: database enumeration failed: {err}");
|
||||
Vec::new()
|
||||
});
|
||||
|
||||
if !databases.is_empty() {
|
||||
has_databases = true;
|
||||
permissions.admin.push("database:list".to_string());
|
||||
|
||||
let role = RoleBinding {
|
||||
name: "database:access".into(),
|
||||
source: "digitalocean".into(),
|
||||
permissions: vec!["database:read".to_string(), "database:write".to_string()],
|
||||
};
|
||||
roles.push(role);
|
||||
}
|
||||
|
||||
for db in &databases {
|
||||
let db_name = db
|
||||
.name
|
||||
.clone()
|
||||
.or_else(|| db.id.clone())
|
||||
.unwrap_or_else(|| "unknown_database".to_string());
|
||||
|
||||
let engine = db.engine.as_deref().unwrap_or("unknown");
|
||||
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: "database".into(),
|
||||
name: db_name.clone(),
|
||||
permissions: vec!["database:read".to_string()],
|
||||
risk: severity_to_str(Severity::Critical).to_string(),
|
||||
reason: format!("Managed database ({engine}) accessible with this token"),
|
||||
});
|
||||
}
|
||||
|
||||
// Enumerate Kubernetes clusters
|
||||
let clusters = list_kubernetes_clusters(&client, token).await.unwrap_or_else(|err| {
|
||||
warn!("DigitalOcean access-map: kubernetes enumeration failed: {err}");
|
||||
Vec::new()
|
||||
});
|
||||
|
||||
if !clusters.is_empty() {
|
||||
has_kubernetes = true;
|
||||
permissions.risky.push("kubernetes:list".to_string());
|
||||
|
||||
let role = RoleBinding {
|
||||
name: "kubernetes:access".into(),
|
||||
source: "digitalocean".into(),
|
||||
permissions: vec!["kubernetes:read".to_string(), "kubernetes:write".to_string()],
|
||||
};
|
||||
roles.push(role);
|
||||
}
|
||||
|
||||
for cluster in &clusters {
|
||||
let cluster_name = cluster
|
||||
.name
|
||||
.clone()
|
||||
.or_else(|| cluster.id.clone())
|
||||
.unwrap_or_else(|| "unknown_cluster".to_string());
|
||||
|
||||
let region = cluster.region.as_deref().unwrap_or("unknown");
|
||||
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: "kubernetes_cluster".into(),
|
||||
name: cluster_name.clone(),
|
||||
permissions: vec!["kubernetes:read".to_string()],
|
||||
risk: severity_to_str(Severity::High).to_string(),
|
||||
reason: format!("Kubernetes cluster in {region} accessible with this token"),
|
||||
});
|
||||
}
|
||||
|
||||
if team_name.is_some() {
|
||||
permissions.risky.push("team:member".to_string());
|
||||
risk_notes.push("Token has team-level access".into());
|
||||
}
|
||||
|
||||
permissions.admin.sort();
|
||||
permissions.admin.dedup();
|
||||
permissions.risky.sort();
|
||||
permissions.risky.dedup();
|
||||
permissions.read_only.sort();
|
||||
permissions.read_only.dedup();
|
||||
|
||||
let severity = derive_severity(has_droplets, has_databases, has_kubernetes, &projects);
|
||||
|
||||
if resources.is_empty() {
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: "account".into(),
|
||||
name: username.clone(),
|
||||
permissions: Vec::new(),
|
||||
risk: severity_to_str(Severity::Low).to_string(),
|
||||
reason: "DigitalOcean account associated with the token".into(),
|
||||
});
|
||||
risk_notes.push("Token did not enumerate any resources".into());
|
||||
}
|
||||
|
||||
Ok(AccessMapResult {
|
||||
cloud: "digitalocean".into(),
|
||||
identity,
|
||||
roles,
|
||||
permissions,
|
||||
resources,
|
||||
severity,
|
||||
recommendations: build_recommendations(severity),
|
||||
risk_notes,
|
||||
token_details: Some(AccessTokenDetails {
|
||||
name: None,
|
||||
username: account.email.clone(),
|
||||
account_type: None,
|
||||
company: team_name,
|
||||
location: None,
|
||||
email: account.email.clone(),
|
||||
url: None,
|
||||
token_type: None,
|
||||
created_at: None,
|
||||
last_used_at: None,
|
||||
expires_at: None,
|
||||
user_id: account.uuid,
|
||||
scopes: Vec::new(),
|
||||
}),
|
||||
provider_metadata: None,
|
||||
fingerprint: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch_account(client: &Client, token: &str) -> Result<DigitalOceanAccount> {
|
||||
let resp = client
|
||||
.get(format!("{DIGITALOCEAN_API}/v2/account"))
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await
|
||||
.context("DigitalOcean access-map: failed to fetch account info")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(anyhow!(
|
||||
"DigitalOcean access-map: account lookup failed with HTTP {}",
|
||||
resp.status()
|
||||
));
|
||||
}
|
||||
|
||||
let body: AccountResponse =
|
||||
resp.json().await.context("DigitalOcean access-map: invalid account JSON")?;
|
||||
|
||||
body.account
|
||||
.ok_or_else(|| anyhow!("DigitalOcean access-map: account field missing from response"))
|
||||
}
|
||||
|
||||
async fn list_projects(client: &Client, token: &str) -> Result<Vec<DigitalOceanProject>> {
|
||||
let resp = client
|
||||
.get(format!("{DIGITALOCEAN_API}/v2/projects"))
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await
|
||||
.context("DigitalOcean access-map: failed to list projects")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
warn!("DigitalOcean access-map: project enumeration failed with HTTP {}", resp.status());
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let body: ProjectsResponse =
|
||||
resp.json().await.context("DigitalOcean access-map: invalid projects JSON")?;
|
||||
Ok(body.projects)
|
||||
}
|
||||
|
||||
async fn list_droplets(client: &Client, token: &str) -> Result<Vec<DigitalOceanDroplet>> {
|
||||
let resp = client
|
||||
.get(format!("{DIGITALOCEAN_API}/v2/droplets"))
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await
|
||||
.context("DigitalOcean access-map: failed to list droplets")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
warn!("DigitalOcean access-map: droplet enumeration failed with HTTP {}", resp.status());
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let body: DropletsResponse =
|
||||
resp.json().await.context("DigitalOcean access-map: invalid droplets JSON")?;
|
||||
Ok(body.droplets)
|
||||
}
|
||||
|
||||
async fn list_databases(client: &Client, token: &str) -> Result<Vec<DigitalOceanDatabase>> {
|
||||
let resp = client
|
||||
.get(format!("{DIGITALOCEAN_API}/v2/databases"))
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await
|
||||
.context("DigitalOcean access-map: failed to list databases")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
warn!("DigitalOcean access-map: database enumeration failed with HTTP {}", resp.status());
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let body: DatabasesResponse =
|
||||
resp.json().await.context("DigitalOcean access-map: invalid databases JSON")?;
|
||||
Ok(body.databases)
|
||||
}
|
||||
|
||||
async fn list_kubernetes_clusters(
|
||||
client: &Client,
|
||||
token: &str,
|
||||
) -> Result<Vec<DigitalOceanKubernetesCluster>> {
|
||||
let resp = client
|
||||
.get(format!("{DIGITALOCEAN_API}/v2/kubernetes/clusters"))
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await
|
||||
.context("DigitalOcean access-map: failed to list kubernetes clusters")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
warn!("DigitalOcean access-map: kubernetes enumeration failed with HTTP {}", resp.status());
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let body: KubernetesClustersResponse =
|
||||
resp.json().await.context("DigitalOcean access-map: invalid kubernetes JSON")?;
|
||||
Ok(body.kubernetes_clusters)
|
||||
}
|
||||
|
||||
fn derive_severity(
|
||||
has_droplets: bool,
|
||||
has_databases: bool,
|
||||
_has_kubernetes: bool,
|
||||
projects: &[DigitalOceanProject],
|
||||
) -> Severity {
|
||||
if has_databases {
|
||||
return Severity::Critical;
|
||||
}
|
||||
|
||||
if has_droplets {
|
||||
return Severity::High;
|
||||
}
|
||||
|
||||
if !projects.is_empty() {
|
||||
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",
|
||||
}
|
||||
}
|
||||
345
src/access_map/fastly.rs
Normal file
345
src/access_map/fastly.rs
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
use anyhow::{anyhow, Context, Result};
|
||||
use reqwest::{header, Client};
|
||||
use serde::Deserialize;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::{cli::commands::access_map::AccessMapArgs, validation::GLOBAL_USER_AGENT};
|
||||
|
||||
use super::{
|
||||
build_recommendations, AccessMapResult, AccessSummary, AccessTokenDetails, PermissionSummary,
|
||||
ResourceExposure, RoleBinding, Severity,
|
||||
};
|
||||
|
||||
const FASTLY_API: &str = "https://api.fastly.com";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct FastlyUser {
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
#[serde(default)]
|
||||
login: Option<String>,
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
email: Option<String>,
|
||||
#[serde(default)]
|
||||
role: Option<String>,
|
||||
#[serde(default)]
|
||||
customer_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct FastlyService {
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(rename = "type", default)]
|
||||
service_type: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
#[serde(default)]
|
||||
customer_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct FastlyCustomer {
|
||||
#[allow(dead_code)]
|
||||
#[serde(default)]
|
||||
id: 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 Fastly token from {}", path.display()))?;
|
||||
raw.trim().to_string()
|
||||
} else {
|
||||
return Err(anyhow!("Fastly 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 Fastly HTTP client")?;
|
||||
|
||||
let user = fetch_current_user(&client, token).await?;
|
||||
|
||||
let username = user
|
||||
.login
|
||||
.clone()
|
||||
.or_else(|| user.name.clone())
|
||||
.or_else(|| user.email.clone())
|
||||
.unwrap_or_else(|| "fastly_user".to_string());
|
||||
|
||||
let role = user.role.clone().unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
let identity = AccessSummary {
|
||||
id: username.clone(),
|
||||
access_type: "user".into(),
|
||||
project: None,
|
||||
tenant: user.customer_id.clone(),
|
||||
account_id: user.id.clone(),
|
||||
};
|
||||
|
||||
let mut risk_notes = Vec::new();
|
||||
let mut resources = Vec::new();
|
||||
let mut permissions = PermissionSummary::default();
|
||||
let mut roles = Vec::new();
|
||||
|
||||
// Classify role
|
||||
let role_binding = RoleBinding {
|
||||
name: format!("role:{role}"),
|
||||
source: "fastly".into(),
|
||||
permissions: role_permissions(&role),
|
||||
};
|
||||
roles.push(role_binding);
|
||||
|
||||
match classify_role(&role) {
|
||||
RoleRisk::Superuser => {
|
||||
permissions.admin.push(format!("role:{role}"));
|
||||
risk_notes.push("Superuser role grants full platform access".into());
|
||||
}
|
||||
RoleRisk::Engineer => {
|
||||
permissions.risky.push(format!("role:{role}"));
|
||||
}
|
||||
RoleRisk::Billing => {
|
||||
permissions.risky.push(format!("role:{role}"));
|
||||
risk_notes.push("Billing role grants access to financial data".into());
|
||||
}
|
||||
RoleRisk::ReadOnly => {
|
||||
permissions.read_only.push(format!("role:{role}"));
|
||||
}
|
||||
}
|
||||
|
||||
// If superuser, try to fetch customer details
|
||||
if role == "superuser" {
|
||||
if let Some(customer_id) = &user.customer_id {
|
||||
let customer =
|
||||
fetch_customer(&client, token, customer_id).await.unwrap_or_else(|err| {
|
||||
warn!("Fastly access-map: customer lookup failed: {err}");
|
||||
None
|
||||
});
|
||||
|
||||
if let Some(cust) = customer {
|
||||
let cust_name = cust.name.unwrap_or_else(|| customer_id.clone());
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: "customer".into(),
|
||||
name: cust_name,
|
||||
permissions: vec!["customer:admin".to_string()],
|
||||
risk: severity_to_str(Severity::Critical).to_string(),
|
||||
reason: "Full customer account access via superuser role".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enumerate services
|
||||
let services = list_services(&client, token).await.unwrap_or_else(|err| {
|
||||
warn!("Fastly access-map: service enumeration failed: {err}");
|
||||
Vec::new()
|
||||
});
|
||||
|
||||
for service in &services {
|
||||
let service_name = service
|
||||
.name
|
||||
.clone()
|
||||
.or_else(|| service.id.clone())
|
||||
.unwrap_or_else(|| "unknown_service".to_string());
|
||||
|
||||
let svc_type = service.service_type.as_deref().unwrap_or("unknown");
|
||||
|
||||
let risk = match classify_role(&role) {
|
||||
RoleRisk::Superuser => Severity::Critical,
|
||||
RoleRisk::Engineer => Severity::High,
|
||||
RoleRisk::Billing => Severity::Medium,
|
||||
RoleRisk::ReadOnly => Severity::Low,
|
||||
};
|
||||
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: "service".into(),
|
||||
name: service_name.clone(),
|
||||
permissions: vec![format!("service:{svc_type}")],
|
||||
risk: severity_to_str(risk).to_string(),
|
||||
reason: format!("Fastly service ({svc_type}) accessible with {role} role"),
|
||||
});
|
||||
}
|
||||
|
||||
permissions.admin.sort();
|
||||
permissions.admin.dedup();
|
||||
permissions.risky.sort();
|
||||
permissions.risky.dedup();
|
||||
permissions.read_only.sort();
|
||||
permissions.read_only.dedup();
|
||||
|
||||
let severity = derive_severity(&role, &services);
|
||||
|
||||
if services.is_empty() {
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: "account".into(),
|
||||
name: username.clone(),
|
||||
permissions: Vec::new(),
|
||||
risk: severity_to_str(Severity::Low).to_string(),
|
||||
reason: "Fastly account associated with the token".into(),
|
||||
});
|
||||
risk_notes.push("Token did not enumerate any services".into());
|
||||
}
|
||||
|
||||
Ok(AccessMapResult {
|
||||
cloud: "fastly".into(),
|
||||
identity,
|
||||
roles,
|
||||
permissions,
|
||||
resources,
|
||||
severity,
|
||||
recommendations: build_recommendations(severity),
|
||||
risk_notes,
|
||||
token_details: Some(AccessTokenDetails {
|
||||
name: user.name.clone(),
|
||||
username: user.login.clone(),
|
||||
account_type: Some(role.clone()),
|
||||
company: None,
|
||||
location: None,
|
||||
email: user.email.clone(),
|
||||
url: None,
|
||||
token_type: None,
|
||||
created_at: None,
|
||||
last_used_at: None,
|
||||
expires_at: None,
|
||||
user_id: user.id,
|
||||
scopes: vec![role],
|
||||
}),
|
||||
provider_metadata: None,
|
||||
fingerprint: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch_current_user(client: &Client, token: &str) -> Result<FastlyUser> {
|
||||
let resp = client
|
||||
.get(format!("{FASTLY_API}/current_user"))
|
||||
.header("Fastly-Key", token)
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await
|
||||
.context("Fastly access-map: failed to fetch current user")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(anyhow!(
|
||||
"Fastly access-map: current_user lookup failed with HTTP {}",
|
||||
resp.status()
|
||||
));
|
||||
}
|
||||
|
||||
resp.json().await.context("Fastly access-map: invalid current_user JSON")
|
||||
}
|
||||
|
||||
async fn list_services(client: &Client, token: &str) -> Result<Vec<FastlyService>> {
|
||||
let resp = client
|
||||
.get(format!("{FASTLY_API}/service"))
|
||||
.header("Fastly-Key", token)
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await
|
||||
.context("Fastly access-map: failed to list services")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
warn!("Fastly access-map: service enumeration failed with HTTP {}", resp.status());
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
resp.json().await.context("Fastly access-map: invalid services JSON")
|
||||
}
|
||||
|
||||
async fn fetch_customer(
|
||||
client: &Client,
|
||||
token: &str,
|
||||
customer_id: &str,
|
||||
) -> Result<Option<FastlyCustomer>> {
|
||||
let resp = client
|
||||
.get(format!("{FASTLY_API}/customer/{customer_id}"))
|
||||
.header("Fastly-Key", token)
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await
|
||||
.context("Fastly access-map: failed to fetch customer")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
warn!("Fastly access-map: customer lookup failed with HTTP {}", resp.status());
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let customer: FastlyCustomer =
|
||||
resp.json().await.context("Fastly access-map: invalid customer JSON")?;
|
||||
Ok(Some(customer))
|
||||
}
|
||||
|
||||
enum RoleRisk {
|
||||
Superuser,
|
||||
Engineer,
|
||||
Billing,
|
||||
ReadOnly,
|
||||
}
|
||||
|
||||
fn classify_role(role: &str) -> RoleRisk {
|
||||
match role {
|
||||
"superuser" => RoleRisk::Superuser,
|
||||
"engineer" => RoleRisk::Engineer,
|
||||
"billing" => RoleRisk::Billing,
|
||||
_ => RoleRisk::ReadOnly,
|
||||
}
|
||||
}
|
||||
|
||||
fn role_permissions(role: &str) -> Vec<String> {
|
||||
match role {
|
||||
"superuser" => vec![
|
||||
"service:create".to_string(),
|
||||
"service:delete".to_string(),
|
||||
"service:configure".to_string(),
|
||||
"service:purge".to_string(),
|
||||
"customer:admin".to_string(),
|
||||
"user:manage".to_string(),
|
||||
],
|
||||
"engineer" => vec![
|
||||
"service:configure".to_string(),
|
||||
"service:purge".to_string(),
|
||||
"service:deploy".to_string(),
|
||||
],
|
||||
"billing" => vec!["billing:read".to_string(), "billing:write".to_string()],
|
||||
_ => vec!["service:read".to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
fn derive_severity(role: &str, services: &[FastlyService]) -> Severity {
|
||||
match classify_role(role) {
|
||||
RoleRisk::Superuser => Severity::Critical,
|
||||
RoleRisk::Engineer => {
|
||||
if services.len() > 5 {
|
||||
Severity::High
|
||||
} else {
|
||||
Severity::High
|
||||
}
|
||||
}
|
||||
RoleRisk::Billing => Severity::Medium,
|
||||
RoleRisk::ReadOnly => {
|
||||
if services.is_empty() {
|
||||
Severity::Low
|
||||
} else {
|
||||
Severity::Low
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn severity_to_str(severity: Severity) -> &'static str {
|
||||
match severity {
|
||||
Severity::Low => "low",
|
||||
Severity::Medium => "medium",
|
||||
Severity::High => "high",
|
||||
Severity::Critical => "critical",
|
||||
}
|
||||
}
|
||||
352
src/access_map/hubspot.rs
Normal file
352
src/access_map/hubspot.rs
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
use anyhow::{anyhow, Context, Result};
|
||||
use reqwest::{header, Client};
|
||||
use serde::Deserialize;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::{cli::commands::access_map::AccessMapArgs, validation::GLOBAL_USER_AGENT};
|
||||
|
||||
use super::{
|
||||
build_recommendations, AccessMapResult, AccessSummary, AccessTokenDetails, PermissionSummary,
|
||||
ResourceExposure, RoleBinding, Severity,
|
||||
};
|
||||
|
||||
const HUBSPOT_API: &str = "https://api.hubapi.com";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct HubSpotAccountInfo {
|
||||
#[serde(default)]
|
||||
portal_id: Option<u64>,
|
||||
#[serde(default)]
|
||||
account_type: Option<String>,
|
||||
#[serde(default)]
|
||||
time_zone: Option<String>,
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
company_currency: Option<String>,
|
||||
#[serde(default)]
|
||||
ui_domain: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct HubSpotOwner {
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
id: Option<String>,
|
||||
#[serde(default)]
|
||||
email: Option<String>,
|
||||
#[serde(rename = "firstName", default)]
|
||||
first_name: Option<String>,
|
||||
#[serde(rename = "lastName", default)]
|
||||
last_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct HubSpotOwnersResponse {
|
||||
#[serde(default)]
|
||||
results: Vec<HubSpotOwner>,
|
||||
}
|
||||
|
||||
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 HubSpot token from {}", path.display()))?;
|
||||
raw.trim().to_string()
|
||||
} else {
|
||||
return Err(anyhow!("HubSpot 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 HubSpot HTTP client")?;
|
||||
|
||||
let account_info = fetch_account_info(&client, token).await?;
|
||||
|
||||
let portal_id_str = account_info
|
||||
.portal_id
|
||||
.map(|id| id.to_string())
|
||||
.unwrap_or_else(|| "unknown_portal".to_string());
|
||||
|
||||
let identity = AccessSummary {
|
||||
id: portal_id_str.clone(),
|
||||
access_type: account_info.account_type.clone().unwrap_or_else(|| "api_key".into()),
|
||||
project: None,
|
||||
tenant: account_info.ui_domain.clone(),
|
||||
account_id: Some(portal_id_str.clone()),
|
||||
};
|
||||
|
||||
let mut risk_notes = Vec::new();
|
||||
let mut resources = Vec::new();
|
||||
let mut permissions = PermissionSummary::default();
|
||||
let mut roles = Vec::new();
|
||||
let mut detected_scopes: Vec<String> = Vec::new();
|
||||
|
||||
// Probe CRM resources to determine accessible scopes
|
||||
let contacts_accessible = probe_crm_object(&client, token, "contacts").await;
|
||||
let deals_accessible = probe_crm_object(&client, token, "deals").await;
|
||||
let companies_accessible = probe_crm_object(&client, token, "companies").await;
|
||||
|
||||
if contacts_accessible {
|
||||
detected_scopes.push("crm.objects.contacts.read".into());
|
||||
detected_scopes.push("crm.objects.contacts.write".into());
|
||||
}
|
||||
if deals_accessible {
|
||||
detected_scopes.push("crm.objects.deals.read".into());
|
||||
detected_scopes.push("crm.objects.deals.write".into());
|
||||
}
|
||||
if companies_accessible {
|
||||
detected_scopes.push("crm.objects.companies.read".into());
|
||||
detected_scopes.push("crm.objects.companies.write".into());
|
||||
}
|
||||
|
||||
// Fetch owners to check account management access
|
||||
let owners = fetch_owners(&client, token).await.unwrap_or_else(|err| {
|
||||
warn!("HubSpot access-map: owners lookup failed: {err}");
|
||||
Vec::new()
|
||||
});
|
||||
|
||||
if !owners.is_empty() {
|
||||
detected_scopes.push("crm.objects.owners.read".into());
|
||||
risk_notes.push(format!("Token can enumerate {} CRM owners", owners.len()));
|
||||
}
|
||||
|
||||
// Check if account info was accessible (indicates account management access)
|
||||
if account_info.portal_id.is_some() {
|
||||
detected_scopes.push("account-info.security.read".into());
|
||||
}
|
||||
|
||||
for scope in &detected_scopes {
|
||||
let role = RoleBinding {
|
||||
name: format!("scope:{scope}"),
|
||||
source: "hubspot".into(),
|
||||
permissions: vec![scope.clone()],
|
||||
};
|
||||
roles.push(role);
|
||||
|
||||
match classify_scope(scope) {
|
||||
ScopeRisk::Admin => permissions.admin.push(scope.clone()),
|
||||
ScopeRisk::Write => permissions.risky.push(scope.clone()),
|
||||
ScopeRisk::Read => permissions.read_only.push(scope.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
// Add resource exposures
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: "account".into(),
|
||||
name: portal_id_str.clone(),
|
||||
permissions: detected_scopes.clone(),
|
||||
risk: severity_to_str(if has_write_scope(&detected_scopes) {
|
||||
Severity::High
|
||||
} else {
|
||||
Severity::Medium
|
||||
})
|
||||
.to_string(),
|
||||
reason: "HubSpot portal accessible with this token".to_string(),
|
||||
});
|
||||
|
||||
if contacts_accessible {
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: "crm_object".into(),
|
||||
name: "contacts".into(),
|
||||
permissions: vec![
|
||||
"crm.objects.contacts.read".into(),
|
||||
"crm.objects.contacts.write".into(),
|
||||
],
|
||||
risk: severity_to_str(Severity::High).to_string(),
|
||||
reason: "CRM contacts accessible - contains customer PII".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if deals_accessible {
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: "crm_object".into(),
|
||||
name: "deals".into(),
|
||||
permissions: vec!["crm.objects.deals.read".into(), "crm.objects.deals.write".into()],
|
||||
risk: severity_to_str(Severity::High).to_string(),
|
||||
reason: "CRM deals accessible - contains business-sensitive data".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if companies_accessible {
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: "crm_object".into(),
|
||||
name: "companies".into(),
|
||||
permissions: vec![
|
||||
"crm.objects.companies.read".into(),
|
||||
"crm.objects.companies.write".into(),
|
||||
],
|
||||
risk: severity_to_str(Severity::Medium).to_string(),
|
||||
reason: "CRM companies accessible".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
for owner in &owners {
|
||||
let owner_name = owner
|
||||
.first_name
|
||||
.as_deref()
|
||||
.map(|f| {
|
||||
let last = owner.last_name.as_deref().unwrap_or("");
|
||||
format!("{f} {last}").trim().to_string()
|
||||
})
|
||||
.or_else(|| owner.email.clone())
|
||||
.unwrap_or_else(|| "unknown_owner".to_string());
|
||||
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: "owner".into(),
|
||||
name: owner_name,
|
||||
permissions: vec!["crm.objects.owners.read".into()],
|
||||
risk: severity_to_str(Severity::Low).to_string(),
|
||||
reason: "CRM owner enumerable with this token".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
permissions.admin.sort();
|
||||
permissions.admin.dedup();
|
||||
permissions.risky.sort();
|
||||
permissions.risky.dedup();
|
||||
permissions.read_only.sort();
|
||||
permissions.read_only.dedup();
|
||||
|
||||
let severity = derive_severity(&detected_scopes, contacts_accessible || deals_accessible);
|
||||
|
||||
if detected_scopes.is_empty() {
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: "account".into(),
|
||||
name: portal_id_str.clone(),
|
||||
permissions: Vec::new(),
|
||||
risk: severity_to_str(Severity::Low).to_string(),
|
||||
reason: "HubSpot account associated with the token".into(),
|
||||
});
|
||||
risk_notes.push("Token did not enumerate any CRM resources or scopes".into());
|
||||
}
|
||||
|
||||
Ok(AccessMapResult {
|
||||
cloud: "hubspot".into(),
|
||||
identity,
|
||||
roles,
|
||||
permissions,
|
||||
resources,
|
||||
severity,
|
||||
recommendations: build_recommendations(severity),
|
||||
risk_notes,
|
||||
token_details: Some(AccessTokenDetails {
|
||||
name: None,
|
||||
username: None,
|
||||
account_type: account_info.account_type,
|
||||
company: None,
|
||||
location: account_info.time_zone,
|
||||
email: None,
|
||||
url: account_info.ui_domain.map(|d| format!("https://{d}")),
|
||||
token_type: None,
|
||||
created_at: None,
|
||||
last_used_at: None,
|
||||
expires_at: None,
|
||||
user_id: account_info.portal_id.map(|id| id.to_string()),
|
||||
scopes: detected_scopes,
|
||||
}),
|
||||
provider_metadata: None,
|
||||
fingerprint: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch_account_info(client: &Client, token: &str) -> Result<HubSpotAccountInfo> {
|
||||
let resp = client
|
||||
.get(format!("{HUBSPOT_API}/account-info/v3/details"))
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await
|
||||
.context("HubSpot access-map: failed to fetch account info")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(anyhow!(
|
||||
"HubSpot access-map: account info lookup failed with HTTP {}",
|
||||
resp.status()
|
||||
));
|
||||
}
|
||||
|
||||
resp.json().await.context("HubSpot access-map: invalid account info JSON")
|
||||
}
|
||||
|
||||
async fn fetch_owners(client: &Client, token: &str) -> Result<Vec<HubSpotOwner>> {
|
||||
let resp = client
|
||||
.get(format!("{HUBSPOT_API}/crm/v3/owners/"))
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await
|
||||
.context("HubSpot access-map: failed to fetch owners")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(anyhow!(
|
||||
"HubSpot access-map: owners lookup failed with HTTP {}",
|
||||
resp.status()
|
||||
));
|
||||
}
|
||||
|
||||
let body: HubSpotOwnersResponse =
|
||||
resp.json().await.context("HubSpot access-map: invalid owners JSON")?;
|
||||
Ok(body.results)
|
||||
}
|
||||
|
||||
async fn probe_crm_object(client: &Client, token: &str, object_type: &str) -> bool {
|
||||
let resp = client
|
||||
.get(format!("{HUBSPOT_API}/crm/v3/objects/{object_type}?limit=1"))
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match resp {
|
||||
Ok(r) => r.status().is_success(),
|
||||
Err(err) => {
|
||||
warn!("HubSpot access-map: {object_type} probe failed: {err}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ScopeRisk {
|
||||
Admin,
|
||||
Write,
|
||||
Read,
|
||||
}
|
||||
|
||||
fn classify_scope(scope: &str) -> ScopeRisk {
|
||||
match scope {
|
||||
"account-info.security.read" => ScopeRisk::Admin,
|
||||
s if s.contains(".write") => ScopeRisk::Write,
|
||||
_ => ScopeRisk::Read,
|
||||
}
|
||||
}
|
||||
|
||||
fn has_write_scope(scopes: &[String]) -> bool {
|
||||
scopes.iter().any(|s| s.contains(".write"))
|
||||
}
|
||||
|
||||
fn derive_severity(scopes: &[String], has_crm_write: bool) -> Severity {
|
||||
if has_crm_write && has_write_scope(scopes) {
|
||||
return Severity::High;
|
||||
}
|
||||
|
||||
let has_read = scopes.iter().any(|s| s.contains(".read"));
|
||||
if has_read {
|
||||
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",
|
||||
}
|
||||
}
|
||||
334
src/access_map/ibm_cloud.rs
Normal file
334
src/access_map/ibm_cloud.rs
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
use anyhow::{anyhow, Context, Result};
|
||||
use reqwest::{header, Client};
|
||||
use serde::Deserialize;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::{cli::commands::access_map::AccessMapArgs, validation::GLOBAL_USER_AGENT};
|
||||
|
||||
use super::{
|
||||
build_recommendations, AccessMapResult, AccessSummary, AccessTokenDetails, PermissionSummary,
|
||||
ResourceExposure, RoleBinding, Severity,
|
||||
};
|
||||
|
||||
const IBM_IAM_API: &str = "https://iam.cloud.ibm.com";
|
||||
const IBM_RESOURCE_API: &str = "https://resource-controller.cloud.ibm.com";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct IbmApiKeyDetails {
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
entity_tag: Option<String>,
|
||||
#[serde(default)]
|
||||
iam_id: Option<String>,
|
||||
#[serde(default)]
|
||||
account_id: Option<String>,
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
description: Option<String>,
|
||||
#[serde(default)]
|
||||
created_at: Option<String>,
|
||||
#[serde(default)]
|
||||
modified_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct IbmTokenResponse {
|
||||
#[serde(default)]
|
||||
access_token: Option<String>,
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
token_type: Option<String>,
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
expires_in: Option<u64>,
|
||||
#[serde(default)]
|
||||
scope: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct IbmResourceInstance {
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
id: Option<String>,
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
resource_plan_id: Option<String>,
|
||||
#[serde(default)]
|
||||
region_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct IbmResourceListResponse {
|
||||
#[serde(default)]
|
||||
resources: Vec<IbmResourceInstance>,
|
||||
}
|
||||
|
||||
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 IBM Cloud API key from {}", path.display()))?;
|
||||
raw.trim().to_string()
|
||||
} else {
|
||||
return Err(anyhow!("IBM Cloud 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 IBM Cloud HTTP client")?;
|
||||
|
||||
let api_key_details = fetch_api_key_details(&client, token).await?;
|
||||
|
||||
let key_name = api_key_details.name.clone().unwrap_or_else(|| "ibm_cloud_apikey".to_string());
|
||||
|
||||
let account_id = api_key_details.account_id.clone();
|
||||
|
||||
let identity = AccessSummary {
|
||||
id: api_key_details.iam_id.clone().unwrap_or_else(|| key_name.clone()),
|
||||
access_type: "apikey".into(),
|
||||
project: None,
|
||||
tenant: None,
|
||||
account_id: account_id.clone(),
|
||||
};
|
||||
|
||||
let mut risk_notes = Vec::new();
|
||||
let mut resources = Vec::new();
|
||||
let mut permissions = PermissionSummary::default();
|
||||
let mut roles = Vec::new();
|
||||
let mut detected_scopes: Vec<String> = Vec::new();
|
||||
|
||||
// Exchange API key for IAM token
|
||||
let iam_token = exchange_token(&client, token).await;
|
||||
|
||||
let resource_instances = match &iam_token {
|
||||
Ok(token_resp) => {
|
||||
if let Some(ref access_token) = token_resp.access_token {
|
||||
if let Some(ref scope) = token_resp.scope {
|
||||
for s in scope.split_whitespace() {
|
||||
detected_scopes.push(s.to_string());
|
||||
}
|
||||
}
|
||||
fetch_resource_instances(&client, access_token).await.unwrap_or_else(|err| {
|
||||
warn!("IBM Cloud access-map: resource enumeration failed: {err}");
|
||||
Vec::new()
|
||||
})
|
||||
} else {
|
||||
warn!("IBM Cloud access-map: token exchange returned no access_token");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("IBM Cloud access-map: token exchange failed: {err}");
|
||||
risk_notes.push("IAM token exchange failed; resource enumeration skipped".into());
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
// Add IAM-level role
|
||||
roles.push(RoleBinding {
|
||||
name: "apikey".into(),
|
||||
source: "ibm_cloud".into(),
|
||||
permissions: detected_scopes.clone(),
|
||||
});
|
||||
|
||||
for scope in &detected_scopes {
|
||||
match classify_scope(scope) {
|
||||
ScopeRisk::Admin => permissions.admin.push(scope.clone()),
|
||||
ScopeRisk::Write => permissions.risky.push(scope.clone()),
|
||||
ScopeRisk::Read => permissions.read_only.push(scope.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
// Add account-level resource
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: "account".into(),
|
||||
name: account_id.clone().unwrap_or_else(|| "unknown_account".to_string()),
|
||||
permissions: detected_scopes.clone(),
|
||||
risk: severity_to_str(if resource_instances.len() > 10 {
|
||||
Severity::Critical
|
||||
} else if !resource_instances.is_empty() {
|
||||
Severity::High
|
||||
} else {
|
||||
Severity::Medium
|
||||
})
|
||||
.to_string(),
|
||||
reason: "IBM Cloud account accessible with this API key".to_string(),
|
||||
});
|
||||
|
||||
for instance in &resource_instances {
|
||||
let instance_name = instance.name.clone().unwrap_or_else(|| "unknown_resource".to_string());
|
||||
|
||||
let region = instance.region_id.clone().unwrap_or_else(|| "global".to_string());
|
||||
let plan = instance.resource_plan_id.clone().unwrap_or_else(|| "unknown_plan".to_string());
|
||||
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: "resource_instance".into(),
|
||||
name: format!("{instance_name} ({region})"),
|
||||
permissions: vec![format!("plan:{plan}")],
|
||||
risk: severity_to_str(Severity::Medium).to_string(),
|
||||
reason: "Resource instance accessible via IAM token".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if !resource_instances.is_empty() {
|
||||
risk_notes
|
||||
.push(format!("Token can enumerate {} resource instances", resource_instances.len()));
|
||||
}
|
||||
|
||||
permissions.admin.sort();
|
||||
permissions.admin.dedup();
|
||||
permissions.risky.sort();
|
||||
permissions.risky.dedup();
|
||||
permissions.read_only.sort();
|
||||
permissions.read_only.dedup();
|
||||
|
||||
let severity = derive_severity(&resource_instances);
|
||||
|
||||
if resource_instances.is_empty() && detected_scopes.is_empty() {
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: "account".into(),
|
||||
name: key_name.clone(),
|
||||
permissions: Vec::new(),
|
||||
risk: severity_to_str(Severity::Medium).to_string(),
|
||||
reason: "IBM Cloud API key with no enumerable resources".into(),
|
||||
});
|
||||
risk_notes.push("API key did not enumerate any resource instances".into());
|
||||
}
|
||||
|
||||
Ok(AccessMapResult {
|
||||
cloud: "ibm_cloud".into(),
|
||||
identity,
|
||||
roles,
|
||||
permissions,
|
||||
resources,
|
||||
severity,
|
||||
recommendations: build_recommendations(severity),
|
||||
risk_notes,
|
||||
token_details: Some(AccessTokenDetails {
|
||||
name: api_key_details.name,
|
||||
username: api_key_details.iam_id,
|
||||
account_type: None,
|
||||
company: None,
|
||||
location: None,
|
||||
email: None,
|
||||
url: None,
|
||||
token_type: Some("apikey".into()),
|
||||
created_at: api_key_details.created_at,
|
||||
last_used_at: api_key_details.modified_at,
|
||||
expires_at: None,
|
||||
user_id: api_key_details.id,
|
||||
scopes: detected_scopes,
|
||||
}),
|
||||
provider_metadata: None,
|
||||
fingerprint: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch_api_key_details(client: &Client, api_key: &str) -> Result<IbmApiKeyDetails> {
|
||||
let resp = client
|
||||
.post(format!("{IBM_IAM_API}/v1/apikeys/details"))
|
||||
.header("IAM-Apikey", api_key)
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.header(header::CONTENT_LENGTH, "0")
|
||||
.send()
|
||||
.await
|
||||
.context("IBM Cloud access-map: failed to fetch API key details")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(anyhow!(
|
||||
"IBM Cloud access-map: API key details lookup failed with HTTP {}",
|
||||
resp.status()
|
||||
));
|
||||
}
|
||||
|
||||
resp.json().await.context("IBM Cloud access-map: invalid API key details JSON")
|
||||
}
|
||||
|
||||
async fn exchange_token(client: &Client, api_key: &str) -> Result<IbmTokenResponse> {
|
||||
let resp = client
|
||||
.post(format!("{IBM_IAM_API}/identity/token"))
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
||||
.body(format!("grant_type=urn:ibm:params:oauth:grant-type:apikey&apikey={api_key}"))
|
||||
.send()
|
||||
.await
|
||||
.context("IBM Cloud access-map: failed to exchange token")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(anyhow!(
|
||||
"IBM Cloud access-map: token exchange failed with HTTP {}",
|
||||
resp.status()
|
||||
));
|
||||
}
|
||||
|
||||
resp.json().await.context("IBM Cloud access-map: invalid token exchange JSON")
|
||||
}
|
||||
|
||||
async fn fetch_resource_instances(
|
||||
client: &Client,
|
||||
iam_token: &str,
|
||||
) -> Result<Vec<IbmResourceInstance>> {
|
||||
let resp = client
|
||||
.get(format!("{IBM_RESOURCE_API}/v2/resource_instances"))
|
||||
.header(header::AUTHORIZATION, format!("Bearer {iam_token}"))
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await
|
||||
.context("IBM Cloud access-map: failed to list resource instances")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
warn!(
|
||||
"IBM Cloud access-map: resource instance enumeration failed with HTTP {}",
|
||||
resp.status()
|
||||
);
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let body: IbmResourceListResponse =
|
||||
resp.json().await.context("IBM Cloud access-map: invalid resource instances JSON")?;
|
||||
Ok(body.resources)
|
||||
}
|
||||
|
||||
enum ScopeRisk {
|
||||
Admin,
|
||||
Write,
|
||||
Read,
|
||||
}
|
||||
|
||||
fn classify_scope(scope: &str) -> ScopeRisk {
|
||||
match scope {
|
||||
"ibm" => ScopeRisk::Admin,
|
||||
"openid" => ScopeRisk::Read,
|
||||
_ => ScopeRisk::Write,
|
||||
}
|
||||
}
|
||||
|
||||
fn derive_severity(resource_instances: &[IbmResourceInstance]) -> Severity {
|
||||
if resource_instances.len() > 10 {
|
||||
return Severity::Critical;
|
||||
}
|
||||
|
||||
if !resource_instances.is_empty() {
|
||||
return Severity::High;
|
||||
}
|
||||
|
||||
Severity::Medium
|
||||
}
|
||||
|
||||
fn severity_to_str(severity: Severity) -> &'static str {
|
||||
match severity {
|
||||
Severity::Low => "low",
|
||||
Severity::Medium => "medium",
|
||||
Severity::High => "high",
|
||||
Severity::Critical => "critical",
|
||||
}
|
||||
}
|
||||
312
src/access_map/jira.rs
Normal file
312
src/access_map/jira.rs
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
use anyhow::{anyhow, Context, Result};
|
||||
use reqwest::{header, Client};
|
||||
use serde::Deserialize;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::cli::commands::access_map::AccessMapArgs;
|
||||
use crate::validation::GLOBAL_USER_AGENT;
|
||||
|
||||
use super::{
|
||||
build_recommendations, AccessMapResult, AccessSummary, AccessTokenDetails, PermissionSummary,
|
||||
ResourceExposure, RoleBinding, Severity,
|
||||
};
|
||||
|
||||
// ─── API response types ─────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct JiraUser {
|
||||
#[serde(rename = "accountId")]
|
||||
account_id: Option<String>,
|
||||
#[serde(rename = "emailAddress")]
|
||||
email_address: Option<String>,
|
||||
#[serde(rename = "displayName")]
|
||||
display_name: Option<String>,
|
||||
#[serde(default)]
|
||||
active: bool,
|
||||
#[serde(rename = "accountType")]
|
||||
account_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct JiraPermissionsResponse {
|
||||
permissions: std::collections::HashMap<String, JiraPermissionEntry>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct JiraPermissionEntry {
|
||||
#[serde(rename = "havePermission")]
|
||||
have_permission: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct JiraProject {
|
||||
#[allow(dead_code)]
|
||||
id: Option<String>,
|
||||
key: String,
|
||||
name: String,
|
||||
#[serde(rename = "projectTypeKey")]
|
||||
project_type_key: Option<String>,
|
||||
}
|
||||
|
||||
// ─── Permission classification ──────────────────────────────────────────────
|
||||
|
||||
const ADMIN_PERMISSIONS: &[&str] = &["SYSTEM_ADMIN", "ADMINISTER_PROJECTS"];
|
||||
const RISKY_PERMISSIONS: &[&str] =
|
||||
&["DELETE_ISSUES", "EDIT_ISSUES", "CREATE_ISSUES", "MANAGE_WATCHERS"];
|
||||
const READ_PERMISSIONS: &[&str] = &["BROWSE_PROJECTS"];
|
||||
|
||||
const CHECKED_PERMISSIONS: &str =
|
||||
"BROWSE_PROJECTS,CREATE_ISSUES,EDIT_ISSUES,DELETE_ISSUES,MANAGE_WATCHERS,ADMINISTER_PROJECTS,SYSTEM_ADMIN";
|
||||
|
||||
// ─── Public entry points ────────────────────────────────────────────────────
|
||||
|
||||
/// Entry point when invoked via `kingfisher access-map jira <CREDENTIAL>`.
|
||||
pub async fn map_access(args: &AccessMapArgs) -> Result<AccessMapResult> {
|
||||
let path = args.credential_path.as_deref().ok_or_else(|| {
|
||||
anyhow!("Jira access-map requires a credential file containing the token and base URL")
|
||||
})?;
|
||||
let raw = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("Failed to read Jira credential from {}", path.display()))?;
|
||||
|
||||
let (token, base_url) = parse_jira_credentials(&raw)?;
|
||||
map_access_from_token_and_url(&token, &base_url).await
|
||||
}
|
||||
|
||||
/// Map access for a Jira token + base URL discovered during scanning.
|
||||
pub async fn map_access_from_token_and_url(token: &str, base_url: &str) -> Result<AccessMapResult> {
|
||||
let base_url = base_url.trim_end_matches('/');
|
||||
|
||||
let client = Client::builder()
|
||||
.user_agent(GLOBAL_USER_AGENT.as_str())
|
||||
.build()
|
||||
.context("Failed to build Jira HTTP client")?;
|
||||
|
||||
let mut risk_notes: Vec<String> = Vec::new();
|
||||
let mut permissions = PermissionSummary::default();
|
||||
|
||||
// ── 1. Identity ─────────────────────────────────────────────────────────
|
||||
let user = fetch_myself(&client, token, base_url).await?;
|
||||
|
||||
let identity_id = user
|
||||
.email_address
|
||||
.clone()
|
||||
.or_else(|| user.account_id.clone())
|
||||
.unwrap_or_else(|| "unknown_jira_user".to_string());
|
||||
|
||||
if !user.active {
|
||||
risk_notes.push("Jira account is marked as inactive".to_string());
|
||||
}
|
||||
|
||||
// ── 2. Permissions ──────────────────────────────────────────────────────
|
||||
let granted_perms = fetch_permissions(&client, token, base_url).await.unwrap_or_else(|err| {
|
||||
warn!("Jira access-map: permission check failed: {err}");
|
||||
risk_notes.push(format!("Permission enumeration failed: {err}"));
|
||||
Vec::new()
|
||||
});
|
||||
|
||||
for perm in &granted_perms {
|
||||
if ADMIN_PERMISSIONS.contains(&perm.as_str()) {
|
||||
permissions.admin.push(perm.clone());
|
||||
} else if RISKY_PERMISSIONS.contains(&perm.as_str()) {
|
||||
permissions.risky.push(perm.clone());
|
||||
} else if READ_PERMISSIONS.contains(&perm.as_str()) {
|
||||
permissions.read_only.push(perm.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Projects (resources) ─────────────────────────────────────────────
|
||||
let projects = fetch_projects(&client, token, base_url).await.unwrap_or_else(|err| {
|
||||
warn!("Jira access-map: project enumeration failed: {err}");
|
||||
risk_notes.push(format!("Project enumeration failed: {err}"));
|
||||
Vec::new()
|
||||
});
|
||||
|
||||
// ── Build roles ─────────────────────────────────────────────────────────
|
||||
let roles = vec![RoleBinding {
|
||||
name: format!("jira_user:{}", user.account_type.as_deref().unwrap_or("unknown")),
|
||||
source: "jira".into(),
|
||||
permissions: granted_perms.clone(),
|
||||
}];
|
||||
|
||||
// ── Build resources ─────────────────────────────────────────────────────
|
||||
let mut resources: Vec<ResourceExposure> = Vec::new();
|
||||
for proj in &projects {
|
||||
let has_write = granted_perms.iter().any(|p| {
|
||||
ADMIN_PERMISSIONS.contains(&p.as_str()) || RISKY_PERMISSIONS.contains(&p.as_str())
|
||||
});
|
||||
let risk = if has_write { "medium" } else { "low" };
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: "jira_project".into(),
|
||||
name: format!("{} ({})", proj.name, proj.key),
|
||||
permissions: granted_perms.clone(),
|
||||
risk: risk.into(),
|
||||
reason: format!(
|
||||
"Jira {} project accessible by this token",
|
||||
proj.project_type_key.as_deref().unwrap_or("unknown")
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Severity ────────────────────────────────────────────────────────────
|
||||
let severity = derive_severity(&permissions);
|
||||
|
||||
// ── Risk notes ──────────────────────────────────────────────────────────
|
||||
if permissions.admin.contains(&"SYSTEM_ADMIN".to_string()) {
|
||||
risk_notes
|
||||
.push("Token has SYSTEM_ADMIN privilege — full Jira administration access".into());
|
||||
}
|
||||
if permissions.admin.contains(&"ADMINISTER_PROJECTS".to_string()) {
|
||||
risk_notes.push("Token can administer projects — project configuration access".into());
|
||||
}
|
||||
|
||||
Ok(AccessMapResult {
|
||||
cloud: "jira".into(),
|
||||
identity: AccessSummary {
|
||||
id: identity_id,
|
||||
access_type: user.account_type.unwrap_or_else(|| "token".into()),
|
||||
project: None,
|
||||
tenant: None,
|
||||
account_id: user.account_id.clone(),
|
||||
},
|
||||
roles,
|
||||
permissions,
|
||||
resources,
|
||||
severity,
|
||||
recommendations: build_recommendations(severity),
|
||||
risk_notes,
|
||||
token_details: Some(AccessTokenDetails {
|
||||
name: user.display_name,
|
||||
username: None,
|
||||
account_type: Some("api_token".into()),
|
||||
company: None,
|
||||
location: None,
|
||||
email: user.email_address,
|
||||
url: Some(base_url.to_string()),
|
||||
token_type: Some("bearer".into()),
|
||||
created_at: None,
|
||||
last_used_at: None,
|
||||
expires_at: None,
|
||||
user_id: user.account_id,
|
||||
scopes: granted_perms,
|
||||
}),
|
||||
provider_metadata: None,
|
||||
fingerprint: None,
|
||||
})
|
||||
}
|
||||
|
||||
// ─── API helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
async fn fetch_myself(client: &Client, token: &str, base_url: &str) -> Result<JiraUser> {
|
||||
let resp = client
|
||||
.get(format!("{base_url}/rest/api/3/myself"))
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await
|
||||
.context("Jira access-map: failed to query myself endpoint")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(anyhow!("Jira access-map: myself endpoint returned HTTP {}", resp.status()));
|
||||
}
|
||||
|
||||
resp.json::<JiraUser>().await.context("Jira access-map: invalid myself JSON response")
|
||||
}
|
||||
|
||||
async fn fetch_permissions(client: &Client, token: &str, base_url: &str) -> Result<Vec<String>> {
|
||||
let url = format!("{base_url}/rest/api/3/mypermissions?permissions={CHECKED_PERMISSIONS}");
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await
|
||||
.context("Jira access-map: failed to query mypermissions endpoint")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(anyhow!(
|
||||
"Jira access-map: mypermissions endpoint returned HTTP {}",
|
||||
resp.status()
|
||||
));
|
||||
}
|
||||
|
||||
let body: JiraPermissionsResponse =
|
||||
resp.json().await.context("Jira access-map: invalid mypermissions JSON response")?;
|
||||
|
||||
let granted: Vec<String> = body
|
||||
.permissions
|
||||
.into_iter()
|
||||
.filter(|(_, entry)| entry.have_permission)
|
||||
.map(|(name, _)| name)
|
||||
.collect();
|
||||
|
||||
Ok(granted)
|
||||
}
|
||||
|
||||
async fn fetch_projects(client: &Client, token: &str, base_url: &str) -> Result<Vec<JiraProject>> {
|
||||
let resp = client
|
||||
.get(format!("{base_url}/rest/api/3/project?maxResults=50"))
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await
|
||||
.context("Jira access-map: failed to query project endpoint")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(anyhow!("Jira access-map: project endpoint returned HTTP {}", resp.status()));
|
||||
}
|
||||
|
||||
resp.json::<Vec<JiraProject>>().await.context("Jira access-map: invalid project JSON response")
|
||||
}
|
||||
|
||||
// ─── Credential parsing ─────────────────────────────────────────────────────
|
||||
|
||||
fn parse_jira_credentials(raw: &str) -> Result<(String, String)> {
|
||||
// Try JSON first
|
||||
if let Ok(json) = serde_json::from_str::<serde_json::Value>(raw) {
|
||||
let token = json
|
||||
.get("token")
|
||||
.or_else(|| json.get("api_token"))
|
||||
.or_else(|| json.get("apiToken"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim().to_string());
|
||||
let base_url = json
|
||||
.get("base_url")
|
||||
.or_else(|| json.get("baseUrl"))
|
||||
.or_else(|| json.get("url"))
|
||||
.or_else(|| json.get("domain"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim().to_string());
|
||||
|
||||
if let (Some(token), Some(base_url)) = (token, base_url) {
|
||||
return Ok((token, base_url));
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to line-based: first line = token, second line = base_url
|
||||
let lines: Vec<&str> = raw
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty() && !line.starts_with('#'))
|
||||
.collect();
|
||||
if lines.len() >= 2 {
|
||||
return Ok((lines[0].to_string(), lines[1].to_string()));
|
||||
}
|
||||
|
||||
Err(anyhow!(
|
||||
"Jira credential format not recognized. Provide JSON with token + base_url, or two lines (token, base_url)."
|
||||
))
|
||||
}
|
||||
|
||||
// ─── Severity derivation ────────────────────────────────────────────────────
|
||||
|
||||
fn derive_severity(permissions: &PermissionSummary) -> Severity {
|
||||
if permissions.admin.iter().any(|p| p == "SYSTEM_ADMIN") {
|
||||
Severity::Critical
|
||||
} else if permissions.admin.iter().any(|p| p == "ADMINISTER_PROJECTS") {
|
||||
Severity::High
|
||||
} else if !permissions.risky.is_empty() {
|
||||
Severity::Medium
|
||||
} else {
|
||||
Severity::Low
|
||||
}
|
||||
}
|
||||
260
src/access_map/mysql.rs
Normal file
260
src/access_map/mysql.rs
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use mysql_async::prelude::*;
|
||||
use mysql_async::{Opts, Pool};
|
||||
use tokio::time::timeout;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::cli::commands::access_map::AccessMapArgs;
|
||||
|
||||
use super::{
|
||||
build_recommendations, AccessMapResult, AccessSummary, PermissionSummary, ResourceExposure,
|
||||
RoleBinding, Severity,
|
||||
};
|
||||
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(8);
|
||||
|
||||
// ─── Grant classification ───────────────────────────────────────────────────
|
||||
|
||||
const ADMIN_GRANTS: &[&str] = &["ALL PRIVILEGES", "SUPER", "GRANT OPTION", "CREATE USER"];
|
||||
const RISKY_GRANTS: &[&str] = &["INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "CREATE"];
|
||||
const READ_GRANTS: &[&str] = &["SELECT", "SHOW DATABASES", "SHOW VIEW"];
|
||||
|
||||
// ─── Public entry points ────────────────────────────────────────────────────
|
||||
|
||||
/// Entry point when invoked via `kingfisher access-map mysql <CREDENTIAL>`.
|
||||
pub async fn map_access(args: &AccessMapArgs) -> Result<AccessMapResult> {
|
||||
let path = args.credential_path.as_deref().ok_or_else(|| {
|
||||
anyhow!("MySQL access-map requires a credential file containing the connection URI")
|
||||
})?;
|
||||
let raw = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("Failed to read MySQL URI from {}", path.display()))?;
|
||||
let uri = raw.trim().to_string();
|
||||
map_access_from_uri(&uri).await
|
||||
}
|
||||
|
||||
/// Map access for a MySQL connection URI discovered during scanning.
|
||||
pub async fn map_access_from_uri(uri: &str) -> Result<AccessMapResult> {
|
||||
let opts = Opts::from_url(uri).map_err(|e| anyhow!("Failed to parse MySQL URI: {e}"))?;
|
||||
|
||||
let pool = Pool::new(opts.clone());
|
||||
let mut conn = timeout(CONNECT_TIMEOUT, pool.get_conn())
|
||||
.await
|
||||
.map_err(|_| anyhow!("MySQL connection timed out after {CONNECT_TIMEOUT:?}"))?
|
||||
.context("MySQL connection failed")?;
|
||||
|
||||
let mut risk_notes: Vec<String> = Vec::new();
|
||||
|
||||
// ── 1. Identity ─────────────────────────────────────────────────────────
|
||||
let current_user: String = conn
|
||||
.query_first("SELECT CURRENT_USER()")
|
||||
.await
|
||||
.context("Failed to query CURRENT_USER()")?
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
// ── 2. Server version ───────────────────────────────────────────────────
|
||||
let server_version: String = conn
|
||||
.query_first("SELECT VERSION()")
|
||||
.await
|
||||
.unwrap_or(Some("unknown".to_string()))
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
// ── 3. Grants ───────────────────────────────────────────────────────────
|
||||
let grant_rows: Vec<String> =
|
||||
conn.query("SHOW GRANTS FOR CURRENT_USER()").await.unwrap_or_else(|e| {
|
||||
warn!("MySQL access-map: failed to query grants: {e}");
|
||||
risk_notes.push(format!("Grant enumeration failed: {e}"));
|
||||
Vec::new()
|
||||
});
|
||||
|
||||
// ── 4. Databases (resources) ────────────────────────────────────────────
|
||||
let databases: Vec<String> = conn.query("SHOW DATABASES").await.unwrap_or_else(|e| {
|
||||
warn!("MySQL access-map: failed to list databases: {e}");
|
||||
risk_notes.push(format!("Database enumeration failed: {e}"));
|
||||
Vec::new()
|
||||
});
|
||||
|
||||
// Done with the connection — disconnect cleanly.
|
||||
drop(conn);
|
||||
pool.disconnect().await.ok();
|
||||
|
||||
// ── Parse grants ────────────────────────────────────────────────────────
|
||||
let parsed_grants = parse_grants(&grant_rows);
|
||||
|
||||
// ── Build permissions ───────────────────────────────────────────────────
|
||||
let mut permissions = PermissionSummary::default();
|
||||
|
||||
for grant in &parsed_grants {
|
||||
for priv_name in &grant.privileges {
|
||||
let upper = priv_name.to_uppercase();
|
||||
if ADMIN_GRANTS.contains(&upper.as_str()) {
|
||||
if !permissions.admin.contains(&upper) {
|
||||
permissions.admin.push(upper);
|
||||
}
|
||||
} else if RISKY_GRANTS.contains(&upper.as_str()) {
|
||||
let label = format!("{} ON {}", upper, grant.scope);
|
||||
permissions.risky.push(label);
|
||||
} else if READ_GRANTS.contains(&upper.as_str()) {
|
||||
let label = format!("{} ON {}", upper, grant.scope);
|
||||
permissions.read_only.push(label);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
permissions.risky.sort();
|
||||
permissions.risky.dedup();
|
||||
permissions.read_only.sort();
|
||||
permissions.read_only.dedup();
|
||||
|
||||
// ── Build roles ─────────────────────────────────────────────────────────
|
||||
let mut roles = Vec::new();
|
||||
for grant in &parsed_grants {
|
||||
roles.push(RoleBinding {
|
||||
name: format!("grant:{}", grant.scope),
|
||||
source: "SHOW GRANTS".into(),
|
||||
permissions: grant.privileges.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Build resources ─────────────────────────────────────────────────────
|
||||
let has_global_write = parsed_grants.iter().any(|g| {
|
||||
g.scope == "*.*"
|
||||
&& g.privileges.iter().any(|p| {
|
||||
let u = p.to_uppercase();
|
||||
ADMIN_GRANTS.contains(&u.as_str()) || RISKY_GRANTS.contains(&u.as_str())
|
||||
})
|
||||
});
|
||||
|
||||
let mut resources: Vec<ResourceExposure> = Vec::new();
|
||||
for db in &databases {
|
||||
let db_specific_write = parsed_grants.iter().any(|g| {
|
||||
(g.scope == "*.*" || g.scope == format!("`{db}`.*"))
|
||||
&& g.privileges.iter().any(|p| {
|
||||
let u = p.to_uppercase();
|
||||
ADMIN_GRANTS.contains(&u.as_str()) || RISKY_GRANTS.contains(&u.as_str())
|
||||
})
|
||||
});
|
||||
let risk = if db_specific_write { "medium" } else { "low" };
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: "database".into(),
|
||||
name: db.clone(),
|
||||
permissions: parsed_grants
|
||||
.iter()
|
||||
.filter(|g| g.scope == "*.*" || g.scope == format!("`{db}`.*"))
|
||||
.flat_map(|g| g.privileges.clone())
|
||||
.collect(),
|
||||
risk: risk.into(),
|
||||
reason: format!("Database accessible by user '{current_user}'"),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Severity ────────────────────────────────────────────────────────────
|
||||
let has_all_on_global = parsed_grants.iter().any(|g| {
|
||||
g.scope == "*.*" && g.privileges.iter().any(|p| p.to_uppercase() == "ALL PRIVILEGES")
|
||||
});
|
||||
let has_write_on_global = has_global_write;
|
||||
let has_write_on_specific = parsed_grants.iter().any(|g| {
|
||||
g.scope != "*.*"
|
||||
&& g.privileges.iter().any(|p| {
|
||||
let u = p.to_uppercase();
|
||||
RISKY_GRANTS.contains(&u.as_str())
|
||||
})
|
||||
});
|
||||
|
||||
let severity = if has_all_on_global {
|
||||
Severity::Critical
|
||||
} else if has_write_on_global {
|
||||
Severity::High
|
||||
} else if has_write_on_specific {
|
||||
Severity::Medium
|
||||
} else {
|
||||
Severity::Low
|
||||
};
|
||||
|
||||
// ── Risk notes ──────────────────────────────────────────────────────────
|
||||
if has_all_on_global {
|
||||
risk_notes.push(
|
||||
"User has ALL PRIVILEGES on *.* — full administrative access to all databases".into(),
|
||||
);
|
||||
}
|
||||
if !permissions.admin.is_empty() && !has_all_on_global {
|
||||
risk_notes.push(format!("User has admin-level grants: {}", permissions.admin.join(", ")));
|
||||
}
|
||||
|
||||
let identity = AccessSummary {
|
||||
id: current_user.clone(),
|
||||
access_type: if has_all_on_global { "superuser" } else { "user" }.into(),
|
||||
project: None,
|
||||
tenant: None,
|
||||
account_id: None,
|
||||
};
|
||||
|
||||
Ok(AccessMapResult {
|
||||
cloud: "mysql".into(),
|
||||
identity,
|
||||
roles,
|
||||
permissions,
|
||||
resources,
|
||||
severity,
|
||||
recommendations: build_recommendations(severity),
|
||||
risk_notes,
|
||||
token_details: None,
|
||||
provider_metadata: Some(super::ProviderMetadata {
|
||||
version: Some(server_version),
|
||||
enterprise: None,
|
||||
}),
|
||||
fingerprint: None,
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Grant parsing ──────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ParsedGrant {
|
||||
privileges: Vec<String>,
|
||||
scope: String,
|
||||
}
|
||||
|
||||
/// Parse `SHOW GRANTS` output lines into structured grant entries.
|
||||
///
|
||||
/// Example line: `GRANT SELECT, INSERT ON `mydb`.* TO 'user'@'host'`
|
||||
fn parse_grants(grant_rows: &[String]) -> Vec<ParsedGrant> {
|
||||
let mut results = Vec::new();
|
||||
|
||||
for row in grant_rows {
|
||||
let upper = row.to_uppercase();
|
||||
// Skip proxy grants or other non-standard lines
|
||||
if !upper.starts_with("GRANT ") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Split at " ON " to separate privileges from scope
|
||||
let on_idx = match upper.find(" ON ") {
|
||||
Some(idx) => idx,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let priv_part = &row[6..on_idx]; // skip "GRANT "
|
||||
let after_on = &row[on_idx + 4..]; // skip " ON "
|
||||
|
||||
// Scope is everything up to the next " TO "
|
||||
let to_upper = after_on.to_uppercase();
|
||||
let scope = match to_upper.find(" TO ") {
|
||||
Some(idx) => after_on[..idx].trim().to_string(),
|
||||
None => after_on.trim().to_string(),
|
||||
};
|
||||
|
||||
let privileges: Vec<String> = priv_part
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_uppercase())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
|
||||
if !privileges.is_empty() {
|
||||
results.push(ParsedGrant { privileges, scope });
|
||||
}
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
279
src/access_map/paypal.rs
Normal file
279
src/access_map/paypal.rs
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
use anyhow::{anyhow, Context, Result};
|
||||
use reqwest::{header, Client};
|
||||
use serde::Deserialize;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::cli::commands::access_map::AccessMapArgs;
|
||||
use crate::validation::GLOBAL_USER_AGENT;
|
||||
|
||||
use super::{
|
||||
build_recommendations, AccessMapResult, AccessSummary, AccessTokenDetails, PermissionSummary,
|
||||
ResourceExposure, RoleBinding, Severity,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API response types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TokenResponse {
|
||||
access_token: String,
|
||||
#[serde(default)]
|
||||
token_type: String,
|
||||
#[serde(default)]
|
||||
app_id: String,
|
||||
#[serde(default)]
|
||||
scope: String,
|
||||
#[serde(default)]
|
||||
expires_in: i64,
|
||||
#[allow(dead_code)]
|
||||
#[serde(default)]
|
||||
nonce: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct UserInfo {
|
||||
#[serde(default)]
|
||||
user_id: String,
|
||||
#[serde(default)]
|
||||
name: String,
|
||||
#[serde(default, rename = "payer_id")]
|
||||
payer_id: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scope classification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn classify_scope(scope: &str) -> ScopeCategory {
|
||||
if scope == "openid" || scope.contains("/services/identity/management") {
|
||||
return ScopeCategory::Admin;
|
||||
}
|
||||
if scope.contains("/services/payments/") || scope.contains("/services/disputes/") {
|
||||
return ScopeCategory::Risky;
|
||||
}
|
||||
if scope.contains("/services/reporting/") {
|
||||
return ScopeCategory::Read;
|
||||
}
|
||||
// Default: treat unknown URI scopes as risky
|
||||
ScopeCategory::Risky
|
||||
}
|
||||
|
||||
enum ScopeCategory {
|
||||
Admin,
|
||||
Risky,
|
||||
Read,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public entry points
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn map_access(args: &AccessMapArgs) -> Result<AccessMapResult> {
|
||||
let path = args.credential_path.as_deref().ok_or_else(|| {
|
||||
anyhow!("PayPal access-map requires a credential file with client_id and client_secret")
|
||||
})?;
|
||||
let raw = std::fs::read_to_string(path).with_context(|| {
|
||||
format!("Failed to read PayPal credential file from {}", path.display())
|
||||
})?;
|
||||
let json: serde_json::Value = serde_json::from_str(&raw)
|
||||
.context("PayPal credential file must be valid JSON with client_id and client_secret")?;
|
||||
|
||||
let client_id = json
|
||||
.get("client_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow!("PayPal credential JSON missing 'client_id'"))?;
|
||||
let client_secret = json
|
||||
.get("client_secret")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow!("PayPal credential JSON missing 'client_secret'"))?;
|
||||
|
||||
map_access_from_credentials(client_id, client_secret).await
|
||||
}
|
||||
|
||||
pub async fn map_access_from_credentials(
|
||||
client_id: &str,
|
||||
client_secret: &str,
|
||||
) -> Result<AccessMapResult> {
|
||||
let client = Client::builder()
|
||||
.user_agent(GLOBAL_USER_AGENT.as_str())
|
||||
.build()
|
||||
.context("Failed to build PayPal HTTP client")?;
|
||||
|
||||
let mut risk_notes: Vec<String> = Vec::new();
|
||||
let mut permissions = PermissionSummary::default();
|
||||
|
||||
// Try live first, then sandbox
|
||||
let (token_resp, is_sandbox) =
|
||||
match fetch_token(&client, client_id, client_secret, "api-m.paypal.com").await {
|
||||
Ok(resp) => (resp, false),
|
||||
Err(_live_err) => {
|
||||
let sandbox_resp =
|
||||
fetch_token(&client, client_id, client_secret, "api-m.sandbox.paypal.com")
|
||||
.await
|
||||
.context(
|
||||
"PayPal access-map: token exchange failed for both live and sandbox",
|
||||
)?;
|
||||
risk_notes.push("Credentials are for the PayPal sandbox environment".to_string());
|
||||
(sandbox_resp, true)
|
||||
}
|
||||
};
|
||||
|
||||
let base_host = if is_sandbox { "api-m.sandbox.paypal.com" } else { "api-m.paypal.com" };
|
||||
|
||||
let scopes: Vec<String> =
|
||||
token_resp.scope.split_whitespace().map(String::from).filter(|s| !s.is_empty()).collect();
|
||||
|
||||
// Classify scopes
|
||||
for scope in &scopes {
|
||||
match classify_scope(scope) {
|
||||
ScopeCategory::Admin => permissions.admin.push(scope.clone()),
|
||||
ScopeCategory::Risky => permissions.risky.push(scope.clone()),
|
||||
ScopeCategory::Read => permissions.read_only.push(scope.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch user info
|
||||
let user_info =
|
||||
fetch_user_info(&client, &token_resp.access_token, base_host).await.unwrap_or_else(|err| {
|
||||
warn!("PayPal access-map: user info lookup failed: {err}");
|
||||
risk_notes.push(format!("User info lookup failed: {err}"));
|
||||
UserInfo { user_id: String::new(), name: String::new(), payer_id: String::new() }
|
||||
});
|
||||
|
||||
// Determine severity
|
||||
let has_payment_scopes = scopes.iter().any(|s| s.contains("/services/payments/"));
|
||||
let severity = if is_sandbox {
|
||||
Severity::Medium
|
||||
} else if has_payment_scopes {
|
||||
Severity::Critical
|
||||
} else if !permissions.admin.is_empty() || !permissions.risky.is_empty() {
|
||||
Severity::High
|
||||
} else {
|
||||
Severity::High // live read-only is still High
|
||||
};
|
||||
|
||||
let environment = if is_sandbox { "sandbox" } else { "live" };
|
||||
risk_notes.push(format!("PayPal environment: {environment}"));
|
||||
|
||||
if !token_resp.app_id.is_empty() {
|
||||
risk_notes.push(format!("PayPal app_id: {}", token_resp.app_id));
|
||||
}
|
||||
|
||||
let identity_id = if !user_info.payer_id.is_empty() {
|
||||
user_info.payer_id.clone()
|
||||
} else if !token_resp.app_id.is_empty() {
|
||||
token_resp.app_id.clone()
|
||||
} else {
|
||||
client_id.to_string()
|
||||
};
|
||||
|
||||
let roles = vec![RoleBinding {
|
||||
name: format!("paypal_client_credentials_{environment}"),
|
||||
source: "paypal".into(),
|
||||
permissions: scopes.clone(),
|
||||
}];
|
||||
|
||||
let resources = vec![ResourceExposure {
|
||||
resource_type: "paypal_account".into(),
|
||||
name: identity_id.clone(),
|
||||
permissions: scopes.clone(),
|
||||
risk: severity_to_str(severity).to_string(),
|
||||
reason: format!("PayPal {environment} account reachable with these credentials"),
|
||||
}];
|
||||
|
||||
permissions.admin.sort();
|
||||
permissions.admin.dedup();
|
||||
permissions.risky.sort();
|
||||
permissions.risky.dedup();
|
||||
permissions.read_only.sort();
|
||||
permissions.read_only.dedup();
|
||||
|
||||
Ok(AccessMapResult {
|
||||
cloud: "paypal".into(),
|
||||
identity: AccessSummary {
|
||||
id: identity_id,
|
||||
access_type: "client_credentials".into(),
|
||||
project: if token_resp.app_id.is_empty() { None } else { Some(token_resp.app_id) },
|
||||
tenant: None,
|
||||
account_id: if user_info.payer_id.is_empty() { None } else { Some(user_info.payer_id) },
|
||||
},
|
||||
roles,
|
||||
permissions,
|
||||
resources,
|
||||
severity,
|
||||
recommendations: build_recommendations(severity),
|
||||
risk_notes,
|
||||
token_details: Some(AccessTokenDetails {
|
||||
name: if user_info.name.is_empty() { None } else { Some(user_info.name) },
|
||||
token_type: Some(token_resp.token_type),
|
||||
scopes,
|
||||
expires_at: if token_resp.expires_in > 0 {
|
||||
Some(format!("{}s from issuance", token_resp.expires_in))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
user_id: if user_info.user_id.is_empty() { None } else { Some(user_info.user_id) },
|
||||
..Default::default()
|
||||
}),
|
||||
provider_metadata: None,
|
||||
fingerprint: None,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn fetch_token(
|
||||
client: &Client,
|
||||
client_id: &str,
|
||||
client_secret: &str,
|
||||
host: &str,
|
||||
) -> Result<TokenResponse> {
|
||||
let resp = client
|
||||
.post(format!("https://{host}/v1/oauth2/token"))
|
||||
.basic_auth(client_id, Some(client_secret))
|
||||
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.body("grant_type=client_credentials")
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!("PayPal access-map: failed to exchange credentials with {host}")
|
||||
})?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(anyhow!(
|
||||
"PayPal access-map: token exchange failed with HTTP {} on {host}",
|
||||
resp.status()
|
||||
));
|
||||
}
|
||||
|
||||
resp.json::<TokenResponse>().await.context("PayPal access-map: invalid token response JSON")
|
||||
}
|
||||
|
||||
async fn fetch_user_info(client: &Client, access_token: &str, host: &str) -> Result<UserInfo> {
|
||||
let resp = client
|
||||
.get(format!("https://{host}/v1/identity/oauth2/userinfo?schema=paypalv1.1"))
|
||||
.header(header::AUTHORIZATION, format!("Bearer {access_token}"))
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await
|
||||
.context("PayPal access-map: failed to fetch user info")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(anyhow!("PayPal access-map: user info returned HTTP {}", resp.status()));
|
||||
}
|
||||
|
||||
resp.json::<UserInfo>().await.context("PayPal access-map: invalid user info JSON")
|
||||
}
|
||||
|
||||
fn severity_to_str(severity: Severity) -> &'static str {
|
||||
match severity {
|
||||
Severity::Low => "low",
|
||||
Severity::Medium => "medium",
|
||||
Severity::High => "high",
|
||||
Severity::Critical => "critical",
|
||||
}
|
||||
}
|
||||
291
src/access_map/plaid.rs
Normal file
291
src/access_map/plaid.rs
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
use anyhow::{anyhow, Context, Result};
|
||||
use reqwest::{header, Client, StatusCode};
|
||||
use serde::Deserialize;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::cli::commands::access_map::AccessMapArgs;
|
||||
use crate::validation::GLOBAL_USER_AGENT;
|
||||
|
||||
use super::{
|
||||
build_recommendations, AccessMapResult, AccessSummary, AccessTokenDetails, PermissionSummary,
|
||||
ResourceExposure, RoleBinding, Severity,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API response types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct InstitutionsResponse {
|
||||
#[serde(default)]
|
||||
total: i64,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Environment detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum PlaidEnvironment {
|
||||
Production,
|
||||
Development,
|
||||
Sandbox,
|
||||
}
|
||||
|
||||
impl PlaidEnvironment {
|
||||
fn host(self) -> &'static str {
|
||||
match self {
|
||||
PlaidEnvironment::Production => "production.plaid.com",
|
||||
PlaidEnvironment::Development => "development.plaid.com",
|
||||
PlaidEnvironment::Sandbox => "sandbox.plaid.com",
|
||||
}
|
||||
}
|
||||
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
PlaidEnvironment::Production => "production",
|
||||
PlaidEnvironment::Development => "development",
|
||||
PlaidEnvironment::Sandbox => "sandbox",
|
||||
}
|
||||
}
|
||||
|
||||
fn severity(self) -> Severity {
|
||||
match self {
|
||||
PlaidEnvironment::Production => Severity::Critical,
|
||||
PlaidEnvironment::Development => Severity::High,
|
||||
PlaidEnvironment::Sandbox => Severity::Low,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public entry points
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn map_access(args: &AccessMapArgs) -> Result<AccessMapResult> {
|
||||
let path = args.credential_path.as_deref().ok_or_else(|| {
|
||||
anyhow!("Plaid access-map requires a credential file with client_id and secret")
|
||||
})?;
|
||||
let raw = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("Failed to read Plaid credential file from {}", path.display()))?;
|
||||
let json: serde_json::Value = serde_json::from_str(&raw)
|
||||
.context("Plaid credential file must be valid JSON with client_id and secret")?;
|
||||
|
||||
let client_id = json
|
||||
.get("client_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow!("Plaid credential JSON missing 'client_id'"))?;
|
||||
let secret = json
|
||||
.get("secret")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow!("Plaid credential JSON missing 'secret'"))?;
|
||||
|
||||
map_access_from_credentials(client_id, secret).await
|
||||
}
|
||||
|
||||
pub async fn map_access_from_credentials(client_id: &str, secret: &str) -> Result<AccessMapResult> {
|
||||
let client = Client::builder()
|
||||
.user_agent(GLOBAL_USER_AGENT.as_str())
|
||||
.build()
|
||||
.context("Failed to build Plaid HTTP client")?;
|
||||
|
||||
let mut risk_notes: Vec<String> = Vec::new();
|
||||
let mut permissions = PermissionSummary::default();
|
||||
|
||||
// Detect environment by trying production -> development -> sandbox
|
||||
let (env, institutions_total) = detect_environment(&client, client_id, secret).await?;
|
||||
|
||||
risk_notes.push(format!("Plaid environment: {}", env.label()));
|
||||
|
||||
if let Some(total) = institutions_total {
|
||||
risk_notes.push(format!("Institutions available: {total}"));
|
||||
}
|
||||
|
||||
// Classify permissions based on environment
|
||||
match env {
|
||||
PlaidEnvironment::Production => {
|
||||
permissions.admin.push("production:api_access".to_string());
|
||||
permissions.admin.push("production:real_financial_data".to_string());
|
||||
}
|
||||
PlaidEnvironment::Development => {
|
||||
permissions.risky.push("development:api_access".to_string());
|
||||
permissions.risky.push("development:test_financial_data".to_string());
|
||||
}
|
||||
PlaidEnvironment::Sandbox => {
|
||||
permissions.read_only.push("sandbox:api_access".to_string());
|
||||
permissions.read_only.push("sandbox:mock_data".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Try item/get (will likely fail without an access_token, but that's fine)
|
||||
match try_item_get(&client, client_id, secret, env).await {
|
||||
Ok(()) => {
|
||||
risk_notes.push("item/get endpoint is reachable".to_string());
|
||||
}
|
||||
Err(err) => {
|
||||
// Expected to fail without access_token - not an error
|
||||
warn!("Plaid access-map: item/get probe (expected to fail): {err}");
|
||||
}
|
||||
}
|
||||
|
||||
let severity = env.severity();
|
||||
|
||||
let roles = vec![RoleBinding {
|
||||
name: format!("plaid_api_key_{}", env.label()),
|
||||
source: "plaid".into(),
|
||||
permissions: permissions
|
||||
.admin
|
||||
.iter()
|
||||
.chain(permissions.risky.iter())
|
||||
.chain(permissions.read_only.iter())
|
||||
.cloned()
|
||||
.collect(),
|
||||
}];
|
||||
|
||||
let resources = vec![ResourceExposure {
|
||||
resource_type: "plaid_account".into(),
|
||||
name: format!("{client_id}@{}", env.label()),
|
||||
permissions: vec![format!("{}:api_access", env.label())],
|
||||
risk: severity_to_str(severity).to_string(),
|
||||
reason: format!("Plaid {} API access with real financial data implications", env.label()),
|
||||
}];
|
||||
|
||||
permissions.admin.sort();
|
||||
permissions.admin.dedup();
|
||||
permissions.risky.sort();
|
||||
permissions.risky.dedup();
|
||||
permissions.read_only.sort();
|
||||
permissions.read_only.dedup();
|
||||
|
||||
Ok(AccessMapResult {
|
||||
cloud: "plaid".into(),
|
||||
identity: AccessSummary {
|
||||
id: client_id.to_string(),
|
||||
access_type: "api_key".into(),
|
||||
project: Some(env.label().to_string()),
|
||||
tenant: None,
|
||||
account_id: Some(client_id.to_string()),
|
||||
},
|
||||
roles,
|
||||
permissions,
|
||||
resources,
|
||||
severity,
|
||||
recommendations: build_recommendations(severity),
|
||||
risk_notes,
|
||||
token_details: Some(AccessTokenDetails {
|
||||
name: Some(format!("plaid_{}", env.label())),
|
||||
token_type: Some("client_credentials".into()),
|
||||
..Default::default()
|
||||
}),
|
||||
provider_metadata: None,
|
||||
fingerprint: None,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn detect_environment(
|
||||
client: &Client,
|
||||
client_id: &str,
|
||||
secret: &str,
|
||||
) -> Result<(PlaidEnvironment, Option<i64>)> {
|
||||
// Try production first
|
||||
if let Ok(resp) =
|
||||
try_institutions_get(client, client_id, secret, PlaidEnvironment::Production).await
|
||||
{
|
||||
return Ok((PlaidEnvironment::Production, Some(resp.total)));
|
||||
}
|
||||
|
||||
// Try development
|
||||
if let Ok(resp) =
|
||||
try_institutions_get(client, client_id, secret, PlaidEnvironment::Development).await
|
||||
{
|
||||
return Ok((PlaidEnvironment::Development, Some(resp.total)));
|
||||
}
|
||||
|
||||
// Try sandbox
|
||||
if let Ok(resp) =
|
||||
try_institutions_get(client, client_id, secret, PlaidEnvironment::Sandbox).await
|
||||
{
|
||||
return Ok((PlaidEnvironment::Sandbox, Some(resp.total)));
|
||||
}
|
||||
|
||||
Err(anyhow!("Plaid access-map: credentials not valid for production, development, or sandbox"))
|
||||
}
|
||||
|
||||
async fn try_institutions_get(
|
||||
client: &Client,
|
||||
client_id: &str,
|
||||
secret: &str,
|
||||
env: PlaidEnvironment,
|
||||
) -> Result<InstitutionsResponse> {
|
||||
let body = serde_json::json!({
|
||||
"client_id": client_id,
|
||||
"secret": secret,
|
||||
"count": 1,
|
||||
"offset": 0,
|
||||
"country_codes": ["US"],
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post(format!("https://{}/institutions/get", env.host()))
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!("Plaid access-map: failed to query institutions/get on {}", env.host())
|
||||
})?;
|
||||
|
||||
if resp.status() != StatusCode::OK {
|
||||
return Err(anyhow!(
|
||||
"Plaid access-map: institutions/get returned HTTP {} on {}",
|
||||
resp.status(),
|
||||
env.host()
|
||||
));
|
||||
}
|
||||
|
||||
resp.json::<InstitutionsResponse>()
|
||||
.await
|
||||
.context("Plaid access-map: invalid institutions/get JSON")
|
||||
}
|
||||
|
||||
async fn try_item_get(
|
||||
client: &Client,
|
||||
client_id: &str,
|
||||
secret: &str,
|
||||
env: PlaidEnvironment,
|
||||
) -> Result<()> {
|
||||
let body = serde_json::json!({
|
||||
"client_id": client_id,
|
||||
"secret": secret,
|
||||
"access_token": "",
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post(format!("https://{}/item/get", env.host()))
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.context("Plaid access-map: failed to query item/get")?;
|
||||
|
||||
if resp.status().is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("Plaid access-map: item/get returned HTTP {}", resp.status()))
|
||||
}
|
||||
}
|
||||
|
||||
fn severity_to_str(severity: Severity) -> &'static str {
|
||||
match severity {
|
||||
Severity::Low => "low",
|
||||
Severity::Medium => "medium",
|
||||
Severity::High => "high",
|
||||
Severity::Critical => "critical",
|
||||
}
|
||||
}
|
||||
380
src/access_map/sendgrid.rs
Normal file
380
src/access_map/sendgrid.rs
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
use anyhow::{anyhow, Context, Result};
|
||||
use reqwest::{header, Client};
|
||||
use serde::Deserialize;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::{cli::commands::access_map::AccessMapArgs, validation::GLOBAL_USER_AGENT};
|
||||
|
||||
use super::{
|
||||
build_recommendations, AccessMapResult, AccessSummary, AccessTokenDetails, PermissionSummary,
|
||||
ResourceExposure, RoleBinding, Severity,
|
||||
};
|
||||
|
||||
const SENDGRID_API: &str = "https://api.sendgrid.com";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SendGridAccount {
|
||||
#[serde(rename = "type", default)]
|
||||
account_type: Option<String>,
|
||||
#[serde(default)]
|
||||
reputation: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SendGridProfile {
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
address: Option<String>,
|
||||
#[serde(default)]
|
||||
city: Option<String>,
|
||||
#[serde(default)]
|
||||
company: Option<String>,
|
||||
#[serde(default)]
|
||||
email: Option<String>,
|
||||
#[serde(default)]
|
||||
first_name: Option<String>,
|
||||
#[serde(default)]
|
||||
last_name: Option<String>,
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
phone: Option<String>,
|
||||
#[serde(default)]
|
||||
username: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SendGridScopesResponse {
|
||||
#[serde(default)]
|
||||
scopes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SendGridApiKey {
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
api_key_id: Option<String>,
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SendGridApiKeysResponse {
|
||||
#[serde(default)]
|
||||
result: Vec<SendGridApiKey>,
|
||||
}
|
||||
|
||||
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 SendGrid token from {}", path.display()))?;
|
||||
raw.trim().to_string()
|
||||
} else {
|
||||
return Err(anyhow!("SendGrid 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 SendGrid HTTP client")?;
|
||||
|
||||
let account = fetch_account(&client, token).await?;
|
||||
|
||||
let profile = fetch_profile(&client, token).await.unwrap_or_else(|err| {
|
||||
warn!("SendGrid access-map: profile lookup failed: {err}");
|
||||
SendGridProfile {
|
||||
address: None,
|
||||
city: None,
|
||||
company: None,
|
||||
email: None,
|
||||
first_name: None,
|
||||
last_name: None,
|
||||
phone: None,
|
||||
username: None,
|
||||
}
|
||||
});
|
||||
|
||||
let username = profile
|
||||
.username
|
||||
.clone()
|
||||
.or_else(|| profile.email.clone())
|
||||
.unwrap_or_else(|| "sendgrid_user".to_string());
|
||||
|
||||
let identity = AccessSummary {
|
||||
id: username.clone(),
|
||||
access_type: account.account_type.clone().unwrap_or_else(|| "api_key".into()),
|
||||
project: None,
|
||||
tenant: profile.company.clone(),
|
||||
account_id: profile.username.clone(),
|
||||
};
|
||||
|
||||
let mut risk_notes = Vec::new();
|
||||
let mut resources = Vec::new();
|
||||
let mut permissions = PermissionSummary::default();
|
||||
let mut roles = Vec::new();
|
||||
|
||||
// Fetch scopes
|
||||
let scopes = fetch_scopes(&client, token).await.unwrap_or_else(|err| {
|
||||
warn!("SendGrid access-map: scopes lookup failed: {err}");
|
||||
Vec::new()
|
||||
});
|
||||
|
||||
for scope in &scopes {
|
||||
let role = RoleBinding {
|
||||
name: format!("scope:{scope}"),
|
||||
source: "sendgrid".into(),
|
||||
permissions: vec![scope.clone()],
|
||||
};
|
||||
roles.push(role);
|
||||
|
||||
match classify_scope(scope) {
|
||||
ScopeRisk::Admin => permissions.admin.push(scope.clone()),
|
||||
ScopeRisk::Write => permissions.risky.push(scope.clone()),
|
||||
ScopeRisk::Read => permissions.read_only.push(scope.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
// Check for other API keys (indicates admin access)
|
||||
let other_api_keys = fetch_api_keys(&client, token).await.unwrap_or_else(|err| {
|
||||
warn!("SendGrid access-map: API keys enumeration failed: {err}");
|
||||
Vec::new()
|
||||
});
|
||||
|
||||
if !other_api_keys.is_empty() {
|
||||
risk_notes.push(format!("Token can enumerate {} other API keys", other_api_keys.len()));
|
||||
}
|
||||
|
||||
// Add account resource
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: "account".into(),
|
||||
name: username.clone(),
|
||||
permissions: scopes.clone(),
|
||||
risk: severity_to_str(if has_admin_scope(&scopes) {
|
||||
Severity::Critical
|
||||
} else if has_mail_send(&scopes) {
|
||||
Severity::High
|
||||
} else {
|
||||
Severity::Medium
|
||||
})
|
||||
.to_string(),
|
||||
reason: "SendGrid account accessible with this token".to_string(),
|
||||
});
|
||||
|
||||
if has_mail_send(&scopes) {
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: "capability".into(),
|
||||
name: "mail.send".into(),
|
||||
permissions: vec!["mail.send".into()],
|
||||
risk: severity_to_str(Severity::High).to_string(),
|
||||
reason: "Token can send email as the organization - phishing risk".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
for api_key in &other_api_keys {
|
||||
let key_name = api_key.name.clone().unwrap_or_else(|| "unnamed_key".to_string());
|
||||
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: "api_key".into(),
|
||||
name: key_name,
|
||||
permissions: vec!["api_keys.read".into()],
|
||||
risk: severity_to_str(Severity::Medium).to_string(),
|
||||
reason: "API key enumerable with this token".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(rep) = account.reputation {
|
||||
risk_notes.push(format!("Sender reputation: {rep:.1}"));
|
||||
}
|
||||
|
||||
permissions.admin.sort();
|
||||
permissions.admin.dedup();
|
||||
permissions.risky.sort();
|
||||
permissions.risky.dedup();
|
||||
permissions.read_only.sort();
|
||||
permissions.read_only.dedup();
|
||||
|
||||
let severity = derive_severity(&scopes);
|
||||
|
||||
if scopes.is_empty() {
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: "account".into(),
|
||||
name: username.clone(),
|
||||
permissions: Vec::new(),
|
||||
risk: severity_to_str(Severity::Low).to_string(),
|
||||
reason: "SendGrid account associated with the token".into(),
|
||||
});
|
||||
risk_notes.push("Token did not enumerate any scopes".into());
|
||||
}
|
||||
|
||||
Ok(AccessMapResult {
|
||||
cloud: "sendgrid".into(),
|
||||
identity,
|
||||
roles,
|
||||
permissions,
|
||||
resources,
|
||||
severity,
|
||||
recommendations: build_recommendations(severity),
|
||||
risk_notes,
|
||||
token_details: Some(AccessTokenDetails {
|
||||
name: profile.first_name.as_ref().map(|f| {
|
||||
let last = profile.last_name.as_deref().unwrap_or("");
|
||||
format!("{f} {last}").trim().to_string()
|
||||
}),
|
||||
username: profile.username,
|
||||
account_type: account.account_type,
|
||||
company: profile.company,
|
||||
location: profile.city,
|
||||
email: profile.email,
|
||||
url: None,
|
||||
token_type: None,
|
||||
created_at: None,
|
||||
last_used_at: None,
|
||||
expires_at: None,
|
||||
user_id: None,
|
||||
scopes,
|
||||
}),
|
||||
provider_metadata: None,
|
||||
fingerprint: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch_account(client: &Client, token: &str) -> Result<SendGridAccount> {
|
||||
let resp = client
|
||||
.get(format!("{SENDGRID_API}/v3/user/account"))
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await
|
||||
.context("SendGrid access-map: failed to fetch account info")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(anyhow!(
|
||||
"SendGrid access-map: account lookup failed with HTTP {}",
|
||||
resp.status()
|
||||
));
|
||||
}
|
||||
|
||||
resp.json().await.context("SendGrid access-map: invalid account JSON")
|
||||
}
|
||||
|
||||
async fn fetch_profile(client: &Client, token: &str) -> Result<SendGridProfile> {
|
||||
let resp = client
|
||||
.get(format!("{SENDGRID_API}/v3/user/profile"))
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await
|
||||
.context("SendGrid access-map: failed to fetch user profile")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(anyhow!(
|
||||
"SendGrid access-map: profile lookup failed with HTTP {}",
|
||||
resp.status()
|
||||
));
|
||||
}
|
||||
|
||||
resp.json().await.context("SendGrid access-map: invalid profile JSON")
|
||||
}
|
||||
|
||||
async fn fetch_scopes(client: &Client, token: &str) -> Result<Vec<String>> {
|
||||
let resp = client
|
||||
.get(format!("{SENDGRID_API}/v3/scopes"))
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await
|
||||
.context("SendGrid access-map: failed to fetch scopes")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
warn!("SendGrid access-map: scopes lookup failed with HTTP {}", resp.status());
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let body: SendGridScopesResponse =
|
||||
resp.json().await.context("SendGrid access-map: invalid scopes JSON")?;
|
||||
Ok(body.scopes)
|
||||
}
|
||||
|
||||
async fn fetch_api_keys(client: &Client, token: &str) -> Result<Vec<SendGridApiKey>> {
|
||||
let resp = client
|
||||
.get(format!("{SENDGRID_API}/v3/api_keys"))
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await
|
||||
.context("SendGrid access-map: failed to list API keys")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
warn!("SendGrid access-map: API keys enumeration failed with HTTP {}", resp.status());
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let body: SendGridApiKeysResponse =
|
||||
resp.json().await.context("SendGrid access-map: invalid API keys JSON")?;
|
||||
Ok(body.result)
|
||||
}
|
||||
|
||||
enum ScopeRisk {
|
||||
Admin,
|
||||
Write,
|
||||
Read,
|
||||
}
|
||||
|
||||
fn classify_scope(scope: &str) -> ScopeRisk {
|
||||
match scope {
|
||||
"api_keys.create" | "api_keys.delete" | "api_keys.update" | "user.account.update" => {
|
||||
ScopeRisk::Admin
|
||||
}
|
||||
s if s.starts_with("mail.send")
|
||||
|| s.starts_with("marketing.")
|
||||
|| s.starts_with("templates.")
|
||||
|| s.starts_with("stats.") =>
|
||||
{
|
||||
ScopeRisk::Write
|
||||
}
|
||||
_ if scope.ends_with(".read") => ScopeRisk::Read,
|
||||
_ => ScopeRisk::Read,
|
||||
}
|
||||
}
|
||||
|
||||
fn has_admin_scope(scopes: &[String]) -> bool {
|
||||
scopes.iter().any(|s| {
|
||||
matches!(
|
||||
s.as_str(),
|
||||
"api_keys.create" | "api_keys.delete" | "api_keys.update" | "user.account.update"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn has_mail_send(scopes: &[String]) -> bool {
|
||||
scopes.iter().any(|s| s == "mail.send")
|
||||
}
|
||||
|
||||
fn derive_severity(scopes: &[String]) -> Severity {
|
||||
if has_admin_scope(scopes) {
|
||||
return Severity::Critical;
|
||||
}
|
||||
|
||||
if has_mail_send(scopes) {
|
||||
return Severity::High;
|
||||
}
|
||||
|
||||
let has_read = scopes.iter().any(|s| s.ends_with(".read"));
|
||||
if has_read {
|
||||
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",
|
||||
}
|
||||
}
|
||||
338
src/access_map/sendinblue.rs
Normal file
338
src/access_map/sendinblue.rs
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
use anyhow::{anyhow, Context, Result};
|
||||
use reqwest::{header, Client};
|
||||
use serde::Deserialize;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::{cli::commands::access_map::AccessMapArgs, validation::GLOBAL_USER_AGENT};
|
||||
|
||||
use super::{
|
||||
build_recommendations, AccessMapResult, AccessSummary, AccessTokenDetails, PermissionSummary,
|
||||
ResourceExposure, RoleBinding, Severity,
|
||||
};
|
||||
|
||||
const BREVO_API: &str = "https://api.brevo.com";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct BrevoAccount {
|
||||
#[serde(default)]
|
||||
email: Option<String>,
|
||||
#[serde(default)]
|
||||
first_name: Option<String>,
|
||||
#[serde(default)]
|
||||
last_name: Option<String>,
|
||||
#[serde(default)]
|
||||
company_name: Option<String>,
|
||||
#[serde(default)]
|
||||
plan: Vec<BrevoPlan>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct BrevoPlan {
|
||||
#[serde(rename = "type", default)]
|
||||
plan_type: Option<String>,
|
||||
#[serde(default)]
|
||||
credits: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct BrevoSender {
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
id: Option<u64>,
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
email: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct BrevoSendersResponse {
|
||||
#[serde(default)]
|
||||
senders: Vec<BrevoSender>,
|
||||
}
|
||||
|
||||
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 Brevo (Sendinblue) token from {}", path.display())
|
||||
})?;
|
||||
raw.trim().to_string()
|
||||
} else {
|
||||
return Err(anyhow!(
|
||||
"Brevo (Sendinblue) 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 Brevo HTTP client")?;
|
||||
|
||||
let account = fetch_account(&client, token).await?;
|
||||
|
||||
let username = account.email.clone().unwrap_or_else(|| "brevo_user".to_string());
|
||||
|
||||
let identity = AccessSummary {
|
||||
id: username.clone(),
|
||||
access_type: "api_key".into(),
|
||||
project: None,
|
||||
tenant: account.company_name.clone(),
|
||||
account_id: account.email.clone(),
|
||||
};
|
||||
|
||||
let mut risk_notes = Vec::new();
|
||||
let mut resources = Vec::new();
|
||||
let mut permissions = PermissionSummary::default();
|
||||
let mut roles = Vec::new();
|
||||
let mut detected_scopes: Vec<String> = Vec::new();
|
||||
|
||||
// Brevo API keys are full-access; determine capabilities by probing endpoints
|
||||
let senders = fetch_senders(&client, token).await.unwrap_or_else(|err| {
|
||||
warn!("Brevo access-map: senders lookup failed: {err}");
|
||||
Vec::new()
|
||||
});
|
||||
|
||||
let contacts_accessible = probe_contacts(&client, token).await;
|
||||
let templates_accessible = probe_templates(&client, token).await;
|
||||
|
||||
// Brevo doesn't have granular scopes; full API key grants everything
|
||||
detected_scopes.push("account.read".into());
|
||||
|
||||
if !senders.is_empty() {
|
||||
detected_scopes.push("senders.read".into());
|
||||
detected_scopes.push("email.send".into());
|
||||
risk_notes.push(format!(
|
||||
"Token has access to {} configured senders - email sending possible",
|
||||
senders.len()
|
||||
));
|
||||
}
|
||||
|
||||
if contacts_accessible {
|
||||
detected_scopes.push("contacts.read".into());
|
||||
detected_scopes.push("contacts.write".into());
|
||||
}
|
||||
|
||||
if templates_accessible {
|
||||
detected_scopes.push("templates.read".into());
|
||||
detected_scopes.push("templates.write".into());
|
||||
}
|
||||
|
||||
// Since Brevo API keys are full-access, classify as admin
|
||||
permissions.admin.push("full_api_key".into());
|
||||
roles.push(RoleBinding {
|
||||
name: "full_api_key".into(),
|
||||
source: "brevo".into(),
|
||||
permissions: detected_scopes.clone(),
|
||||
});
|
||||
|
||||
for scope in &detected_scopes {
|
||||
match classify_scope(scope) {
|
||||
ScopeRisk::Admin => { /* already added full_api_key above */ }
|
||||
ScopeRisk::Write => permissions.risky.push(scope.clone()),
|
||||
ScopeRisk::Read => permissions.read_only.push(scope.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
// Account-level resource
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: "account".into(),
|
||||
name: username.clone(),
|
||||
permissions: detected_scopes.clone(),
|
||||
risk: severity_to_str(if !senders.is_empty() { Severity::High } else { Severity::Medium })
|
||||
.to_string(),
|
||||
reason: "Brevo account accessible with full API key".to_string(),
|
||||
});
|
||||
|
||||
// Add plan information
|
||||
for plan in &account.plan {
|
||||
let plan_type = plan.plan_type.as_deref().unwrap_or("unknown");
|
||||
let credits = plan.credits.unwrap_or(0.0);
|
||||
risk_notes.push(format!("Plan: {plan_type}, credits: {credits}"));
|
||||
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: "plan".into(),
|
||||
name: plan_type.to_string(),
|
||||
permissions: vec!["account.read".into()],
|
||||
risk: severity_to_str(Severity::Low).to_string(),
|
||||
reason: format!("Brevo plan with {credits} credits"),
|
||||
});
|
||||
}
|
||||
|
||||
// Sender resources
|
||||
for sender in &senders {
|
||||
let sender_name = sender.name.clone().unwrap_or_else(|| "unnamed_sender".to_string());
|
||||
let sender_email = sender.email.clone().unwrap_or_default();
|
||||
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: "sender".into(),
|
||||
name: format!("{sender_name} <{sender_email}>"),
|
||||
permissions: vec!["email.send".into()],
|
||||
risk: severity_to_str(Severity::High).to_string(),
|
||||
reason: "Configured sender - can be used to send email".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if contacts_accessible {
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: "capability".into(),
|
||||
name: "contacts".into(),
|
||||
permissions: vec!["contacts.read".into(), "contacts.write".into()],
|
||||
risk: severity_to_str(Severity::Medium).to_string(),
|
||||
reason: "Contact list accessible - contains recipient PII".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if templates_accessible {
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: "capability".into(),
|
||||
name: "templates".into(),
|
||||
permissions: vec!["templates.read".into(), "templates.write".into()],
|
||||
risk: severity_to_str(Severity::Medium).to_string(),
|
||||
reason: "Email templates accessible and modifiable".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
permissions.admin.sort();
|
||||
permissions.admin.dedup();
|
||||
permissions.risky.sort();
|
||||
permissions.risky.dedup();
|
||||
permissions.read_only.sort();
|
||||
permissions.read_only.dedup();
|
||||
|
||||
let severity = derive_severity(&senders);
|
||||
|
||||
Ok(AccessMapResult {
|
||||
cloud: "sendinblue".into(),
|
||||
identity,
|
||||
roles,
|
||||
permissions,
|
||||
resources,
|
||||
severity,
|
||||
recommendations: build_recommendations(severity),
|
||||
risk_notes,
|
||||
token_details: Some(AccessTokenDetails {
|
||||
name: account.first_name.as_ref().map(|f| {
|
||||
let last = account.last_name.as_deref().unwrap_or("");
|
||||
format!("{f} {last}").trim().to_string()
|
||||
}),
|
||||
username: account.email.clone(),
|
||||
account_type: account.plan.first().and_then(|p| p.plan_type.clone()),
|
||||
company: account.company_name,
|
||||
location: None,
|
||||
email: account.email,
|
||||
url: None,
|
||||
token_type: Some("api_key".into()),
|
||||
created_at: None,
|
||||
last_used_at: None,
|
||||
expires_at: None,
|
||||
user_id: None,
|
||||
scopes: detected_scopes,
|
||||
}),
|
||||
provider_metadata: None,
|
||||
fingerprint: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch_account(client: &Client, token: &str) -> Result<BrevoAccount> {
|
||||
let resp = client
|
||||
.get(format!("{BREVO_API}/v3/account"))
|
||||
.header("api-key", token)
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await
|
||||
.context("Brevo access-map: failed to fetch account info")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(anyhow!("Brevo access-map: account lookup failed with HTTP {}", resp.status()));
|
||||
}
|
||||
|
||||
resp.json().await.context("Brevo access-map: invalid account JSON")
|
||||
}
|
||||
|
||||
async fn fetch_senders(client: &Client, token: &str) -> Result<Vec<BrevoSender>> {
|
||||
let resp = client
|
||||
.get(format!("{BREVO_API}/v3/senders"))
|
||||
.header("api-key", token)
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await
|
||||
.context("Brevo access-map: failed to fetch senders")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(anyhow!("Brevo access-map: senders lookup failed with HTTP {}", resp.status()));
|
||||
}
|
||||
|
||||
let body: BrevoSendersResponse =
|
||||
resp.json().await.context("Brevo access-map: invalid senders JSON")?;
|
||||
Ok(body.senders)
|
||||
}
|
||||
|
||||
async fn probe_contacts(client: &Client, token: &str) -> bool {
|
||||
let resp = client
|
||||
.get(format!("{BREVO_API}/v3/contacts?limit=1"))
|
||||
.header("api-key", token)
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match resp {
|
||||
Ok(r) => r.status().is_success(),
|
||||
Err(err) => {
|
||||
warn!("Brevo access-map: contacts probe failed: {err}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn probe_templates(client: &Client, token: &str) -> bool {
|
||||
let resp = client
|
||||
.get(format!("{BREVO_API}/v3/smtp/templates?limit=1"))
|
||||
.header("api-key", token)
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match resp {
|
||||
Ok(r) => r.status().is_success(),
|
||||
Err(err) => {
|
||||
warn!("Brevo access-map: templates probe failed: {err}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ScopeRisk {
|
||||
Admin,
|
||||
Write,
|
||||
Read,
|
||||
}
|
||||
|
||||
fn classify_scope(scope: &str) -> ScopeRisk {
|
||||
match scope {
|
||||
"full_api_key" => ScopeRisk::Admin,
|
||||
s if s.contains(".write") || s == "email.send" => ScopeRisk::Write,
|
||||
_ => ScopeRisk::Read,
|
||||
}
|
||||
}
|
||||
|
||||
fn derive_severity(senders: &[BrevoSender]) -> Severity {
|
||||
if !senders.is_empty() {
|
||||
return Severity::High;
|
||||
}
|
||||
|
||||
Severity::Medium
|
||||
}
|
||||
|
||||
fn severity_to_str(severity: Severity) -> &'static str {
|
||||
match severity {
|
||||
Severity::Low => "low",
|
||||
Severity::Medium => "medium",
|
||||
Severity::High => "high",
|
||||
Severity::Critical => "critical",
|
||||
}
|
||||
}
|
||||
274
src/access_map/shopify.rs
Normal file
274
src/access_map/shopify.rs
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
use anyhow::{anyhow, Context, Result};
|
||||
use reqwest::{header, Client};
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::{cli::commands::access_map::AccessMapArgs, validation::GLOBAL_USER_AGENT};
|
||||
|
||||
use super::{
|
||||
build_recommendations, AccessMapResult, AccessSummary, AccessTokenDetails, PermissionSummary,
|
||||
ResourceExposure, RoleBinding, Severity,
|
||||
};
|
||||
|
||||
const SHOPIFY_API_VERSION: &str = "2024-10";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ShopResponse {
|
||||
shop: ShopInfo,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ShopInfo {
|
||||
id: Option<u64>,
|
||||
name: Option<String>,
|
||||
email: Option<String>,
|
||||
domain: Option<String>,
|
||||
plan_name: Option<String>,
|
||||
}
|
||||
|
||||
/// Entry point when invoked via the CLI `access-map shopify` subcommand.
|
||||
pub async fn map_access(args: &AccessMapArgs) -> Result<AccessMapResult> {
|
||||
let path = args.credential_path.as_deref().ok_or_else(|| {
|
||||
anyhow!("Shopify access-map requires a credential file with token and subdomain")
|
||||
})?;
|
||||
let raw = std::fs::read_to_string(path).with_context(|| {
|
||||
format!("Failed to read Shopify credential file from {}", path.display())
|
||||
})?;
|
||||
let (token, subdomain) = parse_shopify_credentials(&raw)?;
|
||||
map_access_from_token_and_subdomain(&token, &subdomain).await
|
||||
}
|
||||
|
||||
/// Maps a Shopify access token and store subdomain to an access profile.
|
||||
pub async fn map_access_from_token_and_subdomain(
|
||||
token: &str,
|
||||
subdomain: &str,
|
||||
) -> Result<AccessMapResult> {
|
||||
let subdomain = subdomain.trim().trim_matches('/').to_ascii_lowercase();
|
||||
if subdomain.is_empty() {
|
||||
return Err(anyhow!("Shopify access-map requires a non-empty store subdomain"));
|
||||
}
|
||||
|
||||
let base_url = format!("https://{subdomain}.myshopify.com");
|
||||
let client = Client::builder()
|
||||
.user_agent(GLOBAL_USER_AGENT.as_str())
|
||||
.build()
|
||||
.context("Failed to build Shopify HTTP client")?;
|
||||
|
||||
let mut risk_notes = Vec::new();
|
||||
let mut permissions = PermissionSummary::default();
|
||||
let mut detected_scopes: Vec<String> = Vec::new();
|
||||
|
||||
// Fetch shop info
|
||||
let shop = fetch_shop(&client, token, &base_url).await?;
|
||||
|
||||
let shop_id = shop.id.map(|id| id.to_string()).unwrap_or_default();
|
||||
let shop_name = shop.name.clone().unwrap_or_default();
|
||||
let shop_email = shop.email.clone();
|
||||
let shop_domain = shop.domain.clone();
|
||||
let _plan_name = shop.plan_name.clone();
|
||||
|
||||
// Probe endpoints to detect scopes
|
||||
let probes =
|
||||
[("orders", "read_orders"), ("customers", "read_customers"), ("products", "read_products")];
|
||||
|
||||
for (resource, scope) in &probes {
|
||||
match probe_endpoint(&client, token, &base_url, resource).await {
|
||||
Ok(true) => {
|
||||
detected_scopes.push(scope.to_string());
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(err) => {
|
||||
warn!("Shopify access-map: probe for {resource} failed: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Classify scopes
|
||||
let admin_scopes = ["write_customers", "write_orders", "write_products", "write_script_tags"];
|
||||
let risky_scopes = ["read_customers", "read_orders", "write_products"];
|
||||
let read_scopes = ["read_products", "read_inventory"];
|
||||
|
||||
for scope in &detected_scopes {
|
||||
if admin_scopes.contains(&scope.as_str()) {
|
||||
permissions.admin.push(scope.clone());
|
||||
} else if risky_scopes.contains(&scope.as_str()) {
|
||||
permissions.risky.push(scope.clone());
|
||||
} else if read_scopes.contains(&scope.as_str()) {
|
||||
permissions.read_only.push(scope.clone());
|
||||
}
|
||||
}
|
||||
|
||||
permissions.admin.sort();
|
||||
permissions.admin.dedup();
|
||||
permissions.risky.sort();
|
||||
permissions.risky.dedup();
|
||||
permissions.read_only.sort();
|
||||
permissions.read_only.dedup();
|
||||
|
||||
// Determine severity
|
||||
let has_customer_order_write =
|
||||
detected_scopes.iter().any(|s| s == "write_customers" || s == "write_orders");
|
||||
let has_customer_order_read =
|
||||
detected_scopes.iter().any(|s| s == "read_customers" || s == "read_orders");
|
||||
|
||||
let severity = if has_customer_order_write {
|
||||
Severity::Critical
|
||||
} else if has_customer_order_read {
|
||||
Severity::High
|
||||
} else {
|
||||
Severity::Medium
|
||||
};
|
||||
|
||||
if has_customer_order_write {
|
||||
risk_notes.push("Token has write access to customer or order data".to_string());
|
||||
}
|
||||
if has_customer_order_read {
|
||||
risk_notes.push("Token can read customer PII or financial order data".to_string());
|
||||
}
|
||||
|
||||
let roles = vec![RoleBinding {
|
||||
name: "shopify_access_token".into(),
|
||||
source: "shopify".into(),
|
||||
permissions: detected_scopes.clone(),
|
||||
}];
|
||||
|
||||
let mut resources = vec![ResourceExposure {
|
||||
resource_type: "shopify_store".into(),
|
||||
name: shop_name.clone(),
|
||||
permissions: detected_scopes.clone(),
|
||||
risk: severity_to_str(severity).to_string(),
|
||||
reason: "Shopify store accessible with this token".to_string(),
|
||||
}];
|
||||
|
||||
if detected_scopes.iter().any(|s| s == "read_customers") {
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: "customer_data".into(),
|
||||
name: format!("{subdomain} customers"),
|
||||
permissions: vec!["read_customers".into()],
|
||||
risk: severity_to_str(Severity::High).to_string(),
|
||||
reason: "Customer PII is accessible".to_string(),
|
||||
});
|
||||
}
|
||||
if detected_scopes.iter().any(|s| s == "read_orders") {
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: "order_data".into(),
|
||||
name: format!("{subdomain} orders"),
|
||||
permissions: vec!["read_orders".into()],
|
||||
risk: severity_to_str(Severity::High).to_string(),
|
||||
reason: "Financial order data is accessible".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(AccessMapResult {
|
||||
cloud: "shopify".into(),
|
||||
identity: AccessSummary {
|
||||
id: shop_email.clone().unwrap_or_else(|| shop_name.clone()),
|
||||
access_type: "token".into(),
|
||||
project: Some(subdomain.clone()),
|
||||
tenant: shop_domain,
|
||||
account_id: Some(shop_id.clone()),
|
||||
},
|
||||
roles,
|
||||
permissions,
|
||||
resources,
|
||||
severity,
|
||||
recommendations: build_recommendations(severity),
|
||||
risk_notes,
|
||||
token_details: Some(AccessTokenDetails {
|
||||
name: Some(shop_name),
|
||||
username: None,
|
||||
account_type: Some("shopify_access_token".into()),
|
||||
company: None,
|
||||
location: None,
|
||||
email: shop_email,
|
||||
url: Some(base_url),
|
||||
token_type: Some("access_token".into()),
|
||||
created_at: None,
|
||||
last_used_at: None,
|
||||
expires_at: None,
|
||||
user_id: Some(shop_id),
|
||||
scopes: detected_scopes,
|
||||
}),
|
||||
provider_metadata: None,
|
||||
fingerprint: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_shopify_credentials(raw: &str) -> Result<(String, String)> {
|
||||
if let Ok(json) = serde_json::from_str::<Value>(raw) {
|
||||
let token = json
|
||||
.get("token")
|
||||
.or_else(|| json.get("access_token"))
|
||||
.or_else(|| json.get("shopify_token"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim().to_string());
|
||||
let subdomain = json
|
||||
.get("subdomain")
|
||||
.or_else(|| json.get("store"))
|
||||
.or_else(|| json.get("shop"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim().to_string());
|
||||
|
||||
if let (Some(token), Some(subdomain)) = (token, subdomain) {
|
||||
return Ok((token, subdomain));
|
||||
}
|
||||
}
|
||||
|
||||
let lines: Vec<&str> = raw
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty() && !line.starts_with('#'))
|
||||
.collect();
|
||||
if lines.len() >= 2 {
|
||||
return Ok((lines[0].to_string(), lines[1].to_string()));
|
||||
}
|
||||
|
||||
Err(anyhow!(
|
||||
"Shopify credential format not recognized. Provide JSON with token + subdomain, or two lines (token, subdomain)."
|
||||
))
|
||||
}
|
||||
|
||||
async fn fetch_shop(client: &Client, token: &str, base_url: &str) -> Result<ShopInfo> {
|
||||
let resp = client
|
||||
.get(format!("{base_url}/admin/api/{SHOPIFY_API_VERSION}/shop.json"))
|
||||
.header("X-Shopify-Access-Token", token)
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await
|
||||
.context("Shopify access-map: failed to query shop endpoint")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(anyhow!("Shopify access-map: shop endpoint returned HTTP {}", resp.status()));
|
||||
}
|
||||
|
||||
let shop_resp: ShopResponse =
|
||||
resp.json().await.context("Shopify access-map: invalid shop JSON")?;
|
||||
Ok(shop_resp.shop)
|
||||
}
|
||||
|
||||
async fn probe_endpoint(
|
||||
client: &Client,
|
||||
token: &str,
|
||||
base_url: &str,
|
||||
resource: &str,
|
||||
) -> Result<bool> {
|
||||
let resp = client
|
||||
.get(format!("{base_url}/admin/api/{SHOPIFY_API_VERSION}/{resource}.json?limit=1"))
|
||||
.header("X-Shopify-Access-Token", token)
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await
|
||||
.context("Shopify access-map: probe request failed")?;
|
||||
|
||||
Ok(resp.status().is_success())
|
||||
}
|
||||
|
||||
fn severity_to_str(severity: Severity) -> &'static str {
|
||||
match severity {
|
||||
Severity::Low => "low",
|
||||
Severity::Medium => "medium",
|
||||
Severity::High => "high",
|
||||
Severity::Critical => "critical",
|
||||
}
|
||||
}
|
||||
328
src/access_map/square.rs
Normal file
328
src/access_map/square.rs
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
use anyhow::{anyhow, Context, Result};
|
||||
use reqwest::{header, Client};
|
||||
use serde::Deserialize;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::{cli::commands::access_map::AccessMapArgs, validation::GLOBAL_USER_AGENT};
|
||||
|
||||
use super::{
|
||||
build_recommendations, AccessMapResult, AccessSummary, AccessTokenDetails, PermissionSummary,
|
||||
ResourceExposure, RoleBinding, Severity,
|
||||
};
|
||||
|
||||
const SQUARE_API: &str = "https://connect.squareup.com";
|
||||
const SQUARE_VERSION: &str = "2024-01-18";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SquareMerchantResponse {
|
||||
#[serde(default)]
|
||||
merchant: Vec<SquareMerchant>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SquareMerchant {
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
#[serde(default)]
|
||||
business_name: Option<String>,
|
||||
#[serde(default)]
|
||||
country: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
#[serde(default)]
|
||||
currency: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
#[serde(default)]
|
||||
status: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SquareLocationsResponse {
|
||||
#[serde(default)]
|
||||
locations: Vec<SquareLocation>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SquareLocation {
|
||||
#[allow(dead_code)]
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
status: Option<String>,
|
||||
#[serde(default, rename = "type")]
|
||||
location_type: Option<String>,
|
||||
#[serde(default)]
|
||||
capabilities: Vec<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 Square token from {}", path.display()))?;
|
||||
raw.trim().to_string()
|
||||
} else {
|
||||
return Err(anyhow!("Square 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 Square HTTP client")?;
|
||||
|
||||
let key_type = classify_key_type(token);
|
||||
|
||||
let merchant_resp = fetch_merchant(&client, token).await?;
|
||||
let merchant = merchant_resp.merchant.first();
|
||||
|
||||
let merchant_id = merchant.and_then(|m| m.id.clone()).unwrap_or_else(|| "unknown".to_string());
|
||||
let business_name = merchant.and_then(|m| m.business_name.clone());
|
||||
let display_name = business_name.clone().unwrap_or_else(|| merchant_id.clone());
|
||||
|
||||
let identity = AccessSummary {
|
||||
id: display_name.clone(),
|
||||
access_type: key_type.label.to_string(),
|
||||
project: None,
|
||||
tenant: merchant.and_then(|m| m.country.clone()),
|
||||
account_id: merchant.and_then(|m| m.id.clone()),
|
||||
};
|
||||
|
||||
let mut risk_notes = Vec::new();
|
||||
let mut resources = Vec::new();
|
||||
let mut permissions = PermissionSummary::default();
|
||||
let mut roles = Vec::new();
|
||||
let mut detected_scopes: Vec<String> = Vec::new();
|
||||
|
||||
// Merchant-level resource.
|
||||
permissions.admin.push("merchant:read".to_string());
|
||||
detected_scopes.push("merchant:read".to_string());
|
||||
|
||||
// Enumerate locations.
|
||||
let locations = list_locations(&client, token).await.unwrap_or_else(|err| {
|
||||
warn!("Square access-map: locations enumeration failed: {err}");
|
||||
Vec::new()
|
||||
});
|
||||
|
||||
if !locations.is_empty() {
|
||||
permissions.read_only.push("locations:read".to_string());
|
||||
detected_scopes.push("locations:read".to_string());
|
||||
}
|
||||
|
||||
for loc in &locations {
|
||||
let loc_name = loc.name.clone().unwrap_or_else(|| "unknown_location".to_string());
|
||||
let loc_type = loc.location_type.clone().unwrap_or_default();
|
||||
let loc_status = loc.status.clone().unwrap_or_default();
|
||||
let has_cc = loc.capabilities.iter().any(|c| c == "CREDIT_CARD_PROCESSING");
|
||||
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: "location".into(),
|
||||
name: loc_name,
|
||||
permissions: loc.capabilities.clone(),
|
||||
risk: severity_to_str(if has_cc { Severity::Medium } else { Severity::Low })
|
||||
.to_string(),
|
||||
reason: format!(
|
||||
"Square location ({}, {}){}",
|
||||
loc_type,
|
||||
loc_status,
|
||||
if has_cc { " with credit card processing" } else { "" }
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Probe additional capabilities.
|
||||
let probes: &[(&str, &str, ScopeRisk)] = &[
|
||||
("/v2/customers?limit=1", "customers:read", ScopeRisk::Risky),
|
||||
("/v2/payments?limit=1", "payments:read", ScopeRisk::Risky),
|
||||
("/v2/catalog/list?limit=1", "catalog:read", ScopeRisk::Read),
|
||||
];
|
||||
|
||||
for (endpoint, scope_name, risk) in probes {
|
||||
match probe_endpoint(&client, token, endpoint).await {
|
||||
Ok(true) => {
|
||||
detected_scopes.push(scope_name.to_string());
|
||||
match risk {
|
||||
ScopeRisk::Admin => permissions.admin.push(scope_name.to_string()),
|
||||
ScopeRisk::Risky => permissions.risky.push(scope_name.to_string()),
|
||||
ScopeRisk::Read => permissions.read_only.push(scope_name.to_string()),
|
||||
}
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(err) => {
|
||||
warn!("Square access-map: probe for {scope_name} failed: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
roles.push(RoleBinding {
|
||||
name: format!("key_type:{}", key_type.label),
|
||||
source: "square".into(),
|
||||
permissions: detected_scopes.clone(),
|
||||
});
|
||||
|
||||
// Account resource.
|
||||
let has_payments = detected_scopes.iter().any(|s| s == "payments:read");
|
||||
let has_customers = detected_scopes.iter().any(|s| s == "customers:read");
|
||||
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: "merchant".into(),
|
||||
name: merchant_id.clone(),
|
||||
permissions: detected_scopes.clone(),
|
||||
risk: severity_to_str(if has_payments || has_customers {
|
||||
Severity::High
|
||||
} else {
|
||||
Severity::Medium
|
||||
})
|
||||
.to_string(),
|
||||
reason: format!("Square merchant accessible via {} token", key_type.label),
|
||||
});
|
||||
|
||||
if has_payments {
|
||||
risk_notes.push("Token can access payment data (financial transactions)".into());
|
||||
}
|
||||
if has_customers {
|
||||
risk_notes.push("Token can access customer data (PII)".into());
|
||||
}
|
||||
if key_type.is_oauth {
|
||||
risk_notes.push("OAuth token — may have broad scopes granted during authorization".into());
|
||||
}
|
||||
|
||||
permissions.admin.sort();
|
||||
permissions.admin.dedup();
|
||||
permissions.risky.sort();
|
||||
permissions.risky.dedup();
|
||||
permissions.read_only.sort();
|
||||
permissions.read_only.dedup();
|
||||
|
||||
let severity = derive_severity(has_payments, has_customers, &detected_scopes);
|
||||
|
||||
Ok(AccessMapResult {
|
||||
cloud: "square".into(),
|
||||
identity,
|
||||
roles,
|
||||
permissions,
|
||||
resources,
|
||||
severity,
|
||||
recommendations: build_recommendations(severity),
|
||||
risk_notes,
|
||||
token_details: Some(AccessTokenDetails {
|
||||
name: business_name,
|
||||
username: None,
|
||||
account_type: Some(key_type.label.to_string()),
|
||||
company: merchant.and_then(|m| m.business_name.clone()),
|
||||
location: merchant.and_then(|m| m.country.clone()),
|
||||
email: None,
|
||||
url: None,
|
||||
token_type: Some(key_type.label.to_string()),
|
||||
created_at: None,
|
||||
last_used_at: None,
|
||||
expires_at: None,
|
||||
user_id: merchant.and_then(|m| m.id.clone()),
|
||||
scopes: detected_scopes,
|
||||
}),
|
||||
provider_metadata: None,
|
||||
fingerprint: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch_merchant(client: &Client, token: &str) -> Result<SquareMerchantResponse> {
|
||||
let resp = client
|
||||
.get(format!("{SQUARE_API}/v2/merchants/me"))
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header("Square-Version", SQUARE_VERSION)
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await
|
||||
.context("Square access-map: failed to fetch merchant info")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(anyhow!(
|
||||
"Square access-map: merchant lookup failed with HTTP {}",
|
||||
resp.status()
|
||||
));
|
||||
}
|
||||
|
||||
resp.json().await.context("Square access-map: invalid merchant JSON")
|
||||
}
|
||||
|
||||
async fn list_locations(client: &Client, token: &str) -> Result<Vec<SquareLocation>> {
|
||||
let resp = client
|
||||
.get(format!("{SQUARE_API}/v2/locations"))
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header("Square-Version", SQUARE_VERSION)
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await
|
||||
.context("Square access-map: failed to list locations")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
warn!("Square access-map: locations enumeration failed with HTTP {}", resp.status());
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let body: SquareLocationsResponse =
|
||||
resp.json().await.context("Square access-map: invalid locations JSON")?;
|
||||
Ok(body.locations)
|
||||
}
|
||||
|
||||
async fn probe_endpoint(client: &Client, token: &str, endpoint: &str) -> Result<bool> {
|
||||
let resp = client
|
||||
.get(format!("{SQUARE_API}{endpoint}"))
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header("Square-Version", SQUARE_VERSION)
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await
|
||||
.context("Square access-map: probe request failed")?;
|
||||
|
||||
Ok(resp.status().is_success())
|
||||
}
|
||||
|
||||
struct KeyClassification {
|
||||
label: &'static str,
|
||||
is_oauth: bool,
|
||||
}
|
||||
|
||||
fn classify_key_type(token: &str) -> KeyClassification {
|
||||
if token.starts_with("EAAA") {
|
||||
KeyClassification { label: "oauth_token", is_oauth: true }
|
||||
} else if token.starts_with("sq0atp-") {
|
||||
KeyClassification { label: "personal_access_token", is_oauth: false }
|
||||
} else {
|
||||
KeyClassification { label: "unknown_token", is_oauth: false }
|
||||
}
|
||||
}
|
||||
|
||||
enum ScopeRisk {
|
||||
#[allow(dead_code)]
|
||||
Admin,
|
||||
Risky,
|
||||
Read,
|
||||
}
|
||||
|
||||
fn derive_severity(has_payments: bool, has_customers: bool, scopes: &[String]) -> Severity {
|
||||
if has_payments || has_customers {
|
||||
return Severity::High;
|
||||
}
|
||||
|
||||
let has_catalog = scopes.iter().any(|s| s == "catalog:read");
|
||||
let has_locations = scopes.iter().any(|s| s == "locations:read");
|
||||
|
||||
if has_catalog || has_locations {
|
||||
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",
|
||||
}
|
||||
}
|
||||
354
src/access_map/stripe.rs
Normal file
354
src/access_map/stripe.rs
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
use anyhow::{anyhow, Context, Result};
|
||||
use reqwest::{header, Client};
|
||||
use serde::Deserialize;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::{cli::commands::access_map::AccessMapArgs, validation::GLOBAL_USER_AGENT};
|
||||
|
||||
use super::{
|
||||
build_recommendations, AccessMapResult, AccessSummary, AccessTokenDetails, PermissionSummary,
|
||||
ResourceExposure, RoleBinding, Severity,
|
||||
};
|
||||
|
||||
const STRIPE_API: &str = "https://api.stripe.com";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct StripeBusinessProfile {
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct StripeAccount {
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
#[serde(default)]
|
||||
business_profile: Option<StripeBusinessProfile>,
|
||||
#[serde(default)]
|
||||
email: Option<String>,
|
||||
#[serde(default)]
|
||||
country: Option<String>,
|
||||
#[serde(default)]
|
||||
charges_enabled: Option<bool>,
|
||||
#[serde(default)]
|
||||
payouts_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
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 Stripe token from {}", path.display()))?;
|
||||
raw.trim().to_string()
|
||||
} else {
|
||||
return Err(anyhow!("Stripe 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 Stripe HTTP client")?;
|
||||
|
||||
let key_type = classify_key_prefix(token);
|
||||
|
||||
let account = fetch_account(&client, token).await?;
|
||||
|
||||
let account_id = account.id.clone().unwrap_or_else(|| "unknown".to_string());
|
||||
let business_name = account.business_profile.as_ref().and_then(|bp| bp.name.clone());
|
||||
let display_name = business_name
|
||||
.clone()
|
||||
.or_else(|| account.email.clone())
|
||||
.unwrap_or_else(|| account_id.clone());
|
||||
|
||||
let identity = AccessSummary {
|
||||
id: display_name.clone(),
|
||||
access_type: key_type.label.to_string(),
|
||||
project: None,
|
||||
tenant: account.country.clone(),
|
||||
account_id: account.id.clone(),
|
||||
};
|
||||
|
||||
let mut risk_notes = Vec::new();
|
||||
let mut resources = Vec::new();
|
||||
let mut permissions = PermissionSummary::default();
|
||||
let mut roles = Vec::new();
|
||||
let mut detected_scopes: Vec<String> = Vec::new();
|
||||
|
||||
// Full secret keys have unrestricted access.
|
||||
if key_type.is_full_secret {
|
||||
permissions.admin.push("full_api_access".to_string());
|
||||
detected_scopes.push("full_api_access".to_string());
|
||||
roles.push(RoleBinding {
|
||||
name: format!("key_type:{}", key_type.label),
|
||||
source: "stripe".into(),
|
||||
permissions: vec!["full_api_access".to_string()],
|
||||
});
|
||||
|
||||
if key_type.is_live {
|
||||
risk_notes.push("Live secret key grants unrestricted access to all Stripe API resources including charges, refunds, and customer PII".into());
|
||||
}
|
||||
} else if key_type.is_restricted {
|
||||
// Probe individual capabilities for restricted keys.
|
||||
let probes: &[(&str, &str)] = &[
|
||||
("/v1/balance", "balance:read"),
|
||||
("/v1/charges?limit=1", "charges:read"),
|
||||
("/v1/customers?limit=1", "customers:read"),
|
||||
("/v1/payment_intents?limit=1", "payment_intents:read"),
|
||||
("/v1/subscriptions?limit=1", "subscriptions:read"),
|
||||
("/v1/products?limit=1", "products:read"),
|
||||
];
|
||||
|
||||
for (endpoint, scope_name) in probes {
|
||||
match probe_endpoint(&client, token, endpoint).await {
|
||||
Ok(true) => {
|
||||
detected_scopes.push(scope_name.to_string());
|
||||
let risk = classify_scope(scope_name);
|
||||
match risk {
|
||||
ScopeRisk::Admin => permissions.admin.push(scope_name.to_string()),
|
||||
ScopeRisk::Risky => permissions.risky.push(scope_name.to_string()),
|
||||
ScopeRisk::Read => permissions.read_only.push(scope_name.to_string()),
|
||||
}
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(err) => {
|
||||
warn!("Stripe access-map: probe for {scope_name} failed: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
roles.push(RoleBinding {
|
||||
name: format!("key_type:{}", key_type.label),
|
||||
source: "stripe".into(),
|
||||
permissions: detected_scopes.clone(),
|
||||
});
|
||||
|
||||
if key_type.is_live && !detected_scopes.is_empty() {
|
||||
risk_notes.push(format!(
|
||||
"Restricted live key with {} accessible scope(s)",
|
||||
detected_scopes.len()
|
||||
));
|
||||
}
|
||||
} else if key_type.is_publishable {
|
||||
permissions.read_only.push("publishable_key".to_string());
|
||||
detected_scopes.push("publishable_key".to_string());
|
||||
roles.push(RoleBinding {
|
||||
name: format!("key_type:{}", key_type.label),
|
||||
source: "stripe".into(),
|
||||
permissions: vec!["publishable_key".to_string()],
|
||||
});
|
||||
risk_notes
|
||||
.push("Publishable key — intended for client-side use, limited capabilities".into());
|
||||
}
|
||||
|
||||
// Account-level resource.
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: "account".into(),
|
||||
name: account_id.clone(),
|
||||
permissions: detected_scopes.clone(),
|
||||
risk: severity_to_str(if key_type.is_full_secret && key_type.is_live {
|
||||
Severity::Critical
|
||||
} else if key_type.is_live {
|
||||
Severity::High
|
||||
} else {
|
||||
Severity::Low
|
||||
})
|
||||
.to_string(),
|
||||
reason: format!("Stripe account accessible via {} key", key_type.label),
|
||||
});
|
||||
|
||||
if account.charges_enabled == Some(true) {
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: "capability".into(),
|
||||
name: "charges_enabled".into(),
|
||||
permissions: vec!["charges".to_string()],
|
||||
risk: severity_to_str(if key_type.is_live { Severity::High } else { Severity::Low })
|
||||
.to_string(),
|
||||
reason: "Account can process charges".into(),
|
||||
});
|
||||
}
|
||||
|
||||
if account.payouts_enabled == Some(true) {
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: "capability".into(),
|
||||
name: "payouts_enabled".into(),
|
||||
permissions: vec!["payouts".to_string()],
|
||||
risk: severity_to_str(if key_type.is_live { Severity::High } else { Severity::Low })
|
||||
.to_string(),
|
||||
reason: "Account can process payouts".into(),
|
||||
});
|
||||
}
|
||||
|
||||
permissions.admin.sort();
|
||||
permissions.admin.dedup();
|
||||
permissions.risky.sort();
|
||||
permissions.risky.dedup();
|
||||
permissions.read_only.sort();
|
||||
permissions.read_only.dedup();
|
||||
|
||||
let severity = derive_severity(&key_type, &detected_scopes);
|
||||
|
||||
Ok(AccessMapResult {
|
||||
cloud: "stripe".into(),
|
||||
identity,
|
||||
roles,
|
||||
permissions,
|
||||
resources,
|
||||
severity,
|
||||
recommendations: build_recommendations(severity),
|
||||
risk_notes,
|
||||
token_details: Some(AccessTokenDetails {
|
||||
name: business_name,
|
||||
username: account.email.clone(),
|
||||
account_type: Some(key_type.label.to_string()),
|
||||
company: None,
|
||||
location: account.country,
|
||||
email: account.email,
|
||||
url: None,
|
||||
token_type: Some(key_type.label.to_string()),
|
||||
created_at: None,
|
||||
last_used_at: None,
|
||||
expires_at: None,
|
||||
user_id: account.id,
|
||||
scopes: detected_scopes,
|
||||
}),
|
||||
provider_metadata: None,
|
||||
fingerprint: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch_account(client: &Client, token: &str) -> Result<StripeAccount> {
|
||||
let resp = client
|
||||
.get(format!("{STRIPE_API}/v1/account"))
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await
|
||||
.context("Stripe access-map: failed to fetch account info")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(anyhow!(
|
||||
"Stripe access-map: account lookup failed with HTTP {}",
|
||||
resp.status()
|
||||
));
|
||||
}
|
||||
|
||||
resp.json().await.context("Stripe access-map: invalid account JSON")
|
||||
}
|
||||
|
||||
async fn probe_endpoint(client: &Client, token: &str, endpoint: &str) -> Result<bool> {
|
||||
let resp = client
|
||||
.get(format!("{STRIPE_API}{endpoint}"))
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await
|
||||
.context("Stripe access-map: probe request failed")?;
|
||||
|
||||
Ok(resp.status().is_success())
|
||||
}
|
||||
|
||||
struct KeyType {
|
||||
label: &'static str,
|
||||
is_live: bool,
|
||||
is_full_secret: bool,
|
||||
is_restricted: bool,
|
||||
is_publishable: bool,
|
||||
}
|
||||
|
||||
fn classify_key_prefix(token: &str) -> KeyType {
|
||||
if token.starts_with("sk_live_") {
|
||||
KeyType {
|
||||
label: "live_secret_key",
|
||||
is_live: true,
|
||||
is_full_secret: true,
|
||||
is_restricted: false,
|
||||
is_publishable: false,
|
||||
}
|
||||
} else if token.starts_with("sk_test_") {
|
||||
KeyType {
|
||||
label: "test_secret_key",
|
||||
is_live: false,
|
||||
is_full_secret: true,
|
||||
is_restricted: false,
|
||||
is_publishable: false,
|
||||
}
|
||||
} else if token.starts_with("rk_live_") {
|
||||
KeyType {
|
||||
label: "live_restricted_key",
|
||||
is_live: true,
|
||||
is_full_secret: false,
|
||||
is_restricted: true,
|
||||
is_publishable: false,
|
||||
}
|
||||
} else if token.starts_with("rk_test_") {
|
||||
KeyType {
|
||||
label: "test_restricted_key",
|
||||
is_live: false,
|
||||
is_full_secret: false,
|
||||
is_restricted: true,
|
||||
is_publishable: false,
|
||||
}
|
||||
} else if token.starts_with("pk_live_") {
|
||||
KeyType {
|
||||
label: "live_publishable_key",
|
||||
is_live: true,
|
||||
is_full_secret: false,
|
||||
is_restricted: false,
|
||||
is_publishable: true,
|
||||
}
|
||||
} else {
|
||||
KeyType {
|
||||
label: "unknown_key",
|
||||
is_live: false,
|
||||
is_full_secret: false,
|
||||
is_restricted: false,
|
||||
is_publishable: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ScopeRisk {
|
||||
#[allow(dead_code)]
|
||||
Admin,
|
||||
Risky,
|
||||
Read,
|
||||
}
|
||||
|
||||
fn classify_scope(scope: &str) -> ScopeRisk {
|
||||
match scope {
|
||||
"charges:read" | "payment_intents:read" | "customers:read" => ScopeRisk::Risky,
|
||||
"balance:read" | "products:read" | "subscriptions:read" => ScopeRisk::Read,
|
||||
_ => ScopeRisk::Read,
|
||||
}
|
||||
}
|
||||
|
||||
fn derive_severity(key_type: &KeyType, scopes: &[String]) -> Severity {
|
||||
if key_type.is_full_secret && key_type.is_live {
|
||||
return Severity::Critical;
|
||||
}
|
||||
|
||||
if key_type.is_restricted && key_type.is_live {
|
||||
let has_risky = scopes.iter().any(|s| {
|
||||
matches!(s.as_str(), "charges:read" | "payment_intents:read" | "customers:read")
|
||||
});
|
||||
if has_risky {
|
||||
return Severity::High;
|
||||
}
|
||||
return Severity::Medium;
|
||||
}
|
||||
|
||||
// Test keys and publishable keys are low severity.
|
||||
Severity::Low
|
||||
}
|
||||
|
||||
fn severity_to_str(severity: Severity) -> &'static str {
|
||||
match severity {
|
||||
Severity::Low => "low",
|
||||
Severity::Medium => "medium",
|
||||
Severity::High => "high",
|
||||
Severity::Critical => "critical",
|
||||
}
|
||||
}
|
||||
444
src/access_map/terraform.rs
Normal file
444
src/access_map/terraform.rs
Normal file
|
|
@ -0,0 +1,444 @@
|
|||
use anyhow::{anyhow, Context, Result};
|
||||
use reqwest::{header, Client};
|
||||
use serde::Deserialize;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::{cli::commands::access_map::AccessMapArgs, validation::GLOBAL_USER_AGENT};
|
||||
|
||||
use super::{
|
||||
build_recommendations, AccessMapResult, AccessSummary, AccessTokenDetails, PermissionSummary,
|
||||
ResourceExposure, RoleBinding, Severity,
|
||||
};
|
||||
|
||||
const TERRAFORM_API: &str = "https://app.terraform.io";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TerraformAccountResponse {
|
||||
#[serde(default)]
|
||||
data: Option<TerraformAccountData>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TerraformAccountData {
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
#[serde(default)]
|
||||
attributes: Option<TerraformAccountAttributes>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TerraformAccountAttributes {
|
||||
#[serde(default)]
|
||||
username: Option<String>,
|
||||
#[serde(default)]
|
||||
email: Option<String>,
|
||||
#[serde(default, rename = "is-service-account")]
|
||||
is_service_account: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TerraformOrgsResponse {
|
||||
#[serde(default)]
|
||||
data: Vec<TerraformOrgData>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TerraformOrgData {
|
||||
#[allow(dead_code)]
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
#[serde(default)]
|
||||
attributes: Option<TerraformOrgAttributes>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TerraformOrgAttributes {
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
#[serde(default)]
|
||||
email: Option<String>,
|
||||
#[serde(default)]
|
||||
permissions: Option<TerraformOrgPermissions>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TerraformOrgPermissions {
|
||||
#[serde(default, rename = "can-create-workspace")]
|
||||
can_create_workspace: Option<bool>,
|
||||
#[serde(default, rename = "can-manage-modules")]
|
||||
can_manage_modules: Option<bool>,
|
||||
#[serde(default, rename = "can-manage-providers")]
|
||||
can_manage_providers: Option<bool>,
|
||||
#[serde(default, rename = "can-update")]
|
||||
can_update: Option<bool>,
|
||||
#[serde(default, rename = "can-destroy")]
|
||||
can_destroy: Option<bool>,
|
||||
#[serde(default, rename = "can-access-via-teams")]
|
||||
can_access_via_teams: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TerraformWorkspacesResponse {
|
||||
#[serde(default)]
|
||||
data: Vec<TerraformWorkspaceData>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TerraformWorkspaceData {
|
||||
#[allow(dead_code)]
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
#[serde(default)]
|
||||
attributes: Option<TerraformWorkspaceAttributes>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TerraformWorkspaceAttributes {
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
locked: Option<bool>,
|
||||
#[serde(default, rename = "auto-apply")]
|
||||
auto_apply: Option<bool>,
|
||||
}
|
||||
|
||||
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 Terraform token from {}", path.display()))?;
|
||||
raw.trim().to_string()
|
||||
} else {
|
||||
return Err(anyhow!("Terraform 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 Terraform HTTP client")?;
|
||||
|
||||
let account = fetch_account(&client, token).await?;
|
||||
|
||||
let account_data = account.data.as_ref();
|
||||
let attrs = account_data.and_then(|d| d.attributes.as_ref());
|
||||
|
||||
let username = attrs
|
||||
.and_then(|a| a.username.clone())
|
||||
.or_else(|| attrs.and_then(|a| a.email.clone()))
|
||||
.unwrap_or_else(|| "terraform_user".to_string());
|
||||
|
||||
let is_service_account = attrs.and_then(|a| a.is_service_account).unwrap_or(false);
|
||||
|
||||
let identity = AccessSummary {
|
||||
id: username.clone(),
|
||||
access_type: if is_service_account { "service_account".into() } else { "user".into() },
|
||||
project: None,
|
||||
tenant: None,
|
||||
account_id: account_data.and_then(|d| d.id.clone()),
|
||||
};
|
||||
|
||||
let mut risk_notes = Vec::new();
|
||||
let mut resources = Vec::new();
|
||||
let mut permissions = PermissionSummary::default();
|
||||
let mut roles = Vec::new();
|
||||
let mut all_scopes: Vec<String> = Vec::new();
|
||||
|
||||
let orgs = list_organizations(&client, token).await.unwrap_or_else(|err| {
|
||||
warn!("Terraform access-map: organization enumeration failed: {err}");
|
||||
Vec::new()
|
||||
});
|
||||
|
||||
let mut has_org_admin = false;
|
||||
let mut has_workspace_write = false;
|
||||
|
||||
for org in &orgs {
|
||||
let org_attrs = org.attributes.as_ref();
|
||||
let org_name =
|
||||
org_attrs.and_then(|a| a.name.clone()).unwrap_or_else(|| "unknown_org".to_string());
|
||||
|
||||
let org_perms = org_attrs.and_then(|a| a.permissions.as_ref());
|
||||
|
||||
// Classify org-level permissions.
|
||||
if let Some(perms) = org_perms {
|
||||
if perms.can_manage_modules == Some(true) {
|
||||
permissions.admin.push(format!("{org_name}:can-manage-modules"));
|
||||
all_scopes.push(format!("{org_name}:can-manage-modules"));
|
||||
has_org_admin = true;
|
||||
}
|
||||
if perms.can_manage_providers == Some(true) {
|
||||
permissions.admin.push(format!("{org_name}:can-manage-providers"));
|
||||
all_scopes.push(format!("{org_name}:can-manage-providers"));
|
||||
has_org_admin = true;
|
||||
}
|
||||
if perms.can_destroy == Some(true) {
|
||||
permissions.admin.push(format!("{org_name}:can-destroy"));
|
||||
all_scopes.push(format!("{org_name}:can-destroy"));
|
||||
has_org_admin = true;
|
||||
}
|
||||
if perms.can_create_workspace == Some(true) {
|
||||
permissions.risky.push(format!("{org_name}:can-create-workspace"));
|
||||
all_scopes.push(format!("{org_name}:can-create-workspace"));
|
||||
has_workspace_write = true;
|
||||
}
|
||||
if perms.can_update == Some(true) {
|
||||
permissions.risky.push(format!("{org_name}:can-update"));
|
||||
all_scopes.push(format!("{org_name}:can-update"));
|
||||
has_workspace_write = true;
|
||||
}
|
||||
if perms.can_access_via_teams == Some(true) {
|
||||
permissions.read_only.push(format!("{org_name}:can-access-via-teams"));
|
||||
all_scopes.push(format!("{org_name}:can-access-via-teams"));
|
||||
}
|
||||
}
|
||||
|
||||
let org_perm_list: Vec<String> = org_perms
|
||||
.map(|p| {
|
||||
let mut v = Vec::new();
|
||||
if p.can_manage_modules == Some(true) {
|
||||
v.push("can-manage-modules".to_string());
|
||||
}
|
||||
if p.can_manage_providers == Some(true) {
|
||||
v.push("can-manage-providers".to_string());
|
||||
}
|
||||
if p.can_create_workspace == Some(true) {
|
||||
v.push("can-create-workspace".to_string());
|
||||
}
|
||||
if p.can_update == Some(true) {
|
||||
v.push("can-update".to_string());
|
||||
}
|
||||
if p.can_destroy == Some(true) {
|
||||
v.push("can-destroy".to_string());
|
||||
}
|
||||
v
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
roles.push(RoleBinding {
|
||||
name: format!("org:{org_name}"),
|
||||
source: "terraform".into(),
|
||||
permissions: org_perm_list.clone(),
|
||||
});
|
||||
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: "organization".into(),
|
||||
name: org_name.clone(),
|
||||
permissions: org_perm_list,
|
||||
risk: severity_to_str(if has_org_admin {
|
||||
Severity::Critical
|
||||
} else if has_workspace_write {
|
||||
Severity::High
|
||||
} else {
|
||||
Severity::Medium
|
||||
})
|
||||
.to_string(),
|
||||
reason: "Terraform Cloud organization accessible with this token".into(),
|
||||
});
|
||||
|
||||
// Enumerate workspaces.
|
||||
let workspaces = list_workspaces(&client, token, &org_name).await.unwrap_or_else(|err| {
|
||||
warn!("Terraform access-map: workspace enumeration for {org_name} failed: {err}");
|
||||
Vec::new()
|
||||
});
|
||||
|
||||
for ws in &workspaces {
|
||||
let ws_attrs = ws.attributes.as_ref();
|
||||
let ws_name = ws_attrs
|
||||
.and_then(|a| a.name.clone())
|
||||
.unwrap_or_else(|| "unknown_workspace".to_string());
|
||||
|
||||
let is_auto_apply = ws_attrs.and_then(|a| a.auto_apply).unwrap_or(false);
|
||||
let is_locked = ws_attrs.and_then(|a| a.locked).unwrap_or(false);
|
||||
|
||||
let mut ws_notes = Vec::new();
|
||||
if is_auto_apply {
|
||||
ws_notes.push("auto-apply enabled");
|
||||
}
|
||||
if !is_locked {
|
||||
ws_notes.push("unlocked");
|
||||
}
|
||||
|
||||
let ws_risk = if has_workspace_write && is_auto_apply && !is_locked {
|
||||
Severity::High
|
||||
} else if has_workspace_write {
|
||||
Severity::Medium
|
||||
} else {
|
||||
Severity::Low
|
||||
};
|
||||
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: "workspace".into(),
|
||||
name: format!("{org_name}/{ws_name}"),
|
||||
permissions: vec!["workspace:read".to_string()],
|
||||
risk: severity_to_str(ws_risk).to_string(),
|
||||
reason: if ws_notes.is_empty() {
|
||||
"Workspace accessible with this token".to_string()
|
||||
} else {
|
||||
format!("Workspace accessible ({})", ws_notes.join(", "))
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
permissions.admin.sort();
|
||||
permissions.admin.dedup();
|
||||
permissions.risky.sort();
|
||||
permissions.risky.dedup();
|
||||
permissions.read_only.sort();
|
||||
permissions.read_only.dedup();
|
||||
|
||||
let severity = derive_severity(has_org_admin, has_workspace_write, &orgs);
|
||||
|
||||
if orgs.is_empty() {
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: "account".into(),
|
||||
name: username.clone(),
|
||||
permissions: Vec::new(),
|
||||
risk: severity_to_str(Severity::Low).to_string(),
|
||||
reason: "Terraform Cloud account associated with the token".into(),
|
||||
});
|
||||
risk_notes.push("Token did not enumerate any organizations".into());
|
||||
}
|
||||
|
||||
if is_service_account {
|
||||
risk_notes.push("Token belongs to a service account".into());
|
||||
}
|
||||
|
||||
Ok(AccessMapResult {
|
||||
cloud: "terraform".into(),
|
||||
identity,
|
||||
roles,
|
||||
permissions,
|
||||
resources,
|
||||
severity,
|
||||
recommendations: build_recommendations(severity),
|
||||
risk_notes,
|
||||
token_details: Some(AccessTokenDetails {
|
||||
name: attrs.and_then(|a| a.username.clone()),
|
||||
username: attrs.and_then(|a| a.username.clone()),
|
||||
account_type: Some(
|
||||
if is_service_account { "service_account" } else { "user" }.to_string(),
|
||||
),
|
||||
company: None,
|
||||
location: None,
|
||||
email: attrs.and_then(|a| a.email.clone()),
|
||||
url: None,
|
||||
token_type: None,
|
||||
created_at: None,
|
||||
last_used_at: None,
|
||||
expires_at: None,
|
||||
user_id: account_data.and_then(|d| d.id.clone()),
|
||||
scopes: all_scopes,
|
||||
}),
|
||||
provider_metadata: None,
|
||||
fingerprint: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch_account(client: &Client, token: &str) -> Result<TerraformAccountResponse> {
|
||||
let resp = client
|
||||
.get(format!("{TERRAFORM_API}/api/v2/account/details"))
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header(header::CONTENT_TYPE, "application/vnd.api+json")
|
||||
.send()
|
||||
.await
|
||||
.context("Terraform access-map: failed to fetch account details")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(anyhow!(
|
||||
"Terraform access-map: account lookup failed with HTTP {}",
|
||||
resp.status()
|
||||
));
|
||||
}
|
||||
|
||||
resp.json().await.context("Terraform access-map: invalid account JSON")
|
||||
}
|
||||
|
||||
async fn list_organizations(client: &Client, token: &str) -> Result<Vec<TerraformOrgData>> {
|
||||
let resp = client
|
||||
.get(format!("{TERRAFORM_API}/api/v2/organizations"))
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header(header::CONTENT_TYPE, "application/vnd.api+json")
|
||||
.send()
|
||||
.await
|
||||
.context("Terraform access-map: failed to list organizations")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
warn!("Terraform access-map: organization enumeration failed with HTTP {}", resp.status());
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let body: TerraformOrgsResponse =
|
||||
resp.json().await.context("Terraform access-map: invalid organizations JSON")?;
|
||||
Ok(body.data)
|
||||
}
|
||||
|
||||
async fn list_workspaces(
|
||||
client: &Client,
|
||||
token: &str,
|
||||
org_name: &str,
|
||||
) -> Result<Vec<TerraformWorkspaceData>> {
|
||||
let mut workspaces = Vec::new();
|
||||
let mut page = 1;
|
||||
|
||||
loop {
|
||||
let resp = client
|
||||
.get(format!(
|
||||
"{TERRAFORM_API}/api/v2/organizations/{org_name}/workspaces?page%5Bnumber%5D={page}&page%5Bsize%5D=100"
|
||||
))
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header(header::CONTENT_TYPE, "application/vnd.api+json")
|
||||
.send()
|
||||
.await
|
||||
.context("Terraform access-map: failed to list workspaces")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
warn!("Terraform access-map: workspace enumeration failed with HTTP {}", resp.status());
|
||||
break;
|
||||
}
|
||||
|
||||
let body: TerraformWorkspacesResponse =
|
||||
resp.json().await.context("Terraform access-map: invalid workspaces JSON")?;
|
||||
|
||||
if body.data.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
workspaces.extend(body.data);
|
||||
page += 1;
|
||||
}
|
||||
|
||||
Ok(workspaces)
|
||||
}
|
||||
|
||||
fn derive_severity(
|
||||
has_org_admin: bool,
|
||||
has_workspace_write: bool,
|
||||
orgs: &[TerraformOrgData],
|
||||
) -> Severity {
|
||||
if has_org_admin {
|
||||
return Severity::Critical;
|
||||
}
|
||||
|
||||
if has_workspace_write && !orgs.is_empty() {
|
||||
return Severity::High;
|
||||
}
|
||||
|
||||
if !orgs.is_empty() {
|
||||
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",
|
||||
}
|
||||
}
|
||||
381
src/access_map/xray.rs
Normal file
381
src/access_map/xray.rs
Normal file
|
|
@ -0,0 +1,381 @@
|
|||
use anyhow::{anyhow, Context, Result};
|
||||
use reqwest::{header, Client};
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::{cli::commands::access_map::AccessMapArgs, validation::GLOBAL_USER_AGENT};
|
||||
|
||||
use super::{
|
||||
build_recommendations, AccessMapResult, AccessSummary, AccessTokenDetails, PermissionSummary,
|
||||
ResourceExposure, RoleBinding, Severity,
|
||||
};
|
||||
|
||||
const MAX_REPO_RESOURCES: usize = 100;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct XrayRepo {
|
||||
name: Option<String>,
|
||||
#[serde(rename = "type")]
|
||||
repo_type: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
#[serde(rename = "pkg_type")]
|
||||
package_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct XrayPolicy {
|
||||
name: Option<String>,
|
||||
#[serde(rename = "type")]
|
||||
policy_type: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
author: Option<String>,
|
||||
}
|
||||
|
||||
/// Entry point when invoked via the CLI `access-map jfrog-xray` subcommand.
|
||||
pub async fn map_access(args: &AccessMapArgs) -> Result<AccessMapResult> {
|
||||
let path = args.credential_path.as_deref().ok_or_else(|| {
|
||||
anyhow!(
|
||||
"JFrog Xray access-map requires a credential file with token (and optionally base_url)"
|
||||
)
|
||||
})?;
|
||||
let raw = std::fs::read_to_string(path).with_context(|| {
|
||||
format!("Failed to read JFrog Xray credential file from {}", path.display())
|
||||
})?;
|
||||
let (token, base_url) = parse_xray_credentials(&raw)?;
|
||||
match base_url {
|
||||
Some(url) => map_access_from_token_and_url(&token, &url).await,
|
||||
None => map_access_from_token(&token).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a JFrog Xray token without a known base URL.
|
||||
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 JFrog Xray HTTP client")?;
|
||||
|
||||
// Without a base URL, try the generic JFrog cloud endpoint
|
||||
let ping_ok = ping_xray(&client, token, "https://access.jfrog.io").await;
|
||||
|
||||
let mut risk_notes = Vec::new();
|
||||
let mut permissions = PermissionSummary::default();
|
||||
|
||||
if ping_ok {
|
||||
permissions.read_only.push("system:ping".to_string());
|
||||
risk_notes.push("Token responded to JFrog Xray cloud ping; base_url unknown so full mapping not possible".to_string());
|
||||
} else {
|
||||
risk_notes.push(
|
||||
"Token did not respond to JFrog Xray cloud ping; provide base_url for full mapping"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let severity = Severity::Medium;
|
||||
Ok(AccessMapResult {
|
||||
cloud: "jfrog_xray".into(),
|
||||
identity: AccessSummary {
|
||||
id: "unknown_xray_user".into(),
|
||||
access_type: "token".into(),
|
||||
project: None,
|
||||
tenant: None,
|
||||
account_id: None,
|
||||
},
|
||||
roles: Vec::new(),
|
||||
permissions,
|
||||
resources: Vec::new(),
|
||||
severity,
|
||||
recommendations: build_recommendations(severity),
|
||||
risk_notes,
|
||||
token_details: Some(AccessTokenDetails {
|
||||
token_type: Some("bearer_token".into()),
|
||||
..Default::default()
|
||||
}),
|
||||
provider_metadata: None,
|
||||
fingerprint: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Maps a JFrog Xray token with a known base URL to an access profile.
|
||||
pub async fn map_access_from_token_and_url(token: &str, base_url: &str) -> Result<AccessMapResult> {
|
||||
let base_url = base_url.trim().trim_end_matches('/');
|
||||
if base_url.is_empty() {
|
||||
return Err(anyhow!("JFrog Xray access-map requires a non-empty base URL"));
|
||||
}
|
||||
|
||||
let client = Client::builder()
|
||||
.user_agent(GLOBAL_USER_AGENT.as_str())
|
||||
.build()
|
||||
.context("Failed to build JFrog Xray HTTP client")?;
|
||||
|
||||
let mut risk_notes = Vec::new();
|
||||
let mut permissions = PermissionSummary::default();
|
||||
|
||||
// Ping
|
||||
let ping_ok = ping_xray(&client, token, base_url).await;
|
||||
if !ping_ok {
|
||||
return Err(anyhow!("JFrog Xray access-map: ping failed for {base_url}"));
|
||||
}
|
||||
permissions.read_only.push("system:ping".to_string());
|
||||
|
||||
// Fetch repos under binary manager
|
||||
let repos = fetch_repos(&client, token, base_url).await.unwrap_or_else(|err| {
|
||||
warn!("JFrog Xray access-map: repo listing failed: {err}");
|
||||
risk_notes.push(format!("Repository listing failed: {err}"));
|
||||
Vec::new()
|
||||
});
|
||||
|
||||
if !repos.is_empty() {
|
||||
permissions.read_only.push("repos:list".to_string());
|
||||
}
|
||||
|
||||
// Fetch policies
|
||||
let policies = fetch_policies(&client, token, base_url).await.unwrap_or_else(|err| {
|
||||
warn!("JFrog Xray access-map: policy listing failed: {err}");
|
||||
risk_notes.push(format!("Policy listing failed: {err}"));
|
||||
Vec::new()
|
||||
});
|
||||
|
||||
let has_policy_access = !policies.is_empty();
|
||||
if has_policy_access {
|
||||
permissions.risky.push("policies:list".to_string());
|
||||
risk_notes.push(format!("Can view {} security policies", policies.len()));
|
||||
}
|
||||
|
||||
// Determine if user has admin-level access based on policy management
|
||||
// If policies are readable AND repos are listed, likely has elevated access
|
||||
let can_manage_policies = has_policy_access && policies.len() > 0;
|
||||
let can_scan_repos = !repos.is_empty();
|
||||
|
||||
if can_manage_policies {
|
||||
permissions.risky.push("policies:read".to_string());
|
||||
risk_notes.push("Policy management access detected".to_string());
|
||||
}
|
||||
|
||||
if can_scan_repos {
|
||||
permissions.risky.push("repos:scan_control".to_string());
|
||||
risk_notes.push("Repository scanning control access detected".to_string());
|
||||
}
|
||||
|
||||
permissions.admin.sort();
|
||||
permissions.admin.dedup();
|
||||
permissions.risky.sort();
|
||||
permissions.risky.dedup();
|
||||
permissions.read_only.sort();
|
||||
permissions.read_only.dedup();
|
||||
|
||||
// Severity: admin if can manage policies, high if repo scanning, medium otherwise
|
||||
let severity = if can_manage_policies && can_scan_repos {
|
||||
Severity::High
|
||||
} else if can_manage_policies || can_scan_repos {
|
||||
Severity::Medium
|
||||
} else {
|
||||
Severity::Low
|
||||
};
|
||||
|
||||
let roles = vec![RoleBinding {
|
||||
name: if can_manage_policies {
|
||||
"xray:policy_manager".into()
|
||||
} else if can_scan_repos {
|
||||
"xray:scanner".into()
|
||||
} else {
|
||||
"xray:viewer".into()
|
||||
},
|
||||
source: "jfrog_xray".into(),
|
||||
permissions: permissions
|
||||
.admin
|
||||
.iter()
|
||||
.chain(permissions.risky.iter())
|
||||
.chain(permissions.read_only.iter())
|
||||
.cloned()
|
||||
.collect(),
|
||||
}];
|
||||
|
||||
let mut resources = vec![ResourceExposure {
|
||||
resource_type: "xray_instance".into(),
|
||||
name: base_url.to_string(),
|
||||
permissions: vec!["authenticated".into()],
|
||||
risk: severity_to_str(severity).to_string(),
|
||||
reason: "JFrog Xray instance accessible with this token".to_string(),
|
||||
}];
|
||||
|
||||
for repo in repos.iter().take(MAX_REPO_RESOURCES) {
|
||||
let repo_name = repo.name.clone().unwrap_or_default();
|
||||
let repo_type = repo.repo_type.clone().unwrap_or_default();
|
||||
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: format!("xray_repo:{repo_type}"),
|
||||
name: repo_name,
|
||||
permissions: vec!["scan:visible".into()],
|
||||
risk: severity_to_str(Severity::Low).to_string(),
|
||||
reason: "Repository visible in Xray scan configuration".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if repos.len() > MAX_REPO_RESOURCES {
|
||||
risk_notes.push(format!(
|
||||
"Repository list truncated to first {MAX_REPO_RESOURCES} entries ({} total)",
|
||||
repos.len()
|
||||
));
|
||||
}
|
||||
|
||||
for policy in &policies {
|
||||
if let Some(name) = &policy.name {
|
||||
let policy_type = policy.policy_type.clone().unwrap_or_else(|| "unknown".to_string());
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: format!("xray_policy:{policy_type}"),
|
||||
name: name.clone(),
|
||||
permissions: vec!["policy:read".into()],
|
||||
risk: severity_to_str(Severity::Medium).to_string(),
|
||||
reason: "Security policy visible to this token".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(AccessMapResult {
|
||||
cloud: "jfrog_xray".into(),
|
||||
identity: AccessSummary {
|
||||
id: "xray_token".into(),
|
||||
access_type: "token".into(),
|
||||
project: None,
|
||||
tenant: None,
|
||||
account_id: None,
|
||||
},
|
||||
roles,
|
||||
permissions,
|
||||
resources,
|
||||
severity,
|
||||
recommendations: build_recommendations(severity),
|
||||
risk_notes,
|
||||
token_details: Some(AccessTokenDetails {
|
||||
url: Some(base_url.to_string()),
|
||||
token_type: Some("bearer_token".into()),
|
||||
..Default::default()
|
||||
}),
|
||||
provider_metadata: None,
|
||||
fingerprint: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_xray_credentials(raw: &str) -> Result<(String, Option<String>)> {
|
||||
if let Ok(json) = serde_json::from_str::<Value>(raw) {
|
||||
let token = json
|
||||
.get("token")
|
||||
.or_else(|| json.get("access_token"))
|
||||
.or_else(|| json.get("api_key"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim().to_string());
|
||||
let base_url = json
|
||||
.get("base_url")
|
||||
.or_else(|| json.get("url"))
|
||||
.or_else(|| json.get("instance_url"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim().to_string());
|
||||
|
||||
if let Some(token) = token {
|
||||
return Ok((token, base_url));
|
||||
}
|
||||
}
|
||||
|
||||
let lines: Vec<&str> = raw
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty() && !line.starts_with('#'))
|
||||
.collect();
|
||||
match lines.len() {
|
||||
1 => Ok((lines[0].to_string(), None)),
|
||||
n if n >= 2 => Ok((lines[0].to_string(), Some(lines[1].to_string()))),
|
||||
_ => Err(anyhow!(
|
||||
"JFrog Xray credential format not recognized. Provide JSON with token (+ optional base_url), or lines."
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn ping_xray(client: &Client, token: &str, base_url: &str) -> bool {
|
||||
let resp = client
|
||||
.get(format!("{base_url}/xray/api/v1/system/ping"))
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
matches!(resp, Ok(r) if r.status().is_success())
|
||||
}
|
||||
|
||||
async fn fetch_repos(client: &Client, token: &str, base_url: &str) -> Result<Vec<XrayRepo>> {
|
||||
let resp = client
|
||||
.get(format!("{base_url}/xray/api/v1/binMgr/default/repos"))
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await
|
||||
.context("JFrog Xray access-map: failed to query binMgr repos endpoint")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(anyhow!(
|
||||
"JFrog Xray access-map: binMgr repos endpoint returned HTTP {}",
|
||||
resp.status()
|
||||
));
|
||||
}
|
||||
|
||||
// The response may be a direct array or wrapped in an object
|
||||
let body: Value =
|
||||
resp.json().await.context("JFrog Xray access-map: invalid binMgr repos JSON")?;
|
||||
|
||||
if let Some(arr) = body.as_array() {
|
||||
let repos: Vec<XrayRepo> =
|
||||
arr.iter().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect();
|
||||
return Ok(repos);
|
||||
}
|
||||
|
||||
// Try nested "repos" key
|
||||
if let Some(arr) = body.get("repos").and_then(|v| v.as_array()) {
|
||||
let repos: Vec<XrayRepo> =
|
||||
arr.iter().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect();
|
||||
return Ok(repos);
|
||||
}
|
||||
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn fetch_policies(client: &Client, token: &str, base_url: &str) -> Result<Vec<XrayPolicy>> {
|
||||
let resp = client
|
||||
.get(format!("{base_url}/xray/api/v1/policies"))
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await
|
||||
.context("JFrog Xray access-map: failed to query policies endpoint")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(anyhow!(
|
||||
"JFrog Xray access-map: policies endpoint returned HTTP {}",
|
||||
resp.status()
|
||||
));
|
||||
}
|
||||
|
||||
let body: Value = resp.json().await.context("JFrog Xray access-map: invalid policies JSON")?;
|
||||
|
||||
if let Some(arr) = body.as_array() {
|
||||
let policies: Vec<XrayPolicy> =
|
||||
arr.iter().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect();
|
||||
return Ok(policies);
|
||||
}
|
||||
|
||||
if let Some(arr) = body.get("policies").and_then(|v| v.as_array()) {
|
||||
let policies: Vec<XrayPolicy> =
|
||||
arr.iter().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect();
|
||||
return Ok(policies);
|
||||
}
|
||||
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
fn severity_to_str(severity: Severity) -> &'static str {
|
||||
match severity {
|
||||
Severity::Low => "low",
|
||||
Severity::Medium => "medium",
|
||||
Severity::High => "high",
|
||||
Severity::Critical => "critical",
|
||||
}
|
||||
}
|
||||
322
src/access_map/zendesk.rs
Normal file
322
src/access_map/zendesk.rs
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
use anyhow::{anyhow, Context, Result};
|
||||
use reqwest::{header, Client};
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::{cli::commands::access_map::AccessMapArgs, validation::GLOBAL_USER_AGENT};
|
||||
|
||||
use super::{
|
||||
build_recommendations, AccessMapResult, AccessSummary, AccessTokenDetails, PermissionSummary,
|
||||
ResourceExposure, RoleBinding, Severity,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ZendeskUserResponse {
|
||||
user: ZendeskUser,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ZendeskUser {
|
||||
id: Option<u64>,
|
||||
email: Option<String>,
|
||||
name: Option<String>,
|
||||
role: Option<String>,
|
||||
active: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ZendeskCountResponse {
|
||||
count: Option<ZendeskCount>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ZendeskCount {
|
||||
value: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ZendeskGroupsResponse {
|
||||
groups: Option<Vec<ZendeskGroup>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ZendeskGroup {
|
||||
#[allow(dead_code)]
|
||||
id: Option<u64>,
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
/// Entry point when invoked via the CLI `access-map zendesk` subcommand.
|
||||
pub async fn map_access(args: &AccessMapArgs) -> Result<AccessMapResult> {
|
||||
let path = args.credential_path.as_deref().ok_or_else(|| {
|
||||
anyhow!("Zendesk access-map requires a credential file with token and subdomain")
|
||||
})?;
|
||||
let raw = std::fs::read_to_string(path).with_context(|| {
|
||||
format!("Failed to read Zendesk credential file from {}", path.display())
|
||||
})?;
|
||||
let (token, subdomain) = parse_zendesk_credentials(&raw)?;
|
||||
map_access_from_token_and_subdomain(&token, &subdomain).await
|
||||
}
|
||||
|
||||
/// Maps a Zendesk API token and subdomain to an access profile.
|
||||
pub async fn map_access_from_token_and_subdomain(
|
||||
token: &str,
|
||||
subdomain: &str,
|
||||
) -> Result<AccessMapResult> {
|
||||
let subdomain = subdomain.trim().trim_matches('/').to_ascii_lowercase();
|
||||
if subdomain.is_empty() {
|
||||
return Err(anyhow!("Zendesk access-map requires a non-empty subdomain"));
|
||||
}
|
||||
|
||||
let base_url = format!("https://{subdomain}.zendesk.com");
|
||||
let client = Client::builder()
|
||||
.user_agent(GLOBAL_USER_AGENT.as_str())
|
||||
.build()
|
||||
.context("Failed to build Zendesk HTTP client")?;
|
||||
|
||||
let mut risk_notes = Vec::new();
|
||||
let mut permissions = PermissionSummary::default();
|
||||
|
||||
// Fetch current user info
|
||||
let user = fetch_current_user(&client, token, &base_url).await?;
|
||||
|
||||
let user_id = user.id.map(|id| id.to_string()).unwrap_or_default();
|
||||
let user_email = user.email.clone();
|
||||
let user_name = user.name.clone();
|
||||
let role = user.role.clone().unwrap_or_else(|| "unknown".to_string());
|
||||
let active = user.active.unwrap_or(false);
|
||||
|
||||
if !active {
|
||||
risk_notes.push("User account is inactive / suspended".to_string());
|
||||
}
|
||||
|
||||
// Classify by role
|
||||
let severity = match role.as_str() {
|
||||
"admin" => {
|
||||
permissions.admin.push("account:admin".to_string());
|
||||
permissions.admin.push("users:manage".to_string());
|
||||
permissions.admin.push("tickets:manage".to_string());
|
||||
permissions.admin.push("groups:manage".to_string());
|
||||
permissions.risky.push("settings:manage".to_string());
|
||||
risk_notes.push("Admin role grants full account management".to_string());
|
||||
Severity::Critical
|
||||
}
|
||||
"agent" => {
|
||||
permissions.risky.push("tickets:read".to_string());
|
||||
permissions.risky.push("tickets:write".to_string());
|
||||
permissions.read_only.push("users:read".to_string());
|
||||
risk_notes.push("Agent role provides ticket read/write access".to_string());
|
||||
Severity::Medium
|
||||
}
|
||||
"end-user" | "end_user" => {
|
||||
permissions.read_only.push("tickets:own".to_string());
|
||||
Severity::Low
|
||||
}
|
||||
_ => {
|
||||
permissions.read_only.push(format!("role:{role}"));
|
||||
risk_notes.push(format!("Unknown Zendesk role: {role}"));
|
||||
Severity::Medium
|
||||
}
|
||||
};
|
||||
|
||||
// Probe ticket count
|
||||
let ticket_count = fetch_ticket_count(&client, token, &base_url).await;
|
||||
match ticket_count {
|
||||
Ok(Some(count)) => {
|
||||
permissions.read_only.push("tickets:count".to_string());
|
||||
risk_notes.push(format!("Ticket count accessible: {count} tickets"));
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(err) => {
|
||||
warn!("Zendesk access-map: ticket count probe failed: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
// Probe groups
|
||||
let groups = fetch_groups(&client, token, &base_url).await.unwrap_or_else(|err| {
|
||||
warn!("Zendesk access-map: groups probe failed: {err}");
|
||||
Vec::new()
|
||||
});
|
||||
|
||||
if !groups.is_empty() {
|
||||
permissions.read_only.push("groups:list".to_string());
|
||||
}
|
||||
|
||||
permissions.admin.sort();
|
||||
permissions.admin.dedup();
|
||||
permissions.risky.sort();
|
||||
permissions.risky.dedup();
|
||||
permissions.read_only.sort();
|
||||
permissions.read_only.dedup();
|
||||
|
||||
let roles = vec![RoleBinding {
|
||||
name: format!("zendesk_role:{role}"),
|
||||
source: "zendesk".into(),
|
||||
permissions: permissions
|
||||
.admin
|
||||
.iter()
|
||||
.chain(permissions.risky.iter())
|
||||
.chain(permissions.read_only.iter())
|
||||
.cloned()
|
||||
.collect(),
|
||||
}];
|
||||
|
||||
let mut resources = vec![ResourceExposure {
|
||||
resource_type: "zendesk_instance".into(),
|
||||
name: subdomain.clone(),
|
||||
permissions: vec![format!("role:{role}")],
|
||||
risk: severity_to_str(severity).to_string(),
|
||||
reason: "Zendesk instance accessible with this token".to_string(),
|
||||
}];
|
||||
|
||||
for group in &groups {
|
||||
if let Some(name) = &group.name {
|
||||
resources.push(ResourceExposure {
|
||||
resource_type: "zendesk_group".into(),
|
||||
name: name.clone(),
|
||||
permissions: vec!["group:member".into()],
|
||||
risk: severity_to_str(Severity::Low).to_string(),
|
||||
reason: "Group visible to this token".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let identity_id = user_email
|
||||
.clone()
|
||||
.or_else(|| user_name.clone())
|
||||
.unwrap_or_else(|| format!("zendesk_user:{user_id}"));
|
||||
|
||||
Ok(AccessMapResult {
|
||||
cloud: "zendesk".into(),
|
||||
identity: AccessSummary {
|
||||
id: identity_id,
|
||||
access_type: role.clone(),
|
||||
project: Some(subdomain.clone()),
|
||||
tenant: None,
|
||||
account_id: Some(user_id.clone()),
|
||||
},
|
||||
roles,
|
||||
permissions,
|
||||
resources,
|
||||
severity,
|
||||
recommendations: build_recommendations(severity),
|
||||
risk_notes,
|
||||
token_details: Some(AccessTokenDetails {
|
||||
name: user_name,
|
||||
username: None,
|
||||
account_type: Some(role),
|
||||
company: None,
|
||||
location: None,
|
||||
email: user_email,
|
||||
url: Some(base_url),
|
||||
token_type: Some("bearer_token".into()),
|
||||
created_at: None,
|
||||
last_used_at: None,
|
||||
expires_at: None,
|
||||
user_id: Some(user_id),
|
||||
scopes: Vec::new(),
|
||||
}),
|
||||
provider_metadata: None,
|
||||
fingerprint: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_zendesk_credentials(raw: &str) -> Result<(String, String)> {
|
||||
if let Ok(json) = serde_json::from_str::<Value>(raw) {
|
||||
let token = json
|
||||
.get("token")
|
||||
.or_else(|| json.get("access_token"))
|
||||
.or_else(|| json.get("api_token"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim().to_string());
|
||||
let subdomain = json
|
||||
.get("subdomain")
|
||||
.or_else(|| json.get("domain"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim().to_string());
|
||||
|
||||
if let (Some(token), Some(subdomain)) = (token, subdomain) {
|
||||
return Ok((token, subdomain));
|
||||
}
|
||||
}
|
||||
|
||||
let lines: Vec<&str> = raw
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty() && !line.starts_with('#'))
|
||||
.collect();
|
||||
if lines.len() >= 2 {
|
||||
return Ok((lines[0].to_string(), lines[1].to_string()));
|
||||
}
|
||||
|
||||
Err(anyhow!(
|
||||
"Zendesk credential format not recognized. Provide JSON with token + subdomain, or two lines (token, subdomain)."
|
||||
))
|
||||
}
|
||||
|
||||
async fn fetch_current_user(client: &Client, token: &str, base_url: &str) -> Result<ZendeskUser> {
|
||||
let resp = client
|
||||
.get(format!("{base_url}/api/v2/users/me.json"))
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await
|
||||
.context("Zendesk access-map: failed to query users/me endpoint")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(anyhow!(
|
||||
"Zendesk access-map: users/me endpoint returned HTTP {}",
|
||||
resp.status()
|
||||
));
|
||||
}
|
||||
|
||||
let user_resp: ZendeskUserResponse =
|
||||
resp.json().await.context("Zendesk access-map: invalid users/me JSON")?;
|
||||
Ok(user_resp.user)
|
||||
}
|
||||
|
||||
async fn fetch_ticket_count(client: &Client, token: &str, base_url: &str) -> Result<Option<u64>> {
|
||||
let resp = client
|
||||
.get(format!("{base_url}/api/v2/tickets/count.json"))
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await
|
||||
.context("Zendesk access-map: failed to query tickets/count endpoint")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let count_resp: ZendeskCountResponse =
|
||||
resp.json().await.context("Zendesk access-map: invalid tickets/count JSON")?;
|
||||
Ok(count_resp.count.and_then(|c| c.value))
|
||||
}
|
||||
|
||||
async fn fetch_groups(client: &Client, token: &str, base_url: &str) -> Result<Vec<ZendeskGroup>> {
|
||||
let resp = client
|
||||
.get(format!("{base_url}/api/v2/groups.json"))
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await
|
||||
.context("Zendesk access-map: failed to query groups endpoint")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let groups_resp: ZendeskGroupsResponse =
|
||||
resp.json().await.context("Zendesk access-map: invalid groups JSON")?;
|
||||
Ok(groups_resp.groups.unwrap_or_default())
|
||||
}
|
||||
|
||||
fn severity_to_str(severity: Severity) -> &'static str {
|
||||
match severity {
|
||||
Severity::Low => "low",
|
||||
Severity::Medium => "medium",
|
||||
Severity::High => "high",
|
||||
Severity::Critical => "critical",
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ use clap::{Args, ValueEnum};
|
|||
/// Inspect a cloud credential and derive the effective identity and blast radius.
|
||||
#[derive(Args, Debug)]
|
||||
pub struct AccessMapArgs {
|
||||
/// Cloud provider: aws | gcp | azure | github | gitlab | slack | postgres | mongodb | huggingface | gitea | bitbucket | buildkite | harness | openai | anthropic | salesforce | weightsandbiases | microsoftteams
|
||||
/// Cloud provider for identity mapping
|
||||
#[clap(value_parser, value_name = "PROVIDER")]
|
||||
pub provider: AccessMapProvider,
|
||||
|
||||
|
|
@ -65,4 +65,60 @@ pub enum AccessMapProvider {
|
|||
/// Microsoft Teams
|
||||
#[clap(alias = "msteams")]
|
||||
Microsoftteams,
|
||||
/// Airtable
|
||||
Airtable,
|
||||
/// CircleCI
|
||||
Circleci,
|
||||
/// DigitalOcean
|
||||
#[clap(alias = "do")]
|
||||
Digitalocean,
|
||||
/// Fastly
|
||||
Fastly,
|
||||
/// HubSpot
|
||||
Hubspot,
|
||||
/// IBM Cloud
|
||||
#[clap(alias = "ibm")]
|
||||
Ibmcloud,
|
||||
/// SendGrid
|
||||
Sendgrid,
|
||||
/// Brevo (Sendinblue)
|
||||
#[clap(alias = "brevo")]
|
||||
Sendinblue,
|
||||
/// Stripe
|
||||
Stripe,
|
||||
/// Terraform Cloud
|
||||
#[clap(alias = "tfc")]
|
||||
Terraform,
|
||||
/// Square
|
||||
Square,
|
||||
/// Jira
|
||||
#[clap(alias = "jira")]
|
||||
Jira,
|
||||
/// MySQL database
|
||||
#[clap(alias = "mysql")]
|
||||
Mysql,
|
||||
/// Algolia
|
||||
#[clap(alias = "algolia")]
|
||||
Algolia,
|
||||
/// Auth0
|
||||
#[clap(alias = "auth0")]
|
||||
Auth0,
|
||||
/// PayPal
|
||||
#[clap(alias = "paypal")]
|
||||
Paypal,
|
||||
/// Plaid
|
||||
#[clap(alias = "plaid")]
|
||||
Plaid,
|
||||
/// Shopify
|
||||
#[clap(alias = "shopify")]
|
||||
Shopify,
|
||||
/// Zendesk
|
||||
#[clap(alias = "zendesk")]
|
||||
Zendesk,
|
||||
/// JFrog Artifactory
|
||||
#[clap(alias = "jfrog-art")]
|
||||
Artifactory,
|
||||
/// JFrog Xray
|
||||
#[clap(alias = "jfrog-xray")]
|
||||
Xray,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -192,6 +192,188 @@ impl AccessMapCollector {
|
|||
});
|
||||
}
|
||||
|
||||
pub fn record_airtable(&self, token: &str, fingerprint: String) {
|
||||
let key = xxhash_rust::xxh3::xxh3_64(format!("airtable|{token}").as_bytes());
|
||||
self.inner.entry(key).or_insert_with(|| AccessMapRequest::Airtable {
|
||||
token: token.to_string(),
|
||||
fingerprint,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn record_algolia(&self, app_id: &str, api_key: &str, fingerprint: String) {
|
||||
let key = xxhash_rust::xxh3::xxh3_64(format!("algolia|{app_id}|{api_key}").as_bytes());
|
||||
self.inner.entry(key).or_insert_with(|| AccessMapRequest::Algolia {
|
||||
app_id: app_id.to_string(),
|
||||
api_key: api_key.to_string(),
|
||||
fingerprint,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn record_artifactory(&self, token: &str, base_url: Option<&str>, fingerprint: String) {
|
||||
let key = xxhash_rust::xxh3::xxh3_64(format!("artifactory|{token}").as_bytes());
|
||||
self.inner.entry(key).or_insert_with(|| AccessMapRequest::Artifactory {
|
||||
token: token.to_string(),
|
||||
base_url: base_url.map(|s| s.to_string()),
|
||||
fingerprint,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn record_auth0(
|
||||
&self,
|
||||
client_id: &str,
|
||||
client_secret: &str,
|
||||
domain: &str,
|
||||
fingerprint: String,
|
||||
) {
|
||||
let key = xxhash_rust::xxh3::xxh3_64(
|
||||
format!("auth0|{domain}|{client_id}|{client_secret}").as_bytes(),
|
||||
);
|
||||
self.inner.entry(key).or_insert_with(|| AccessMapRequest::Auth0 {
|
||||
client_id: client_id.to_string(),
|
||||
client_secret: client_secret.to_string(),
|
||||
domain: domain.to_string(),
|
||||
fingerprint,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn record_circleci(&self, token: &str, fingerprint: String) {
|
||||
let key = xxhash_rust::xxh3::xxh3_64(format!("circleci|{token}").as_bytes());
|
||||
self.inner.entry(key).or_insert_with(|| AccessMapRequest::CircleCI {
|
||||
token: token.to_string(),
|
||||
fingerprint,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn record_digitalocean(&self, token: &str, fingerprint: String) {
|
||||
let key = xxhash_rust::xxh3::xxh3_64(format!("digitalocean|{token}").as_bytes());
|
||||
self.inner.entry(key).or_insert_with(|| AccessMapRequest::DigitalOcean {
|
||||
token: token.to_string(),
|
||||
fingerprint,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn record_fastly(&self, token: &str, fingerprint: String) {
|
||||
let key = xxhash_rust::xxh3::xxh3_64(format!("fastly|{token}").as_bytes());
|
||||
self.inner
|
||||
.entry(key)
|
||||
.or_insert_with(|| AccessMapRequest::Fastly { token: token.to_string(), fingerprint });
|
||||
}
|
||||
|
||||
pub fn record_hubspot(&self, token: &str, fingerprint: String) {
|
||||
let key = xxhash_rust::xxh3::xxh3_64(format!("hubspot|{token}").as_bytes());
|
||||
self.inner
|
||||
.entry(key)
|
||||
.or_insert_with(|| AccessMapRequest::HubSpot { token: token.to_string(), fingerprint });
|
||||
}
|
||||
|
||||
pub fn record_ibm_cloud(&self, token: &str, fingerprint: String) {
|
||||
let key = xxhash_rust::xxh3::xxh3_64(format!("ibm_cloud|{token}").as_bytes());
|
||||
self.inner.entry(key).or_insert_with(|| AccessMapRequest::IbmCloud {
|
||||
token: token.to_string(),
|
||||
fingerprint,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn record_jira(&self, token: &str, base_url: &str, fingerprint: String) {
|
||||
let key = xxhash_rust::xxh3::xxh3_64(format!("jira|{base_url}|{token}").as_bytes());
|
||||
self.inner.entry(key).or_insert_with(|| AccessMapRequest::Jira {
|
||||
token: token.to_string(),
|
||||
base_url: base_url.to_string(),
|
||||
fingerprint,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn record_mysql(&self, uri: &str, fingerprint: String) {
|
||||
let key = xxhash_rust::xxh3::xxh3_64(format!("mysql|{uri}").as_bytes());
|
||||
self.inner
|
||||
.entry(key)
|
||||
.or_insert_with(|| AccessMapRequest::MySQL { uri: uri.to_string(), fingerprint });
|
||||
}
|
||||
|
||||
pub fn record_paypal(&self, client_id: &str, client_secret: &str, fingerprint: String) {
|
||||
let key =
|
||||
xxhash_rust::xxh3::xxh3_64(format!("paypal|{client_id}|{client_secret}").as_bytes());
|
||||
self.inner.entry(key).or_insert_with(|| AccessMapRequest::PayPal {
|
||||
client_id: client_id.to_string(),
|
||||
client_secret: client_secret.to_string(),
|
||||
fingerprint,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn record_plaid(&self, client_id: &str, secret: &str, fingerprint: String) {
|
||||
let key = xxhash_rust::xxh3::xxh3_64(format!("plaid|{client_id}|{secret}").as_bytes());
|
||||
self.inner.entry(key).or_insert_with(|| AccessMapRequest::Plaid {
|
||||
client_id: client_id.to_string(),
|
||||
secret: secret.to_string(),
|
||||
fingerprint,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn record_sendgrid(&self, token: &str, fingerprint: String) {
|
||||
let key = xxhash_rust::xxh3::xxh3_64(format!("sendgrid|{token}").as_bytes());
|
||||
self.inner.entry(key).or_insert_with(|| AccessMapRequest::SendGrid {
|
||||
token: token.to_string(),
|
||||
fingerprint,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn record_sendinblue(&self, token: &str, fingerprint: String) {
|
||||
let key = xxhash_rust::xxh3::xxh3_64(format!("sendinblue|{token}").as_bytes());
|
||||
self.inner.entry(key).or_insert_with(|| AccessMapRequest::Sendinblue {
|
||||
token: token.to_string(),
|
||||
fingerprint,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn record_shopify(&self, token: &str, subdomain: &str, fingerprint: String) {
|
||||
let key = xxhash_rust::xxh3::xxh3_64(format!("shopify|{subdomain}|{token}").as_bytes());
|
||||
self.inner.entry(key).or_insert_with(|| AccessMapRequest::Shopify {
|
||||
token: token.to_string(),
|
||||
subdomain: subdomain.to_string(),
|
||||
fingerprint,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn record_square(&self, token: &str, fingerprint: String) {
|
||||
let key = xxhash_rust::xxh3::xxh3_64(format!("square|{token}").as_bytes());
|
||||
self.inner
|
||||
.entry(key)
|
||||
.or_insert_with(|| AccessMapRequest::Square { token: token.to_string(), fingerprint });
|
||||
}
|
||||
|
||||
pub fn record_stripe(&self, token: &str, fingerprint: String) {
|
||||
let key = xxhash_rust::xxh3::xxh3_64(format!("stripe|{token}").as_bytes());
|
||||
self.inner
|
||||
.entry(key)
|
||||
.or_insert_with(|| AccessMapRequest::Stripe { token: token.to_string(), fingerprint });
|
||||
}
|
||||
|
||||
pub fn record_terraform(&self, token: &str, fingerprint: String) {
|
||||
let key = xxhash_rust::xxh3::xxh3_64(format!("terraform|{token}").as_bytes());
|
||||
self.inner.entry(key).or_insert_with(|| AccessMapRequest::Terraform {
|
||||
token: token.to_string(),
|
||||
fingerprint,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn record_xray(&self, token: &str, base_url: Option<&str>, fingerprint: String) {
|
||||
let key = xxhash_rust::xxh3::xxh3_64(format!("xray|{token}").as_bytes());
|
||||
self.inner.entry(key).or_insert_with(|| AccessMapRequest::Xray {
|
||||
token: token.to_string(),
|
||||
base_url: base_url.map(|s| s.to_string()),
|
||||
fingerprint,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn record_zendesk(&self, token: &str, subdomain: &str, fingerprint: String) {
|
||||
let key = xxhash_rust::xxh3::xxh3_64(format!("zendesk|{subdomain}|{token}").as_bytes());
|
||||
self.inner.entry(key).or_insert_with(|| AccessMapRequest::Zendesk {
|
||||
token: token.to_string(),
|
||||
subdomain: subdomain.to_string(),
|
||||
fingerprint,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn into_requests(self) -> Vec<AccessMapRequest> {
|
||||
self.inner.iter().map(|entry| entry.value().clone()).collect()
|
||||
}
|
||||
|
|
@ -766,6 +948,13 @@ fn maybe_record_access_map(om: &OwnedBlobMatch, collector: Option<&AccessMapColl
|
|||
}
|
||||
}
|
||||
}
|
||||
Some(Validation::MySQL) => {
|
||||
if let Some((_, value, ..)) = captures.iter().find(|(name, ..)| name == "TOKEN") {
|
||||
if !value.is_empty() {
|
||||
collector.record_mysql(value, fp.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if om.rule.id().starts_with("kingfisher.github.") {
|
||||
if let Some((_, value, ..)) = captures.iter().find(|(name, ..)| name == "TOKEN") {
|
||||
|
|
@ -888,6 +1077,235 @@ fn maybe_record_access_map(om: &OwnedBlobMatch, collector: Option<&AccessMapColl
|
|||
}
|
||||
}
|
||||
}
|
||||
// --- New providers ---
|
||||
if om.rule.id().starts_with("kingfisher.airtable.") {
|
||||
if let Some((_, value, ..)) = captures.iter().find(|(name, ..)| name == "TOKEN") {
|
||||
if !value.is_empty() {
|
||||
collector.record_airtable(value, fp.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if om.rule.id().starts_with("kingfisher.algolia.") {
|
||||
let api_key = captures
|
||||
.iter()
|
||||
.find(|(name, ..)| name == "TOKEN")
|
||||
.map(|(_, value, ..)| value.clone())
|
||||
.unwrap_or_default();
|
||||
let app_id = captures
|
||||
.iter()
|
||||
.find(|(name, ..)| name == "APPID")
|
||||
.map(|(_, value, ..)| value.clone())
|
||||
.or_else(|| om.dependent_captures.get("APPID").cloned())
|
||||
.unwrap_or_default();
|
||||
if !api_key.is_empty() && !app_id.is_empty() {
|
||||
collector.record_algolia(&app_id, &api_key, fp.clone());
|
||||
}
|
||||
}
|
||||
if om.rule.id().starts_with("kingfisher.artifactory.") {
|
||||
if let Some((_, value, ..)) = captures.iter().find(|(name, ..)| name == "TOKEN") {
|
||||
if !value.is_empty() {
|
||||
let base_url = captures
|
||||
.iter()
|
||||
.find(|(name, ..)| name == "HOST" || name == "URL")
|
||||
.map(|(_, value, ..)| value.clone())
|
||||
.or_else(|| om.dependent_captures.get("HOST").cloned());
|
||||
collector.record_artifactory(value, base_url.as_deref(), fp.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if om.rule.id().starts_with("kingfisher.auth0.") {
|
||||
let client_secret = captures
|
||||
.iter()
|
||||
.find(|(name, ..)| name == "TOKEN")
|
||||
.map(|(_, value, ..)| value.clone())
|
||||
.unwrap_or_default();
|
||||
let client_id = captures
|
||||
.iter()
|
||||
.find(|(name, ..)| name == "CLIENTID")
|
||||
.map(|(_, value, ..)| value.clone())
|
||||
.or_else(|| om.dependent_captures.get("CLIENTID").cloned())
|
||||
.unwrap_or_default();
|
||||
let domain = captures
|
||||
.iter()
|
||||
.find(|(name, ..)| name == "DOMAIN")
|
||||
.map(|(_, value, ..)| value.clone())
|
||||
.or_else(|| om.dependent_captures.get("DOMAIN").cloned())
|
||||
.unwrap_or_default();
|
||||
if !client_secret.is_empty() && !client_id.is_empty() && !domain.is_empty() {
|
||||
collector.record_auth0(&client_id, &client_secret, &domain, fp.clone());
|
||||
}
|
||||
}
|
||||
if om.rule.id().starts_with("kingfisher.circleci.") {
|
||||
if let Some((_, value, ..)) = captures.iter().find(|(name, ..)| name == "TOKEN") {
|
||||
if !value.is_empty() {
|
||||
collector.record_circleci(value, fp.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if om.rule.id().starts_with("kingfisher.digitalocean.") {
|
||||
if let Some((_, value, ..)) = captures.iter().find(|(name, ..)| name == "TOKEN") {
|
||||
if !value.is_empty() {
|
||||
collector.record_digitalocean(value, fp.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if om.rule.id().starts_with("kingfisher.fastly.") {
|
||||
if let Some((_, value, ..)) = captures.iter().find(|(name, ..)| name == "TOKEN") {
|
||||
if !value.is_empty() {
|
||||
collector.record_fastly(value, fp.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if om.rule.id().starts_with("kingfisher.hubspot.") {
|
||||
if let Some((_, value, ..)) = captures.iter().find(|(name, ..)| name == "TOKEN") {
|
||||
if !value.is_empty() {
|
||||
collector.record_hubspot(value, fp.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if om.rule.id().starts_with("kingfisher.ibm.") {
|
||||
if let Some((_, value, ..)) = captures.iter().find(|(name, ..)| name == "TOKEN") {
|
||||
if !value.is_empty() {
|
||||
collector.record_ibm_cloud(value, fp.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if om.rule.id().starts_with("kingfisher.jira.") {
|
||||
let token = captures
|
||||
.iter()
|
||||
.find(|(name, ..)| name == "TOKEN")
|
||||
.map(|(_, value, ..)| value.clone())
|
||||
.unwrap_or_default();
|
||||
let base_url = captures
|
||||
.iter()
|
||||
.find(|(name, ..)| name == "DOMAIN" || name == "URL")
|
||||
.map(|(_, value, ..)| value.clone())
|
||||
.or_else(|| om.dependent_captures.get("DOMAIN").cloned())
|
||||
.unwrap_or_default();
|
||||
if !token.is_empty() && !base_url.is_empty() {
|
||||
let url = if base_url.starts_with("http") {
|
||||
base_url
|
||||
} else {
|
||||
format!("https://{base_url}")
|
||||
};
|
||||
collector.record_jira(&token, &url, fp.clone());
|
||||
}
|
||||
}
|
||||
if om.rule.id().starts_with("kingfisher.paypal.") {
|
||||
let client_secret = captures
|
||||
.iter()
|
||||
.find(|(name, ..)| name == "TOKEN")
|
||||
.map(|(_, value, ..)| value.clone())
|
||||
.unwrap_or_default();
|
||||
let client_id = captures
|
||||
.iter()
|
||||
.find(|(name, ..)| name == "CLIENTID")
|
||||
.map(|(_, value, ..)| value.clone())
|
||||
.or_else(|| om.dependent_captures.get("CLIENTID").cloned())
|
||||
.unwrap_or_default();
|
||||
if !client_secret.is_empty() && !client_id.is_empty() {
|
||||
collector.record_paypal(&client_id, &client_secret, fp.clone());
|
||||
}
|
||||
}
|
||||
if om.rule.id().starts_with("kingfisher.plaid.") {
|
||||
let secret = captures
|
||||
.iter()
|
||||
.find(|(name, ..)| name == "TOKEN")
|
||||
.map(|(_, value, ..)| value.clone())
|
||||
.unwrap_or_default();
|
||||
let client_id = captures
|
||||
.iter()
|
||||
.find(|(name, ..)| name == "CLIENTID")
|
||||
.map(|(_, value, ..)| value.clone())
|
||||
.or_else(|| om.dependent_captures.get("CLIENTID").cloned())
|
||||
.unwrap_or_default();
|
||||
if !secret.is_empty() && !client_id.is_empty() {
|
||||
collector.record_plaid(&client_id, &secret, fp.clone());
|
||||
}
|
||||
}
|
||||
if om.rule.id().starts_with("kingfisher.sendgrid.") {
|
||||
if let Some((_, value, ..)) = captures.iter().find(|(name, ..)| name == "TOKEN") {
|
||||
if !value.is_empty() {
|
||||
collector.record_sendgrid(value, fp.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if om.rule.id().starts_with("kingfisher.sendinblue.")
|
||||
|| om.rule.id().starts_with("kingfisher.brevo.")
|
||||
{
|
||||
if let Some((_, value, ..)) = captures.iter().find(|(name, ..)| name == "TOKEN") {
|
||||
if !value.is_empty() {
|
||||
collector.record_sendinblue(value, fp.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if om.rule.id().starts_with("kingfisher.shopify.") {
|
||||
let token = captures
|
||||
.iter()
|
||||
.find(|(name, ..)| name == "TOKEN")
|
||||
.map(|(_, value, ..)| value.clone())
|
||||
.unwrap_or_default();
|
||||
let subdomain = captures
|
||||
.iter()
|
||||
.find(|(name, ..)| name == "DOMAIN" || name == "SUBDOMAIN")
|
||||
.map(|(_, value, ..)| value.clone())
|
||||
.or_else(|| om.dependent_captures.get("DOMAIN").cloned())
|
||||
.unwrap_or_default();
|
||||
if !token.is_empty() && !subdomain.is_empty() {
|
||||
collector.record_shopify(&token, &subdomain, fp.clone());
|
||||
}
|
||||
}
|
||||
if om.rule.id().starts_with("kingfisher.square.") {
|
||||
if let Some((_, value, ..)) = captures.iter().find(|(name, ..)| name == "TOKEN") {
|
||||
if !value.is_empty() {
|
||||
collector.record_square(value, fp.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if om.rule.id().starts_with("kingfisher.stripe.") {
|
||||
if let Some((_, value, ..)) = captures.iter().find(|(name, ..)| name == "TOKEN") {
|
||||
if !value.is_empty() {
|
||||
collector.record_stripe(value, fp.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if om.rule.id().starts_with("kingfisher.terraform.") {
|
||||
if let Some((_, value, ..)) = captures.iter().find(|(name, ..)| name == "TOKEN") {
|
||||
if !value.is_empty() {
|
||||
collector.record_terraform(value, fp.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if om.rule.id().starts_with("kingfisher.jfrog.")
|
||||
|| om.rule.id().starts_with("kingfisher.xray.")
|
||||
{
|
||||
if let Some((_, value, ..)) = captures.iter().find(|(name, ..)| name == "TOKEN") {
|
||||
if !value.is_empty() {
|
||||
let base_url = captures
|
||||
.iter()
|
||||
.find(|(name, ..)| name == "HOST" || name == "URL")
|
||||
.map(|(_, value, ..)| value.clone())
|
||||
.or_else(|| om.dependent_captures.get("HOST").cloned());
|
||||
collector.record_xray(value, base_url.as_deref(), fp.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if om.rule.id().starts_with("kingfisher.zendesk.") {
|
||||
let token = captures
|
||||
.iter()
|
||||
.find(|(name, ..)| name == "TOKEN")
|
||||
.map(|(_, value, ..)| value.clone())
|
||||
.unwrap_or_default();
|
||||
let subdomain = captures
|
||||
.iter()
|
||||
.find(|(name, ..)| name == "SUBDOMAIN" || name == "DOMAIN")
|
||||
.map(|(_, value, ..)| value.clone())
|
||||
.or_else(|| om.dependent_captures.get("SUBDOMAIN").cloned())
|
||||
.unwrap_or_default();
|
||||
if !token.is_empty() && !subdomain.is_empty() {
|
||||
collector.record_zendesk(&token, &subdomain, fp.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue