forked from mirrors/kingfisher
improved access map viewer
This commit is contained in:
parent
20e08105cf
commit
1619737e2c
15 changed files with 1022 additions and 73 deletions
|
|
@ -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 <provider>` as an alias for the `kingfisher access-map <provider>` 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.
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 <provider>` as an alias for the `kingfisher access-map <provider>` 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.
|
||||
|
|
|
|||
|
|
@ -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 <FILE>`).
|
||||
- **Token types supported**: API keys accepted by Pinecone's control-plane API with the `Api-Key: <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 <provider>` subcommand is also an alias for `kingfisher access-map <provider>`.
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 <FILE>`).
|
||||
- **Token types supported**: API keys accepted by Pinecone's control-plane API with the `Api-Key: <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.
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
<div id="am-empty-notice" class="am-empty-hint hidden">No Access Map entries in this report. Run a scan with <code>--access-map</code> to map credential blast radius.</div>
|
||||
<div class="am-container" id="am-container">
|
||||
<div id="am-stats-bar" class="am-stats hidden"></div>
|
||||
<div class="am-toolbar">
|
||||
<input id="tree-search" type="text" placeholder="Filter by identity, resource, or permission…">
|
||||
<div class="am-toolbar" style="display:flex; gap:10px; align-items:center;">
|
||||
<input id="tree-search" type="text" placeholder="Filter by identity, resource, or permission…" style="flex:1;">
|
||||
<label id="am-critical-toggle" class="am-toolbar__toggle" title="Hide read-only permissions and identities with no admin / privilege-escalation / risky permissions.">
|
||||
<input type="checkbox" id="am-critical-checkbox">
|
||||
<span>Critical only</span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="am-card-list" class="am-card-list">
|
||||
<div style="color:var(--text-muted); font-size:13px; text-align:center; padding:32px 0;">
|
||||
|
|
@ -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 =
|
||||
`<div style="padding:32px 16px; text-align:center; color:var(--text-muted); font-size:13px;">${getAccessMapEmptyMessage()}</div>`;
|
||||
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(`<span class="rollup-chip rollup-chip--admin">⚠ ${admin} admin</span>`);
|
||||
else if (privesc) subParts.push(`<span class="rollup-chip rollup-chip--privesc">${privesc} privesc</span>`);
|
||||
else if (risky) subParts.push(`<span class="rollup-chip rollup-chip--risky">${risky} risky</span>`);
|
||||
sub.innerHTML = subParts.map((p) => `<span>${typeof p === "string" && p.startsWith("<") ? p : escapeHtml(p)}</span>`).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
|
||||
? `<div class="am-stat"><span class="am-stat-value" style="color:#b91c1c;">⚠ ${totalAdmin}</span><span class="am-stat-label">admin perm${totalAdmin !== 1 ? "s" : ""} / ${identitiesWithAdmin} identit${identitiesWithAdmin !== 1 ? "ies" : "y"}</span></div>`
|
||||
: "";
|
||||
bar.innerHTML = `
|
||||
<div class="am-stat"><span class="am-stat-value">${accessMap.length}</span><span class="am-stat-label">Identities</span></div>
|
||||
<div class="am-stat"><span class="am-stat-value">${totalRes}</span><span class="am-stat-label">Resources</span></div>
|
||||
<div class="am-stat"><span class="am-stat-value">${permSet.size}</span><span class="am-stat-label">Unique Permissions</span></div>
|
||||
${adminStat}
|
||||
<div class="am-stat">${[...providerSet].map((p) => '<span class="badge ' + providerBadgeClass(p) + '">' + escapeHtml(p) + '</span>').join(" ")}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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 = `<span class="perm-severity__caret">▼</span> ${escapeHtml(label)} <span style="color:var(--text-main); font-weight:700;">${list.length}</span>`;
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
|
|
@ -304,6 +308,22 @@ pub struct PermissionSummary {
|
|||
pub read_only: Vec<String>,
|
||||
}
|
||||
|
||||
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<AccessMapRequest>) -> Vec<AccessMapResul
|
|||
AccessMapRequest::Asana { token, fingerprint } => {
|
||||
(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<AccessMapResult> {
|
||||
pinecone::map_access_from_token(token).await
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Helper functions
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
|
|
|||
349
src/access_map/pinecone.rs
Normal file
349
src/access_map/pinecone.rs
Normal file
|
|
@ -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<PineconeIndex>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default, Clone)]
|
||||
struct PineconeIndex {
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
host: Option<String>,
|
||||
#[serde(default)]
|
||||
dimension: Option<u64>,
|
||||
#[serde(default)]
|
||||
metric: Option<String>,
|
||||
#[serde(default)]
|
||||
status: Option<PineconeIndexStatus>,
|
||||
#[serde(default)]
|
||||
spec: Option<PineconeIndexSpec>,
|
||||
#[serde(default)]
|
||||
deletion_protection: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default, Clone)]
|
||||
struct PineconeIndexStatus {
|
||||
#[serde(default)]
|
||||
ready: Option<bool>,
|
||||
#[serde(default)]
|
||||
state: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default, Clone)]
|
||||
struct PineconeIndexSpec {
|
||||
#[serde(default)]
|
||||
serverless: Option<PineconeServerless>,
|
||||
#[serde(default)]
|
||||
pod: Option<PineconePod>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default, Clone)]
|
||||
struct PineconeServerless {
|
||||
#[serde(default)]
|
||||
cloud: Option<String>,
|
||||
#[serde(default)]
|
||||
region: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default, Clone)]
|
||||
struct PineconePod {
|
||||
#[serde(default)]
|
||||
environment: Option<String>,
|
||||
#[serde(default)]
|
||||
pod_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pods: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct PineconeCollectionList {
|
||||
#[serde(default)]
|
||||
collections: Vec<PineconeCollection>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct PineconeCollection {
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
environment: Option<String>,
|
||||
#[serde(default)]
|
||||
status: 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 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<AccessMapResult> {
|
||||
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<Vec<PineconeIndex>> {
|
||||
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<Vec<PineconeCollection>> {
|
||||
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",
|
||||
}
|
||||
}
|
||||
|
|
@ -156,4 +156,7 @@ pub enum AccessMapProvider {
|
|||
Monday,
|
||||
/// Asana
|
||||
Asana,
|
||||
/// Pinecone
|
||||
#[clap(alias = "pinecone.io")]
|
||||
Pinecone,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<ProviderMetadata>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fingerprint: Option<String>,
|
||||
/// 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<PermissionSummary>,
|
||||
/// Discriminator context to tell duplicate-named identities apart in the UI.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub context: Option<AccessIdentityContext>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, JsonSchema, Clone, Debug)]
|
||||
|
|
@ -1477,6 +1496,48 @@ pub struct AccessMapResourceGroup {
|
|||
pub permissions: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tenant: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub account_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub identity_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub access_type: Option<String>,
|
||||
}
|
||||
|
||||
impl AccessIdentityContext {
|
||||
fn from_summary(identity: &AccessSummary) -> Option<Self> {
|
||||
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<FindingReporterRecord>,
|
||||
|
|
|
|||
|
|
@ -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<AccessMapRequest> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue