From 1619737e2cf0ea76824aeb9bf749d7d0777f8d9a Mon Sep 17 00:00:00 2001 From: Mick Grove Date: Thu, 30 Apr 2026 18:11:10 -0700 Subject: [PATCH] improved access map viewer --- CHANGELOG.md | 3 + README.md | 8 +- .../kingfisher-rules/data/rules/pinecone.yml | 1 + docs-site/docs/changelog.md | 2 + docs-site/docs/features/access-map.md | 30 +- docs-site/docs/getting-started/quick-start.md | 2 +- docs/ACCESS_MAP.md | 29 +- docs/viewer/index.html | 546 ++++++++++++++++-- src/access_map.rs | 38 +- src/access_map/pinecone.rs | 349 +++++++++++ src/cli/commands/access_map.rs | 3 + src/cli/commands/scan.rs | 2 +- src/cli/global.rs | 2 +- src/reporter.rs | 63 +- src/scanner/validation.rs | 15 + 15 files changed, 1021 insertions(+), 72 deletions(-) create mode 100644 src/access_map/pinecone.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index eeb5951..c6f3f6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file. ## [v1.99.0] - `--include-contributors` now respects `--github-repo-type` when enumerating contributor-owned repositories: by default contributor forks are excluded (matching the existing `Source` default), previously they were always included regardless of the flag. Added a new `--github-repo-type all` option to opt into the prior behavior of scanning both source and fork repos for contributors, organizations, and users. +- **Access Map:** Pinecone API keys (validated `kingfisher.pinecone.1`): caller resources via `GET /indexes` (with serverless cloud/region or pod environment metadata, deletion-protection state) and `GET /collections`; standalone `kingfisher access-map pinecone` (alias `pinecone.io`). +- Added `--blast-radius` as an alias for `--access-map` on `kingfisher scan`, and `kingfisher blast-radius ` as an alias for the `kingfisher access-map ` subcommand, so the user-facing "blast radius" concept matches the CLI invocation. +- **Access Map UI redesign** in the report viewer: identities are now grouped into collapsible per-provider sections (admin-bearing providers first); permissions are classified by severity (admin / privilege escalation / risky / read-only) with color-coded badges and rollup chips on each card header; the expanded card body renders permissions **once per group** with a "These permissions apply to all N resources above" banner instead of repeating the same 50+ badges per resource; duplicate-named identities (e.g., multiple MongoDB `admin` tokens) now display a discriminator subtitle (`identity_id · access_type`) so they're tellable apart; new "Critical only" toolbar toggle (persisted in `localStorage`) hides read-only permissions and zero-risk identities; the stats bar gained an admin-permission count. Imported TruffleHog/Gitleaks reports keep the previous flat rendering as a backwards-compatible fallback. Underlying JSON now includes `permissions_by_severity` and an `identity.context` discriminator on each `AccessMapEntry`. ## [v1.98.0] - Bounded disk usage for large multi-repo scans (e.g. `--include-contributors --repo-artifacts` against orgs with thousands of repos): cloning, artifact fetching, and scanning now run concurrently through bounded channels, and each cloned repo is removed from the temp directory as soon as its scan completes. On-disk footprint stays roughly `O(num_jobs)` regardless of total repo count instead of growing without bound. `--keep-clones` and `--git-clone-dir` opt out of the per-repo cleanup as before. diff --git a/README.md b/README.md index 0aaf40f..5e5cc7a 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ Kingfisher is a high-performance, open source secret detection tool for source c - **Extensible rules**: 945 built-in rules (485 with live validation) 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 42 providers (see table below). +- **Blast Radius Mapping**: instantly map leaked keys to their effective cloud identities and exposed resources with `--access-map` (alias `--blast-radius`). Supports 43 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, including `tar.gz`/`bz2`/`xz`, ZIP-family containers (`zip`, `jar`, `docx`, `xlsx`, `pptx`, `odt`, `epub`, `hwpx`, and more), `asar`, HWP (Hancom OLE2/CFBF binary with DEFLATE/zlib stream decoding), and EGG (ALZip; raw-byte scanning) - **SQLite Database Scanning**: Automatically extracts and scans SQLite database contents for secrets stored in table rows @@ -481,7 +481,7 @@ The viewer can import Gitleaks JSON and TruffleHog JSON/JSONL in addition to nat > **Use the access map functionality only when you are authorized to inspect the target account, as Kingfisher will issue additional network requests to determine what access the secret grants** -### Supported Access Map Providers (42) +### Supported Access Map Providers (43) | Cloud & Infra | DevOps & CI/CD | SaaS & APIs | Data & Messaging | |:---|:---|:---|:---| @@ -492,7 +492,7 @@ The viewer can import Gitleaks JSON and TruffleHog JSON/JSONL in addition to nat | DigitalOcean | Buildkite | Salesforce | Sendinblue / Brevo | | IBM Cloud | CircleCI | Shopify | Slack | | Terraform Cloud | Harness | Zendesk | Microsoft Teams | -| | JFrog Artifactory | Stripe | | +| | JFrog Artifactory | Stripe | Pinecone | | | JFrog Xray | Square | | | | Jira | PayPal | | | | | Plaid | | @@ -766,7 +766,7 @@ 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 (42 providers including AWS, GCP, Azure, Alibaba Cloud, Stripe, Jira, monday.com, Asana, and more) | +| [ACCESS_MAP.md](docs/ACCESS_MAP.md) | Access map: supported tokens and credential formats (43 providers including AWS, GCP, Azure, Alibaba Cloud, Stripe, Jira, monday.com, Asana, Pinecone, 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 | diff --git a/crates/kingfisher-rules/data/rules/pinecone.yml b/crates/kingfisher-rules/data/rules/pinecone.yml index 0c0c995..288eb65 100644 --- a/crates/kingfisher-rules/data/rules/pinecone.yml +++ b/crates/kingfisher-rules/data/rules/pinecone.yml @@ -23,6 +23,7 @@ rules: references: - https://docs.pinecone.io/reference/api/authentication - https://docs.pinecone.io/reference/api/2025-10/control-plane/list_indexes + - https://docs.pinecone.io/guides/projects/manage-api-keys validation: type: Http content: diff --git a/docs-site/docs/changelog.md b/docs-site/docs/changelog.md index a8e5452..1992f8f 100644 --- a/docs-site/docs/changelog.md +++ b/docs-site/docs/changelog.md @@ -9,6 +9,8 @@ All notable changes to this project will be documented in this file. ## [unreleased v1.99.0] - `--include-contributors` now respects `--github-repo-type` when enumerating contributor-owned repositories: by default contributor forks are excluded (matching the existing `Source` default), previously they were always included regardless of the flag. Added a new `--github-repo-type all` option to opt into the prior behavior of scanning both source and fork repos for contributors, organizations, and users. +- **Access Map:** Pinecone API keys (validated `kingfisher.pinecone.1`): caller resources via `GET /indexes` (with serverless cloud/region or pod environment metadata, deletion-protection state) and `GET /collections`; standalone `kingfisher access-map pinecone` (alias `pinecone.io`). +- Added `--blast-radius` as an alias for `--access-map` on `kingfisher scan`, and `kingfisher blast-radius ` as an alias for the `kingfisher access-map ` subcommand, so the user-facing "blast radius" concept matches the CLI invocation. ## [v1.98.0] - Added first-class **Postman** scanning target: new `kingfisher scan postman` subcommand (and equivalent `--postman-*` flags) fetches workspaces, collections, and environments via the Postman API and scans them for hard-coded credentials in request `auth` blocks, pre-request/test scripts, saved example responses, and — notably — `secret`-typed environment variables, which the API returns in plaintext despite the UI mask. Selectors: `--workspace`, `--collection`, `--environment`, `--all`, with optional `--include-mocks-monitors` and `--api-url` for self-hosted endpoints. Authenticates via `KF_POSTMAN_TOKEN` (or `POSTMAN_API_KEY`) sent as `X-Api-Key`; honors `X-RateLimit-RetryAfter` on 429s. Findings link back to `https://go.postman.co/...` URLs in reports. diff --git a/docs-site/docs/features/access-map.md b/docs-site/docs/features/access-map.md index 7b08664..fe27ee2 100644 --- a/docs-site/docs/features/access-map.md +++ b/docs-site/docs/features/access-map.md @@ -542,8 +542,36 @@ kingfisher access-map asana ./asana.token --format json > asana.access-map.json - `token_details.token_type` is classified from the token prefix (`personal_access_token_v2`, `personal_access_token_v1`, `oauth_or_legacy_pat`, or generic `asana_token`). - Recorded during `scan --access-map` for validated `kingfisher.asana.3`, `kingfisher.asana.4`, and `kingfisher.asana.5` findings only. `kingfisher.asana.1` is a client ID and `kingfisher.asana.2` is a client secret (requiring the client ID for an OAuth exchange), so neither is used on its own to enumerate user-level resources. +### Pinecone (`pinecone`) + +- **Credential**: a single Pinecone API key (read from a file for `kingfisher access-map pinecone `). +- **Token types supported**: API keys accepted by Pinecone's control-plane API with the `Api-Key: ` header. + +Kingfisher performs read-only enumeration against `https://api.pinecone.io` (`X-Pinecone-API-Version: 2025-10`): + +- `GET /indexes` for index inventory, dimension, metric, status, deletion-protection state, and serverless cloud/region or pod environment/type +- `GET /collections` for collection inventory in pod-based projects (gracefully skipped on serverless-only projects) + +Severity is High when the token reaches more than 10 indexes, Medium when it reaches one or more indexes (especially with deletion protection disabled) or any collections, and Low for empty projects or validation failures. + +#### Standalone example (Pinecone) + +```bash +printf '%s' '62b0dbfe-3489-4b79-b850-34d911527c88' > ./pinecone.key +kingfisher access-map pinecone ./pinecone.key --format json > pinecone.access-map.json +``` + +The `kingfisher blast-radius` and `kingfisher blast_radius` aliases also work for any provider, e.g. `kingfisher blast-radius pinecone ./pinecone.key`. + +#### Notes (Pinecone) + +- Pinecone API keys do not carry granular scopes; access follows the API key's project-level permissions, which include read and write (upsert/delete) against any index in the project. +- Indexes with `deletion_protection: enabled` are flagged in the resource record but still accessible for read/write. +- Recorded during `scan --access-map` (or the `--blast-radius` alias) for validated `kingfisher.pinecone.1` findings. + ## Notes on access-map generation during `scan --access-map` - Access-map entries are only recorded for **validated** findings. +- The `--blast-radius` flag is an alias for `--access-map`. The `kingfisher blast-radius ` subcommand is also an alias for `kingfisher access-map `. - Some providers require extra context that Kingfisher infers from the finding context or validation response (for example, Azure DevOps organization name). -- Validated Hugging Face, Gitea, Bitbucket, Buildkite, Harness, OpenAI, Anthropic, Salesforce, Weights & Biases, Microsoft Teams, monday.com, and Asana credentials discovered during scans with `--access-map` are automatically collected and mapped, matching the existing behavior for other platforms. +- Validated Hugging Face, Gitea, Bitbucket, Buildkite, Harness, OpenAI, Anthropic, Salesforce, Weights & Biases, Microsoft Teams, monday.com, Asana, and Pinecone credentials discovered during scans with `--access-map` (or `--blast-radius`) are automatically collected and mapped, matching the existing behavior for other platforms. diff --git a/docs-site/docs/getting-started/quick-start.md b/docs-site/docs/getting-started/quick-start.md index 7b9b473..d657d3c 100644 --- a/docs-site/docs/getting-started/quick-start.md +++ b/docs-site/docs/getting-started/quick-start.md @@ -104,5 +104,5 @@ kingfisher scan /path/to/code --format json --output findings.json - [Basic Scanning](../usage/basic-scanning.md) — full scanning guide with all options - [Platform Integrations](../usage/integrations.md) — GitHub, GitLab, S3, Docker, Slack, and more - [Writing Custom Rules](../rules/overview.md) — create detection rules for your own patterns -- [Access Map](../features/access-map.md) — blast radius mapping for 42 providers +- [Access Map](../features/access-map.md) — blast radius mapping for 43 providers - [Report Viewer & Triager](../features/report-viewer.md) — local and hosted viewer for Kingfisher, Gitleaks, and TruffleHog JSON reports diff --git a/docs/ACCESS_MAP.md b/docs/ACCESS_MAP.md index 3a70d83..c9d5912 100644 --- a/docs/ACCESS_MAP.md +++ b/docs/ACCESS_MAP.md @@ -537,8 +537,35 @@ kingfisher access-map asana ./asana.token --format json > asana.access-map.json - `token_details.token_type` is classified from the token prefix (`personal_access_token_v2`, `personal_access_token_v1`, `oauth_or_legacy_pat`, or generic `asana_token`). - Recorded during `scan --access-map` for validated `kingfisher.asana.3`, `kingfisher.asana.4`, and `kingfisher.asana.5` findings only. `kingfisher.asana.1` is a client ID and `kingfisher.asana.2` is a client secret (requiring the client ID for an OAuth exchange), so neither is used on its own to enumerate user-level resources. +### Pinecone (`pinecone`) + +- **Credential**: a single Pinecone API key (read from a file for `kingfisher access-map pinecone `). +- **Token types supported**: API keys accepted by Pinecone's control-plane API with the `Api-Key: ` header. + +Kingfisher performs read-only enumeration against `https://api.pinecone.io` (`X-Pinecone-API-Version: 2025-10`): + +- `GET /indexes` for index inventory, dimension, metric, status, deletion-protection state, and serverless cloud/region or pod environment/type +- `GET /collections` for collection inventory in pod-based projects (gracefully skipped on serverless-only projects) + +Severity is High when the token reaches more than 10 indexes, Medium when it reaches one or more indexes (especially with deletion protection disabled) or any collections, and Low for empty projects or validation failures. + +#### Standalone example (Pinecone) + +```bash +printf '%s' '62b0dbfe-3489-4b79-b850-34d911527c88' > ./pinecone.key +kingfisher access-map pinecone ./pinecone.key --format json > pinecone.access-map.json +``` + +The `kingfisher blast-radius` and `kingfisher blast_radius` aliases also work for any provider, e.g. `kingfisher blast-radius pinecone ./pinecone.key`. + +#### Notes (Pinecone) + +- Pinecone API keys do not carry granular scopes; access follows the API key's project-level permissions, which include read and write (upsert/delete) against any index in the project. +- Indexes with `deletion_protection: enabled` are flagged in the resource record but still accessible for read/write. +- Recorded during `scan --access-map` (or the `--blast-radius` alias) for validated `kingfisher.pinecone.1` findings. + ## Notes on access-map generation during `scan --access-map` - Access-map entries are only recorded for **validated** findings. - Some providers require extra context that Kingfisher infers from the finding context or validation response (for example, Azure DevOps organization name). -- Validated Hugging Face, Gitea, Bitbucket, Buildkite, Harness, OpenAI, Anthropic, Salesforce, Weights & Biases, Microsoft Teams, monday.com, and Asana credentials discovered during scans with `--access-map` are automatically collected and mapped, matching the existing behavior for other platforms. +- Validated Hugging Face, Gitea, Bitbucket, Buildkite, Harness, OpenAI, Anthropic, Salesforce, Weights & Biases, Microsoft Teams, monday.com, Asana, and Pinecone credentials discovered during scans with `--access-map` (or the `--blast-radius` alias) are automatically collected and mapped, matching the existing behavior for other platforms. diff --git a/docs/viewer/index.html b/docs/viewer/index.html index a34d79b..87d8064 100644 --- a/docs/viewer/index.html +++ b/docs/viewer/index.html @@ -910,6 +910,154 @@ .badge-github { background: #f4f4f5; color: #18181b; border-color: #d4d4d8; } .badge-gitlab { background: #fff1f2; color: #be123c; border-color: #fecdd3; } .badge-perm { background: #ecfdf5; color: #16a34a; border-color: #bbf7d0; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; } + .badge-perm--admin { background: #fef2f2; color: #b91c1c; border-color: #fecaca; } + .badge-perm--privesc { background: #fff7ed; color: #c2410c; border-color: #fed7aa; } + .badge-perm--risky { background: #fffbeb; color: #b45309; border-color: #fde68a; } + .badge-perm--readonly { background: #ecfdf5; color: #16a34a; border-color: #bbf7d0; } + + /* Severity rollup chips on the card header */ + .id-card__rollup { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 6px; + } + + .rollup-chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + border: 1px solid transparent; + letter-spacing: 0.01em; + } + + .rollup-chip--admin { background: #fef2f2; color: #b91c1c; border-color: #fecaca; } + .rollup-chip--privesc { background: #fff7ed; color: #c2410c; border-color: #fed7aa; } + .rollup-chip--risky { background: #fffbeb; color: #b45309; border-color: #fde68a; } + .rollup-chip--readonly { background: #ecfdf5; color: #16a34a; border-color: #bbf7d0; } + + /* Identity discriminator subtitle */ + .id-card__discriminator { + font-size: 12px; + color: var(--text-muted); + margin-top: 2px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + word-break: break-all; + } + + /* Provider section header */ + .am-provider-section { margin-bottom: 18px; } + .am-provider-header { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + background: linear-gradient(180deg, rgba(0, 237, 100, 0.04), transparent), var(--surface-muted); + border: 1px solid var(--border); + border-radius: 12px; + cursor: pointer; + user-select: none; + margin-bottom: 8px; + font-size: 13px; + } + .am-provider-header:hover { border-color: var(--border-strong); } + .am-provider-header__caret { font-size: 11px; color: var(--text-muted); width: 10px; } + .am-provider-header__title { font-weight: 700; } + .am-provider-header__sub { + color: var(--text-muted); + font-size: 12px; + margin-left: auto; + display: flex; + gap: 12px; + flex-wrap: wrap; + } + .am-provider-section__cards { display: flex; flex-direction: column; gap: 12px; } + .am-provider-section.collapsed .am-provider-section__cards { display: none; } + .am-provider-section.collapsed .am-provider-header__caret { transform: rotate(-90deg); } + + /* Group inside expanded card body */ + .perm-group { + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface); + padding: 12px; + margin-bottom: 10px; + } + .perm-group:last-child { margin-bottom: 0; } + .perm-group__resources { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 10px; + } + .perm-group__resource { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 11px; + padding: 4px 8px; + border-radius: 8px; + background: var(--surface-muted); + border: 1px solid var(--border); + color: var(--text-main); + word-break: break-all; + } + .perm-group__resource a { + color: var(--brand); + margin-left: 4px; + font-size: 10px; + } + .perm-group__shared-note { + font-size: 11px; + color: var(--text-muted); + margin-bottom: 8px; + font-style: italic; + } + + /* Severity sub-section inside a permission group */ + .perm-severity { + margin-top: 8px; + } + .perm-severity__title { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-muted); + margin-bottom: 6px; + cursor: pointer; + user-select: none; + } + .perm-severity__title .perm-severity__caret { font-size: 10px; } + .perm-severity__pills { display: flex; flex-wrap: wrap; gap: 3px; } + .perm-severity.collapsed .perm-severity__pills { display: none; } + .perm-severity.collapsed .perm-severity__caret { transform: rotate(-90deg); } + + /* Critical-only filter toggle */ + .am-toolbar__toggle { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--text-muted); + cursor: pointer; + padding: 4px 10px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--surface); + user-select: none; + } + .am-toolbar__toggle input { margin: 0; cursor: pointer; } + .am-toolbar__toggle.active { + border-color: #fca5a5; + color: #b91c1c; + background: #fef2f2; + } .detail-grid { display: grid; @@ -2199,8 +2347,12 @@
-
- +
+ +
@@ -2676,6 +2828,22 @@ renderAccessMapTree(e.target.value || ""); }); + const criticalCheckbox = document.getElementById("am-critical-checkbox"); + const criticalToggleWrap = document.getElementById("am-critical-toggle"); + if (criticalCheckbox) { + criticalCheckbox.checked = isCriticalOnly(); + if (criticalToggleWrap) { + criticalToggleWrap.classList.toggle("active", criticalCheckbox.checked); + } + criticalCheckbox.addEventListener("change", () => { + setCriticalOnly(criticalCheckbox.checked); + if (criticalToggleWrap) { + criticalToggleWrap.classList.toggle("active", criticalCheckbox.checked); + } + renderAccessMapTree(treeSearch.value || ""); + }); + } + if (amToggle) { amToggle.addEventListener("click", () => { const willCollapse = !amContainer.classList.contains("hidden"); @@ -6107,6 +6275,79 @@ win.focus(); } + // Local-storage key for the "Critical only" preset. + const AM_CRITICAL_KEY = "kf-access-map-critical-only"; + + function isCriticalOnly() { + try { return localStorage.getItem(AM_CRITICAL_KEY) === "1"; } + catch (_) { return false; } + } + + function setCriticalOnly(value) { + try { localStorage.setItem(AM_CRITICAL_KEY, value ? "1" : "0"); } + catch (_) { /* ignore */ } + } + + // SEVERITY_ORDER and labels used throughout the renderer. + const SEVERITY_BUCKETS = [ + { key: "admin", label: "Admin", cls: "admin" }, + { key: "privilege_escalation", label: "Privilege Escalation", cls: "privesc" }, + { key: "risky", label: "Risky", cls: "risky" }, + { key: "read_only", label: "Read Only", cls: "readonly" }, + ]; + + // Classify a permission list using the identity's permissions_by_severity + // map when available; otherwise treat all permissions as "risky" (unknown + // classification — better than green "read only" by default). + function classifyPermissions(perms, byClass) { + const out = { admin: [], privilege_escalation: [], risky: [], read_only: [] }; + if (!perms || !perms.length) return out; + if (!byClass) { + out.risky = perms.slice(); + return out; + } + const lookup = new Map(); + Object.keys(out).forEach((k) => { + (byClass[k] || []).forEach((p) => lookup.set(String(p).toLowerCase(), k)); + }); + perms.forEach((p) => { + const cls = lookup.get(String(p).toLowerCase()) || "risky"; + out[cls].push(p); + }); + return out; + } + + // Aggregate per-identity severity counts across all groups. + function summarizeSeverity(entry) { + const byClass = entry.identity && entry.identity.permissions_by_severity; + if (byClass) { + return { + admin: (byClass.admin || []).length, + privilege_escalation: (byClass.privilege_escalation || []).length, + risky: (byClass.risky || []).length, + read_only: (byClass.read_only || []).length, + }; + } + const all = new Set(); + (entry.groups || []).forEach((g) => (g.permissions || []).forEach((p) => all.add(p))); + return { admin: 0, privilege_escalation: 0, risky: all.size, read_only: 0 }; + } + + function entryHasCritical(entry) { + const s = summarizeSeverity(entry); + return s.admin + s.privilege_escalation + s.risky > 0; + } + + // Strip read_only permissions from a group when in "Critical only" mode. + function applyCriticalFilter(group, byClass) { + if (!isCriticalOnly()) return group; + const perms = group.permissions || []; + if (!perms.length) return group; + const cls = classifyPermissions(perms, byClass); + const kept = [...cls.admin, ...cls.privilege_escalation, ...cls.risky]; + return { ...group, permissions: kept }; + } + function renderAccessMapTree(filter = "") { const list = document.getElementById("am-card-list"); list.innerHTML = ""; @@ -6116,20 +6357,124 @@ const hasAccess = Array.isArray(accessMap) && accessMap.length > 0; syncAccessMapUi(hasAccess); renderAccessMapStats(); + syncCriticalToggleVisibility(); - if (!filteredAccessMapView || filteredAccessMapView.length === 0) { + // When "Critical only" is active, drop entries that have no admin/privesc/risky perms. + let visible = filteredAccessMapView; + if (isCriticalOnly()) { + visible = visible.filter(entryHasCritical); + } + + if (!visible || visible.length === 0) { list.innerHTML = `
${getAccessMapEmptyMessage()}
`; return; } + // Group by provider. + const byProvider = new Map(); + visible.forEach((entry) => { + const prov = String(entry.identity.provider || "unknown").toLowerCase(); + if (!byProvider.has(prov)) byProvider.set(prov, []); + byProvider.get(prov).push(entry); + }); + + // Provider order: those with admin perms first, then by entry count desc, then alpha. + const providerOrder = [...byProvider.keys()].sort((a, b) => { + const aHasAdmin = byProvider.get(a).some((e) => summarizeSeverity(e).admin > 0); + const bHasAdmin = byProvider.get(b).some((e) => summarizeSeverity(e).admin > 0); + if (aHasAdmin !== bHasAdmin) return aHasAdmin ? -1 : 1; + const sizeDiff = byProvider.get(b).length - byProvider.get(a).length; + if (sizeDiff) return sizeDiff; + return a.localeCompare(b); + }); + const frag = document.createDocumentFragment(); - filteredAccessMapView.forEach((entry) => { - frag.appendChild(buildIdentityCard(entry)); + providerOrder.forEach((prov) => { + const entries = byProvider.get(prov); + frag.appendChild(buildProviderSection(prov, entries)); }); list.appendChild(frag); } + function buildProviderSection(provider, entries) { + const section = document.createElement("div"); + section.className = "am-provider-section"; + + let totalRes = 0; + const permSet = new Set(); + let admin = 0; + let privesc = 0; + let risky = 0; + entries.forEach((e) => { + (e.groups || []).forEach((g) => { + totalRes += (g.resources || []).length; + (g.permissions || []).forEach((p) => permSet.add(p)); + }); + const s = summarizeSeverity(e); + admin += s.admin; + privesc += s.privilege_escalation; + risky += s.risky; + }); + + const header = document.createElement("div"); + header.className = "am-provider-header"; + + const caret = document.createElement("span"); + caret.className = "am-provider-header__caret"; + caret.textContent = "▼"; + header.appendChild(caret); + + const provBadge = document.createElement("span"); + provBadge.className = "badge " + providerBadgeClass(provider); + provBadge.textContent = provider.toUpperCase(); + header.appendChild(provBadge); + + const title = document.createElement("span"); + title.className = "am-provider-header__title"; + title.textContent = `${entries.length} identit${entries.length !== 1 ? "ies" : "y"}`; + header.appendChild(title); + + const sub = document.createElement("span"); + sub.className = "am-provider-header__sub"; + const subParts = [ + `${totalRes} resource${totalRes !== 1 ? "s" : ""}`, + `${permSet.size} unique perm${permSet.size !== 1 ? "s" : ""}`, + ]; + if (admin) subParts.push(`⚠ ${admin} admin`); + else if (privesc) subParts.push(`${privesc} privesc`); + else if (risky) subParts.push(`${risky} risky`); + sub.innerHTML = subParts.map((p) => `${typeof p === "string" && p.startsWith("<") ? p : escapeHtml(p)}`).join(""); + header.appendChild(sub); + + const cards = document.createElement("div"); + cards.className = "am-provider-section__cards"; + entries.forEach((entry) => cards.appendChild(buildIdentityCard(entry))); + + header.addEventListener("click", () => { + section.classList.toggle("collapsed"); + caret.textContent = section.classList.contains("collapsed") ? "▶" : "▼"; + }); + + section.appendChild(header); + section.appendChild(cards); + return section; + } + + function syncCriticalToggleVisibility() { + const wrap = document.getElementById("am-critical-toggle"); + if (!wrap) return; + const anyClassified = (accessMap || []).some( + (e) => e && e.permissions_by_severity && ( + (e.permissions_by_severity.admin || []).length + + (e.permissions_by_severity.privilege_escalation || []).length + + (e.permissions_by_severity.risky || []).length + + (e.permissions_by_severity.read_only || []).length + ) > 0 + ); + wrap.style.display = anyClassified ? "" : "none"; + } + function renderAccessMapStats() { const bar = document.getElementById("am-stats-bar"); if (!bar) return; @@ -6141,31 +6486,43 @@ let totalRes = 0; const permSet = new Set(); const providerSet = new Set(); + let totalAdmin = 0; + let identitiesWithAdmin = 0; accessMap.forEach((entry) => { providerSet.add((entry.provider || "unknown").toUpperCase()); (entry.groups || []).forEach((g) => { totalRes += (g.resources || []).length; (g.permissions || []).forEach((p) => permSet.add(p)); }); + const s = summarizeSeverity({ identity: entry, groups: entry.groups || [] }); + totalAdmin += s.admin; + if (s.admin > 0) identitiesWithAdmin += 1; }); bar.classList.remove("hidden"); + const adminStat = totalAdmin > 0 + ? `
⚠ ${totalAdmin}admin perm${totalAdmin !== 1 ? "s" : ""} / ${identitiesWithAdmin} identit${identitiesWithAdmin !== 1 ? "ies" : "y"}
` + : ""; bar.innerHTML = `
${accessMap.length}Identities
${totalRes}Resources
${permSet.size}Unique Permissions
+ ${adminStat}
${[...providerSet].map((p) => '' + escapeHtml(p) + '').join(" ")}
`; } function buildIdentityCard(entry) { const identity = entry.identity; - const groups = entry.groups; + const byClass = identity.permissions_by_severity || null; const provider = (identity.provider || "unknown"); const account = identity.account || "(unknown identity)"; const fingerprint = identity.fingerprint || ""; - // Count resources and collect all permissions + // Apply "Critical only" filter to the groups before rendering. + const groups = (entry.groups || []).map((g) => applyCriticalFilter(g, byClass)); + + // Count resources and collect all permissions across (filtered) groups. let resCount = 0; const allPerms = new Set(); groups.forEach((g) => { @@ -6173,6 +6530,7 @@ (g.permissions || []).forEach((p) => allPerms.add(p)); }); const permArr = [...allPerms]; + const sevSummary = summarizeSeverity(entry); // Card container const card = document.createElement("div"); @@ -6198,6 +6556,32 @@ nameRow.appendChild(provBadge); info.appendChild(nameRow); + // Discriminator subtitle (helps tell duplicate-named identities apart). + // Skip context fields that are equal to the account name — those are + // redundant (e.g., MongoDB admin/admin) and don't help the user + // distinguish duplicates. + const discParts = []; + const ctx = identity.context || {}; + const seen = new Set([String(account).toLowerCase()]); + const pushDistinct = (v) => { + if (!v) return; + const k = String(v).toLowerCase(); + if (seen.has(k)) return; + seen.add(k); + discParts.push(v); + }; + pushDistinct(ctx.identity_id); + pushDistinct(ctx.project); + pushDistinct(ctx.tenant); + pushDistinct(ctx.account_id); + pushDistinct(ctx.access_type); + if (discParts.length) { + const disc = document.createElement("div"); + disc.className = "id-card__discriminator"; + disc.textContent = discParts.join(" · "); + info.appendChild(disc); + } + // Meta row const meta = document.createElement("div"); meta.className = "id-card__meta"; @@ -6221,8 +6605,21 @@ } info.appendChild(meta); - // Permission preview (top 6) - if (permArr.length > 0) { + // Severity rollup chips replace the per-permission preview. + if (byClass && (sevSummary.admin + sevSummary.privilege_escalation + sevSummary.risky + sevSummary.read_only) > 0) { + const rollup = document.createElement("div"); + rollup.className = "id-card__rollup"; + SEVERITY_BUCKETS.forEach(({ key, label, cls }) => { + const n = sevSummary[key]; + if (!n) return; + const chip = document.createElement("span"); + chip.className = "rollup-chip rollup-chip--" + cls; + chip.textContent = (key === "admin" ? "⚠ " : "") + n + " " + label; + rollup.appendChild(chip); + }); + info.appendChild(rollup); + } else if (permArr.length > 0) { + // Fallback for entries without classification (e.g. imported reports). const preview = document.createElement("div"); preview.className = "id-card__perms-preview"; const limit = 6; @@ -6257,77 +6654,104 @@ const body = document.createElement("div"); body.className = "id-card__body"; - // Resources section + // Resources / permissions — rendered as one block per group. + // Each group is a (resources, permissions) pair where permissions are + // shared across all resources in the group, so we render them ONCE. const resSection = document.createElement("div"); resSection.className = "id-card__section"; const resTitle = document.createElement("div"); resTitle.className = "id-card__section-title"; - resTitle.textContent = "Resources (" + resCount + ")"; + resTitle.textContent = "Resources & permissions (" + resCount + " resource" + (resCount !== 1 ? "s" : "") + ", " + groups.length + " group" + (groups.length !== 1 ? "s" : "") + ")"; resSection.appendChild(resTitle); - const resGrid = document.createElement("div"); - resGrid.className = "resource-grid"; - - groups.forEach((group) => { + groups.forEach((group, idx) => { const resources = group.resources || []; const perms = group.permissions || []; + if (resources.length === 0 && perms.length === 0) return; - if (resources.length === 0 && perms.length > 0) { - const chip = document.createElement("div"); - chip.className = "resource-chip"; - const chipName = document.createElement("div"); - chipName.className = "resource-chip__name"; - chipName.textContent = "Project-wide / Unscoped"; - chip.appendChild(chipName); - const chipPerms = document.createElement("div"); - chipPerms.className = "resource-chip__perms"; - perms.forEach((p) => { - const tag = document.createElement("span"); - tag.className = "badge badge-perm"; - tag.style.fontSize = "10px"; - tag.textContent = p; - chipPerms.appendChild(tag); + const block = document.createElement("div"); + block.className = "perm-group"; + + // Resource chips at the top of the block (no per-chip permissions — + // they share the permission set rendered below). + if (resources.length > 0) { + const resBox = document.createElement("div"); + resBox.className = "perm-group__resources"; + resources.forEach((resName) => { + const chip = document.createElement("span"); + chip.className = "perm-group__resource"; + chip.textContent = String(resName); + + const { resourceType, resourceName } = extractResourceParts(String(resName)); + const consoleLink = buildResourceConsoleLink(provider, resourceType, resourceName); + if (consoleLink) { + const a = document.createElement("a"); + a.href = consoleLink; + a.target = "_blank"; + a.rel = "noopener noreferrer"; + a.textContent = "↗"; + a.title = "Open in console"; + a.addEventListener("click", (e) => e.stopPropagation()); + chip.appendChild(a); + } + resBox.appendChild(chip); }); - chip.appendChild(chipPerms); - resGrid.appendChild(chip); + block.appendChild(resBox); + } else if (perms.length > 0) { + const note = document.createElement("div"); + note.className = "perm-group__shared-note"; + note.textContent = "Project-wide / Unscoped permissions:"; + block.appendChild(note); } - resources.forEach((resName) => { - const chip = document.createElement("div"); - chip.className = "resource-chip"; - const chipName = document.createElement("div"); - chipName.className = "resource-chip__name"; - chipName.textContent = String(resName); + if (resources.length > 1 && perms.length > 0) { + const note = document.createElement("div"); + note.className = "perm-group__shared-note"; + note.textContent = "These permissions apply to all " + resources.length + " resources above."; + block.appendChild(note); + } - // Console link - const { resourceType, resourceName } = extractResourceParts(String(resName)); - const consoleLink = buildResourceConsoleLink(provider, resourceType, resourceName); - if (consoleLink) { - const a = document.createElement("a"); - a.href = consoleLink; - a.target = "_blank"; - a.rel = "noopener noreferrer"; - a.textContent = "open in console ↗"; - chipName.appendChild(a); - } - chip.appendChild(chipName); + // Permissions classified by severity. + if (perms.length > 0) { + const cls = classifyPermissions(perms, byClass); + SEVERITY_BUCKETS.forEach(({ key, label, cls: sevCls }) => { + const list = cls[key] || []; + if (!list.length) return; + const wrap = document.createElement("div"); + wrap.className = "perm-severity"; + // Default-collapse Read Only when the group has any non-read-only perms. + const hasOther = (cls.admin.length + cls.privilege_escalation.length + cls.risky.length) > 0; + if (key === "read_only" && hasOther) wrap.classList.add("collapsed"); - if (perms.length > 0) { - const chipPerms = document.createElement("div"); - chipPerms.className = "resource-chip__perms"; - perms.forEach((p) => { + const titleEl = document.createElement("div"); + titleEl.className = "perm-severity__title"; + titleEl.innerHTML = ` ${escapeHtml(label)} ${list.length}`; + + const pills = document.createElement("div"); + pills.className = "perm-severity__pills"; + list.forEach((p) => { const tag = document.createElement("span"); - tag.className = "badge badge-perm"; + tag.className = "badge badge-perm badge-perm--" + sevCls; tag.style.fontSize = "10px"; tag.textContent = p; - chipPerms.appendChild(tag); + pills.appendChild(tag); }); - chip.appendChild(chipPerms); - } - resGrid.appendChild(chip); - }); + + titleEl.addEventListener("click", (e) => { + e.stopPropagation(); + wrap.classList.toggle("collapsed"); + const c = titleEl.querySelector(".perm-severity__caret"); + if (c) c.textContent = wrap.classList.contains("collapsed") ? "▶" : "▼"; + }); + + wrap.appendChild(titleEl); + wrap.appendChild(pills); + block.appendChild(wrap); + }); + } + + resSection.appendChild(block); }); - resSection.appendChild(resGrid); body.appendChild(resSection); // Token details section diff --git a/src/access_map.rs b/src/access_map.rs index 83d0179..3f39d28 100644 --- a/src/access_map.rs +++ b/src/access_map.rs @@ -36,6 +36,7 @@ pub(crate) mod mongodb; pub(crate) mod mysql; mod openai; mod paypal; +mod pinecone; mod plaid; pub(crate) mod postgres; mod report; @@ -112,6 +113,7 @@ pub async fn run(args: AccessMapArgs) -> Result<()> { AccessMapProvider::Xray => xray::map_access(&args).await?, AccessMapProvider::Monday => monday::map_access(&args).await?, AccessMapProvider::Asana => asana::map_access(&args).await?, + AccessMapProvider::Pinecone => pinecone::map_access(&args).await?, }; let mut writer = args.output_args.get_writer()?; @@ -228,6 +230,8 @@ pub enum AccessMapRequest { Monday { token: String, fingerprint: String }, /// An Asana personal access token / OAuth token. Asana { token: String, fingerprint: String }, + /// A Pinecone API key. + Pinecone { token: String, fingerprint: String }, } /// Structured output describing the resolved identity and its risk profile. @@ -292,7 +296,7 @@ pub struct RoleBinding { } /// Summarized permissions grouped by risk profile. -#[derive(Debug, Serialize, Default, Clone)] +#[derive(Debug, Serialize, Default, Clone, JsonSchema)] pub struct PermissionSummary { /// Administrator or owner-level permissions. pub admin: Vec, @@ -304,6 +308,22 @@ pub struct PermissionSummary { pub read_only: Vec, } +impl PermissionSummary { + pub fn is_empty(&self) -> bool { + self.admin.is_empty() + && self.privilege_escalation.is_empty() + && self.risky.is_empty() + && self.read_only.is_empty() + } + + pub fn total(&self) -> usize { + self.admin.len() + + self.privilege_escalation.len() + + self.risky.len() + + self.read_only.len() + } +} + /// Exposed resources and their assessed risk. #[derive(Debug, Serialize, Clone)] pub struct ResourceExposure { @@ -566,6 +586,9 @@ pub async fn map_requests(requests: Vec) -> Vec { (map_token(&AsanaMapper, &token).await, fingerprint) } + AccessMapRequest::Pinecone { token, fingerprint } => { + (map_token(&PineconeMapper, &token).await, fingerprint) + } }; mapped.fingerprint = Some(fp); @@ -905,6 +928,19 @@ impl TokenAccessMapper for AsanaMapper { } } +/// Pinecone access mapper. +pub struct PineconeMapper; + +impl TokenAccessMapper for PineconeMapper { + fn cloud_name(&self) -> &'static str { + "pinecone" + } + + async fn map_access_from_token(&self, token: &str) -> Result { + pinecone::map_access_from_token(token).await + } +} + // ------------------------------------------------------------------------------------------------- // Helper functions // ------------------------------------------------------------------------------------------------- diff --git a/src/access_map/pinecone.rs b/src/access_map/pinecone.rs new file mode 100644 index 0000000..de0e7f4 --- /dev/null +++ b/src/access_map/pinecone.rs @@ -0,0 +1,349 @@ +use anyhow::{Context, Result, anyhow}; +use reqwest::{Client, header}; +use serde::Deserialize; +use tracing::warn; + +use crate::{cli::commands::access_map::AccessMapArgs, validation::GLOBAL_USER_AGENT}; + +use super::{ + AccessMapResult, AccessSummary, AccessTokenDetails, PermissionSummary, ResourceExposure, + RoleBinding, Severity, build_recommendations, +}; + +const PINECONE_API: &str = "https://api.pinecone.io"; + +#[derive(Deserialize, Default)] +struct PineconeIndexList { + #[serde(default)] + indexes: Vec, +} + +#[derive(Deserialize, Default, Clone)] +struct PineconeIndex { + #[serde(default)] + name: Option, + #[serde(default)] + host: Option, + #[serde(default)] + dimension: Option, + #[serde(default)] + metric: Option, + #[serde(default)] + status: Option, + #[serde(default)] + spec: Option, + #[serde(default)] + deletion_protection: Option, +} + +#[derive(Deserialize, Default, Clone)] +struct PineconeIndexStatus { + #[serde(default)] + ready: Option, + #[serde(default)] + state: Option, +} + +#[derive(Deserialize, Default, Clone)] +struct PineconeIndexSpec { + #[serde(default)] + serverless: Option, + #[serde(default)] + pod: Option, +} + +#[derive(Deserialize, Default, Clone)] +struct PineconeServerless { + #[serde(default)] + cloud: Option, + #[serde(default)] + region: Option, +} + +#[derive(Deserialize, Default, Clone)] +struct PineconePod { + #[serde(default)] + environment: Option, + #[serde(default)] + pod_type: Option, + #[serde(default)] + pods: Option, +} + +#[derive(Deserialize, Default)] +struct PineconeCollectionList { + #[serde(default)] + collections: Vec, +} + +#[derive(Deserialize, Default)] +struct PineconeCollection { + #[serde(default)] + name: Option, + #[serde(default)] + environment: Option, + #[serde(default)] + status: Option, +} + +pub async fn map_access(args: &AccessMapArgs) -> Result { + let token = if let Some(path) = args.credential_path.as_deref() { + let raw = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read Pinecone token from {}", path.display()))?; + raw.trim().to_string() + } else { + return Err(anyhow!("Pinecone access-map requires a validated API key from scan results")); + }; + + map_access_from_token(&token).await +} + +pub async fn map_access_from_token(token: &str) -> Result { + let client = Client::builder() + .user_agent(GLOBAL_USER_AGENT.as_str()) + .build() + .context("Failed to build Pinecone HTTP client")?; + + let indexes = fetch_indexes(&client, token).await?; + let collections = fetch_collections(&client, token).await.unwrap_or_else(|err| { + warn!("Pinecone access-map: collection enumeration failed: {err}"); + Vec::new() + }); + + let mut roles = Vec::new(); + let mut permissions = PermissionSummary::default(); + let mut resources = Vec::new(); + let mut risk_notes = Vec::new(); + + roles.push(RoleBinding { + name: "api_key_holder".into(), + source: "pinecone".into(), + permissions: vec![ + "index:list".into(), + "index:describe".into(), + "index:upsert".into(), + "index:query".into(), + "index:delete".into(), + ], + }); + permissions.risky.push("index:upsert".into()); + permissions.risky.push("index:delete".into()); + permissions.read_only.push("index:list".into()); + permissions.read_only.push("index:describe".into()); + permissions.read_only.push("index:query".into()); + + let mut serverless_count = 0usize; + let mut pod_count = 0usize; + + for index in &indexes { + let name = index.name.clone().unwrap_or_else(|| "unknown".to_string()); + let metric = index.metric.as_deref().unwrap_or("unknown"); + let dimension = index + .dimension + .map(|d| d.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + let ready = index.status.as_ref().and_then(|s| s.ready).unwrap_or(false); + let state = index + .status + .as_ref() + .and_then(|s| s.state.clone()) + .unwrap_or_else(|| "unknown".to_string()); + let deletion_protection = index.deletion_protection.as_deref().unwrap_or("disabled"); + + let mut perm_labels = vec![ + format!("metric:{metric}"), + format!("dimension:{dimension}"), + format!("state:{state}"), + format!("deletion_protection:{deletion_protection}"), + ]; + + let mut location = String::new(); + if let Some(spec) = &index.spec { + if let Some(serverless) = &spec.serverless { + serverless_count += 1; + let cloud = serverless.cloud.as_deref().unwrap_or("unknown"); + let region = serverless.region.as_deref().unwrap_or("unknown"); + perm_labels.push(format!("serverless:{cloud}/{region}")); + location = format!("serverless {cloud}/{region}"); + } else if let Some(pod) = &spec.pod { + pod_count += 1; + let env = pod.environment.as_deref().unwrap_or("unknown"); + let pod_type = pod.pod_type.as_deref().unwrap_or("unknown"); + let pods = pod.pods.map(|p| p.to_string()).unwrap_or_else(|| "?".into()); + perm_labels.push(format!("pod:{env}/{pod_type}/{pods}")); + location = format!("pod {env}/{pod_type}"); + } + } + + let host_suffix = + index.host.as_ref().map(|h| format!(" ({h})")).unwrap_or_default(); + let location_suffix = if location.is_empty() { + String::new() + } else { + format!(" — {location}") + }; + let ready_marker = if ready { "ready" } else { "not ready" }; + + resources.push(ResourceExposure { + resource_type: "index".into(), + name: name.clone(), + permissions: perm_labels, + risk: severity_to_str(Severity::Medium).to_string(), + reason: format!( + "Pinecone index {name}{location_suffix}{host_suffix} accessible with this key ({ready_marker})" + ), + }); + } + + for collection in &collections { + let name = collection.name.clone().unwrap_or_else(|| "unknown".to_string()); + let env = collection.environment.as_deref().unwrap_or("unknown"); + let status = collection.status.as_deref().unwrap_or("unknown"); + resources.push(ResourceExposure { + resource_type: "collection".into(), + name: name.clone(), + permissions: vec![format!("environment:{env}"), format!("status:{status}")], + risk: severity_to_str(Severity::Low).to_string(), + reason: format!( + "Pinecone collection {name} in {env} accessible with this key ({status})" + ), + }); + } + + if indexes.is_empty() && collections.is_empty() { + resources.push(ResourceExposure { + resource_type: "project".into(), + name: "pinecone_project".into(), + permissions: Vec::new(), + risk: severity_to_str(Severity::Low).to_string(), + reason: "Pinecone API key validated but no indexes or collections were enumerated" + .into(), + }); + risk_notes.push("Token did not enumerate any indexes or collections".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(&indexes, &collections); + + if serverless_count > 0 && pod_count > 0 { + risk_notes.push(format!( + "Token reaches both serverless ({serverless_count}) and pod-based ({pod_count}) indexes" + )); + } + + Ok(AccessMapResult { + cloud: "pinecone".into(), + identity: AccessSummary { + id: "pinecone_api_key".into(), + access_type: "api_key".into(), + project: None, + tenant: None, + account_id: None, + }, + roles, + permissions, + resources, + severity, + recommendations: build_recommendations(severity), + risk_notes, + token_details: Some(AccessTokenDetails { + name: None, + username: None, + account_type: Some("api_key".into()), + company: None, + location: None, + email: None, + url: Some("https://app.pinecone.io".into()), + token_type: Some("pinecone_api_key".into()), + created_at: None, + last_used_at: None, + expires_at: None, + user_id: None, + scopes: Vec::new(), + }), + provider_metadata: None, + fingerprint: None, + }) +} + +async fn fetch_indexes(client: &Client, token: &str) -> Result> { + let url = format!("{PINECONE_API}/indexes"); + let resp = client + .get(url) + .header("Api-Key", token) + .header(header::ACCEPT, "application/json") + .header("X-Pinecone-API-Version", "2025-10") + .send() + .await + .context("Pinecone access-map: failed to GET /indexes")?; + + if !resp.status().is_success() { + return Err(anyhow!( + "Pinecone access-map: /indexes lookup failed with HTTP {}", + resp.status() + )); + } + + let list: PineconeIndexList = + resp.json().await.context("Pinecone access-map: invalid /indexes JSON")?; + Ok(list.indexes) +} + +async fn fetch_collections(client: &Client, token: &str) -> Result> { + let url = format!("{PINECONE_API}/collections"); + let resp = client + .get(url) + .header("Api-Key", token) + .header(header::ACCEPT, "application/json") + .header("X-Pinecone-API-Version", "2025-10") + .send() + .await + .context("Pinecone access-map: failed to GET /collections")?; + + if !resp.status().is_success() { + warn!("Pinecone access-map: /collections returned HTTP {}", resp.status()); + return Ok(Vec::new()); + } + + let list: PineconeCollectionList = + resp.json().await.context("Pinecone access-map: invalid /collections JSON")?; + Ok(list.collections) +} + +fn derive_severity( + indexes: &[PineconeIndex], + collections: &[PineconeCollection], +) -> Severity { + let index_count = indexes.len(); + let collection_count = collections.len(); + let total = index_count + collection_count; + let any_unprotected = indexes + .iter() + .any(|i| i.deletion_protection.as_deref().unwrap_or("disabled") != "enabled"); + + if index_count > 10 { + return Severity::High; + } + if index_count > 0 && any_unprotected { + return Severity::Medium; + } + if total > 0 { + return Severity::Medium; + } + Severity::Low +} + +fn severity_to_str(severity: Severity) -> &'static str { + match severity { + Severity::Low => "low", + Severity::Medium => "medium", + Severity::High => "high", + Severity::Critical => "critical", + } +} diff --git a/src/cli/commands/access_map.rs b/src/cli/commands/access_map.rs index 996bfdb..442ab85 100644 --- a/src/cli/commands/access_map.rs +++ b/src/cli/commands/access_map.rs @@ -156,4 +156,7 @@ pub enum AccessMapProvider { Monday, /// Asana Asana, + /// Pinecone + #[clap(alias = "pinecone.io")] + Pinecone, } diff --git a/src/cli/commands/scan.rs b/src/cli/commands/scan.rs index b7b486c..ec93065 100644 --- a/src/cli/commands/scan.rs +++ b/src/cli/commands/scan.rs @@ -132,7 +132,7 @@ pub struct ScanArgs { /// Map validated cloud credentials to their effective identities; use only when /// authorized for the target account because this triggers additional network /// requests to determine granted access - #[arg(global = true, long, default_value_t = false)] + #[arg(global = true, long, alias = "blast-radius", default_value_t = false)] pub access_map: bool, // /// Optional path to write a consolidated access-map HTML report diff --git a/src/cli/global.rs b/src/cli/global.rs index 79f1bd4..c4061ab 100644 --- a/src/cli/global.rs +++ b/src/cli/global.rs @@ -79,7 +79,7 @@ pub enum Command { Revoke(RevokeArgs), /// Map a cloud credential to its identity, permissions, and blast radius - #[command(name = "access-map", alias = "access_map")] + #[command(name = "access-map", aliases = ["access_map", "blast-radius", "blast_radius"])] AccessMap(AccessMapArgs), /// View Kingfisher JSON/JSONL reports in a local web UI diff --git a/src/reporter.rs b/src/reporter.rs index b38a45e..0142969 100644 --- a/src/reporter.rs +++ b/src/reporter.rs @@ -15,7 +15,9 @@ use url::Url; use kingfisher_scanner::validation::http_validation::is_auto_provided_request_var; use crate::{ - access_map::{AccessSummary, AccessTokenDetails, ProviderMetadata, ResourceExposure}, + access_map::{ + AccessSummary, AccessTokenDetails, PermissionSummary, ProviderMetadata, ResourceExposure, + }, blob::BlobMetadata, bstring_escape::Escaped, cli, @@ -1254,6 +1256,13 @@ impl DetailsReporter { groups.sort_by(|a, b| a.resources.cmp(&b.resources)); + let permissions_by_severity = if result.permissions.is_empty() { + None + } else { + Some(result.permissions.clone()) + }; + let context = AccessIdentityContext::from_summary(&result.identity); + entries.push(AccessMapEntry { provider: result.cloud.clone(), account: account.clone(), @@ -1261,6 +1270,8 @@ impl DetailsReporter { token_details: result.token_details.clone(), provider_metadata: result.provider_metadata.clone(), fingerprint: result.fingerprint.clone(), + permissions_by_severity, + context, }); } @@ -1468,6 +1479,14 @@ pub struct AccessMapEntry { pub provider_metadata: Option, #[serde(skip_serializing_if = "Option::is_none")] pub fingerprint: Option, + /// Permissions classified by severity (admin / privilege_escalation / risky / read_only). + /// Same shape as PermissionSummary; aggregated across all groups for this identity. + /// Absent when the underlying provider didn't classify (e.g., imported reports). + #[serde(skip_serializing_if = "Option::is_none")] + pub permissions_by_severity: Option, + /// Discriminator context to tell duplicate-named identities apart in the UI. + #[serde(skip_serializing_if = "Option::is_none")] + pub context: Option, } #[derive(Serialize, JsonSchema, Clone, Debug)] @@ -1477,6 +1496,48 @@ pub struct AccessMapResourceGroup { pub permissions: Vec, } +/// Optional identity context (project, tenant, account, host) used by the +/// viewer to disambiguate duplicate-named identities. +#[derive(Serialize, JsonSchema, Clone, Debug, Default)] +pub struct AccessIdentityContext { + #[serde(skip_serializing_if = "Option::is_none")] + pub project: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tenant: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub account_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub identity_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub access_type: Option, +} + +impl AccessIdentityContext { + fn from_summary(identity: &AccessSummary) -> Option { + let project = identity.project.clone().filter(|s| !s.trim().is_empty()); + let tenant = identity.tenant.clone().filter(|s| !s.trim().is_empty()); + let account_id = identity.account_id.clone().filter(|s| !s.trim().is_empty()); + let id = identity.id.clone(); + let identity_id = if id.trim().is_empty() { None } else { Some(id) }; + let access_type = if identity.access_type.trim().is_empty() { + None + } else { + Some(identity.access_type.clone()) + }; + + if project.is_none() + && tenant.is_none() + && account_id.is_none() + && identity_id.is_none() + && access_type.is_none() + { + return None; + } + + Some(Self { project, tenant, account_id, identity_id, access_type }) + } +} + #[derive(Serialize, JsonSchema, Clone, Debug)] pub struct ReportEnvelope { pub findings: Vec, diff --git a/src/scanner/validation.rs b/src/scanner/validation.rs index 16078c9..1695ae4 100644 --- a/src/scanner/validation.rs +++ b/src/scanner/validation.rs @@ -407,6 +407,14 @@ impl AccessMapCollector { .or_insert_with(|| AccessMapRequest::Asana { token: token.to_string(), fingerprint }); } + pub fn record_pinecone(&self, token: &str, fingerprint: String) { + let key = xxhash_rust::xxh3::xxh3_64(format!("pinecone|{token}").as_bytes()); + self.inner.entry(key).or_insert_with(|| AccessMapRequest::Pinecone { + token: token.to_string(), + fingerprint, + }); + } + pub fn into_requests(self) -> Vec { self.inner.iter().map(|entry| entry.value().clone()).collect() } @@ -1474,6 +1482,13 @@ fn maybe_record_access_map(om: &OwnedBlobMatch, collector: Option<&AccessMapColl } } } + if om.rule.id() == "kingfisher.pinecone.1" { + if let Some((_, value, ..)) = captures.iter().find(|(name, ..)| name == "TOKEN") { + if !value.is_empty() { + collector.record_pinecone(value, fp.clone()); + } + } + } } } }