Merge pull request #367 from mongodb/development

This commit is contained in:
Mick Grove 2026-04-29 15:05:01 -07:00 committed by GitHub
commit 0dc8157a6e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 1980 additions and 726 deletions

View file

@ -25,7 +25,7 @@ jobs:
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
- uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
@ -46,7 +46,7 @@ jobs:
CI: true
- name: Upload artifact
uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4.0.0
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
path: docs-site/site

View file

@ -141,7 +141,7 @@ jobs:
--plat-name win_arm64
- name: Publish to PyPI (Trusted Publishing)
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # release/v1
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
with:
packages-dir: dist-pypi
verbose: true

View file

@ -12,7 +12,7 @@ Key capabilities:
- Direct secret revocation from CLI
- Blast radius mapping (AWS, GCP, Azure, GitHub, GitLab, Slack)
- Output formats: TOON, JSON, SARIF, interactive HTML
- Platform integrations: GitHub, GitLab, Azure Repos, Bitbucket, Gitea, Hugging Face, S3, GCS, Docker, Jira, Confluence, Slack
- Platform integrations: GitHub, GitLab, Azure Repos, Bitbucket, Gitea, Hugging Face, S3, GCS, Docker, Jira, Confluence, Slack, Microsoft Teams, Postman
## Scope
- Applies to the entire repository rooted at this file.

View file

@ -2,7 +2,8 @@
All notable changes to this project will be documented in this file.
## [unreleased v1.98.0]
## [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.
- Fixed [#359](https://github.com/mongodb/kingfisher/issues/359): added `kingfisher.github.9` to detect the new ~520-character stateless GitHub App installation token format (`ghs_<APP_ID>_<JWT>`). The legacy 36-character `ghs_` rule (`kingfisher.github.5`) is retained for older / GHES-issued tokens that are still in circulation.
- Added provider endpoint overrides for validation and revocation via global `--endpoint PROVIDER=URL` and `--endpoint-config FILE`, with built-in support for self-hosted GitHub, GitLab, Gitea, Jira, Confluence, and Artifactory instances.

798
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -94,10 +94,10 @@ clap = { version = "4.5", features = [
anyhow = "1.0"
bstr = { version = "1.12", features = ["serde"] }
fixedbitset = "0.5"
gix = { version = "0.81", features = ["max-performance-safe", "serde", "blocking-network-client"] }
gix = { version = "0.83", features = ["max-performance-safe", "serde", "blocking-network-client"] }
ignore = "0.4"
petgraph = "0.8"
roaring = "0.11.3"
roaring = "0.11.4"
schemars = "0.8"
serde = { version = "1.0", features = ["derive", "rc"] }
smallvec = { version = "1", features = [
@ -168,13 +168,13 @@ tree_magic_mini = "3.2"
content_inspector = "0.2.4"
rustc-hash = "2.1.1"
bzip2-rs = "0.1.2"
zip = { version = "8.5.0", default-features = false, features = ["deflate", "deflate64", "time"] }
zip = { version = "8.6.0", default-features = false, features = ["deflate", "deflate64", "time"] }
tar = "0.4.44"
lzma-rs = "0.3.0"
asar = "0.3.0"
cfb = "0.14"
rusqlite = { version = "0.39", features = ["bundled"] }
blake3 = "1.8.2"
blake3 = "1.8.5"
memchr = "2.7"
memmap2 = "0.9.9"
futures = "0.3.31"
@ -195,7 +195,7 @@ mimalloc = { version = "0.1.48", features = ["override"] }
thread_local = "1.1.9"
bloomfilter = "3.0.1"
uuid = "1.19.0"
rand = "0.10.0"
rand = "0.10.1"
percent-encoding = "2.3.2"
self_update = { version = "0.44.0", default-features = false, features = ["reqwest", "rustls", "archive-tar", "archive-zip", "compression-flate2"] }
semver = "1.0.27"
@ -209,7 +209,7 @@ aws-sdk-iam = { version = "1.104.0", default-features = false, features = ["defa
aws-sdk-ec2 = { version = "1.211.0", default-features = false, features = ["default-https-client", "rt-tokio"] }
aws-sdk-dynamodb = { version = "1.105.0", default-features = false, features = ["default-https-client", "rt-tokio"] }
aws-sdk-lambda = { version = "1.116.0", default-features = false, features = ["default-https-client", "rt-tokio"] }
aws-sdk-kms = { version = "1.100.0", default-features = false, features = ["default-https-client", "rt-tokio"] }
aws-sdk-kms = { version = "1.106.0", default-features = false, features = ["default-https-client", "rt-tokio"] }
aws-sdk-secretsmanager = { version = "1.100.0", default-features = false, features = ["default-https-client", "rt-tokio"] }
aws-sdk-sqs = { version = "1.90.0", default-features = false, features = ["default-https-client", "rt-tokio"] }
aws-sdk-sns = { version = "1.89.0", default-features = false, features = ["default-https-client", "rt-tokio"] }

View file

@ -22,7 +22,7 @@ Kingfisher is an open source secret scanner and **live secret validation** tool
It combines Intel's SIMD-accelerated regex engine (Hyperscan) with language-aware parsing to achieve high accuracy at massive scale, and ships with [945 built-in rules](https://mongodb.github.io/kingfisher/rules/builtin-rules/) to detect, **validate**, and triage leaked API keys, tokens, and credentials before they ever reach production.
Kingfisher also ships a **browser-based report viewer** that visualizes and triages findings from Kingfisher **and** from Gitleaks and TruffleHog JSON reports — so you can import scans from other tools and triage them in the same UI. A [hosted copy of the viewer](https://mongodb.github.io/kingfisher/viewer/) is published on the Kingfisher docs site.
Kingfisher also ships a **browser-based report viewer** that visualizes and triages findings from Kingfisher **and** from Gitleaks and TruffleHog JSON reports — so you can import scans from other tools and triage them in the same UI. A [hosted copy of the viewer](https://mongodb.github.io/kingfisher/viewer/) is published on the Kingfisher docs site [or run locally](#3-scan-and-view-results-in-browser)
Designed for offensive security engineers and blue-team defenders alike, Kingfisher helps you scan repositories, cloud storage, chat, docs, and CI pipelines to find and verify exposed secrets quickly.
@ -34,7 +34,7 @@ Designed for offensive security engineers and blue-team defenders alike, Kingfis
Kingfisher is a high-performance, open source secret detection tool for source code and developer platforms. If you are searching for a "GitHub secret scanner," "API key scanner," "token leak detection," or "Git secrets scanner," this project is built for that workflow.
- Scan code, Git history, and integrated platforms (GitHub, GitLab, Azure Repos, Bitbucket, Gitea, Hugging Face, Jira, Confluence, Slack, Microsoft Teams, Docker, AWS S3, and Google Cloud Storage)
- Scan code, Git history, and integrated platforms (GitHub, GitLab, Azure Repos, Bitbucket, Gitea, Hugging Face, Jira, Confluence, Slack, Microsoft Teams, Postman, Docker, AWS S3, and Google Cloud Storage)
- Validate discovered credentials against provider APIs to reduce false positives
- Revoke supported secrets directly from the CLI
- Generate JSON, SARIF, TOON, and HTML outputs for security teams, compliance, and CI
@ -48,9 +48,9 @@ Kingfisher is a high-performance, open source secret detection tool for source c
|:-------------:|:----------:|:------:|:------:|:-------------:|:----------:|:------:|:-------------:|
| <img src="./docs/assets/icons/files.svg" height="40" alt="Files / Dirs"/><br/><sub>Files / Dirs</sub> | <img src="./docs/assets/icons/local-git.svg" height="40" alt="Local Git"/><br/><sub>Local Git</sub> | <img src="./docs/assets/icons/github.svg" height="40" alt="GitHub"/><br/><sub>GitHub</sub> | <img src="./docs/assets/icons/gitlab.svg" height="40" alt="GitLab"/><br/><sub>GitLab</sub> | <img src="./docs/assets/icons/azure-devops.svg" height="40" alt="Azure Repos"/><br/><sub>Azure Repos</sub> | <img src="./docs/assets/icons/bitbucket.svg" height="40" alt="Bitbucket"/><br/><sub>Bitbucket</sub> | <img src="./docs/assets/icons/gitea.svg" height="40" alt="Gitea"/><br/><sub>Gitea</sub> |<img src="./docs/assets/icons/huggingface.svg" height="40" width="40" alt="Hugging Face"/><br/><sub>Hugging Face</sub> |
| Docker | Jira | Confluence | Slack | Teams | AWS S3 | Google Cloud |
|:------:|:----:|:-----------:|:-----:|:-----:|:------:|:---:|
| <img src="./docs/assets/icons/docker.svg" height="40" alt="Docker"/><br/><sub>Docker</sub> | <img src="./docs/assets/icons/jira.svg" height="40" alt="Jira"/><br/><sub>Jira</sub> | <img src="./docs/assets/icons/confluence.svg" height="40" alt="Confluence"/><br/><sub>Confluence</sub> | <img src="./docs/assets/icons/slack.svg" height="40" alt="Slack"/><br/><sub>Slack</sub> | <img src="./docs/assets/icons/teams.svg" height="40" alt="Microsoft Teams"/><br/><sub>Teams</sub> | <img src="./docs/assets/icons/aws-s3.svg" height="40" alt="AWS S3"/><br/><sub>AWS&nbsp;S3</sub> | <img src="./docs/assets/icons/gcs.svg" height="40" alt="Google Cloud Storage"/><br/><sub>Cloud Storage</sub> |
| Docker | Jira | Confluence | Slack | Teams | Postman | AWS S3 | Google Cloud |
|:------:|:----:|:-----------:|:-----:|:-----:|:-------:|:------:|:---:|
| <img src="./docs/assets/icons/docker.svg" height="40" alt="Docker"/><br/><sub>Docker</sub> | <img src="./docs/assets/icons/jira.svg" height="40" alt="Jira"/><br/><sub>Jira</sub> | <img src="./docs/assets/icons/confluence.svg" height="40" alt="Confluence"/><br/><sub>Confluence</sub> | <img src="./docs/assets/icons/slack.svg" height="40" alt="Slack"/><br/><sub>Slack</sub> | <img src="./docs/assets/icons/teams.svg" height="40" alt="Microsoft Teams"/><br/><sub>Teams</sub> | <img src="./docs/assets/icons/postman.svg" height="40" alt="Postman"/><br/><sub>Postman</sub> | <img src="./docs/assets/icons/aws-s3.svg" height="40" alt="AWS S3"/><br/><sub>AWS&nbsp;S3</sub> | <img src="./docs/assets/icons/gcs.svg" height="40" alt="Google Cloud Storage"/><br/><sub>Cloud Storage</sub> |
</div>
@ -194,7 +194,7 @@ KF_GITLAB_TOKEN="glpat-..." kingfisher scan gitlab --group my-group
### 8: Scan Azure Repos
```bash
KF_AZURE_PAT="pat" kingfisher scan azure --organization my-org
KF_AZURE_PAT="pat" kingfisher scan azure --azure-organization my-org
```
### 9: Scan Bitbucket workspace
@ -212,7 +212,7 @@ KF_GITEA_TOKEN="token" kingfisher scan gitea --organization my-org
### 11: Scan Hugging Face
```bash
KF_HUGGINGFACE_TOKEN="hf_..." kingfisher scan huggingface --organization my-org
KF_HUGGINGFACE_TOKEN="hf_..." kingfisher scan huggingface --huggingface-organization my-org
```
### 12: Scan an S3 bucket
@ -623,6 +623,7 @@ Kingfisher can scan multiple platforms and services directly:
- Confluence (pages via CQL queries)
- Slack (messages via search queries)
- Microsoft Teams (messages via Microsoft Graph search)
- Postman (workspaces, collections, and environments — including plaintext "secret"-typed environment variables)
See **[docs/INTEGRATIONS.md](docs/INTEGRATIONS.md)** for complete integration documentation and authentication setup.
@ -645,7 +646,7 @@ kingfisher scan github --organization my-org
kingfisher scan gitlab --group my-group
# Scan Azure Repos
kingfisher scan azure --organization my-org
kingfisher scan azure --azure-organization my-org
# Scan Jira issues
KF_JIRA_TOKEN="token" kingfisher scan jira --url https://jira.company.com \
@ -666,6 +667,9 @@ KF_SLACK_TOKEN="xoxp-..." kingfisher scan slack "from:username has:link"
# Scan Microsoft Teams messages
KF_TEAMS_TOKEN="eyJ0..." kingfisher scan teams "password OR api_key"
# Scan every Postman workspace, collection, and environment visible to the API key
KF_POSTMAN_TOKEN="PMAK-..." kingfisher scan postman --all
```
**For detailed integration instructions and authentication setup, see [docs/INTEGRATIONS.md](docs/INTEGRATIONS.md).**
@ -690,6 +694,7 @@ KF_TEAMS_TOKEN="eyJ0..." kingfisher scan teams "password OR api_key"
| `KF_CONFLUENCE_TOKEN` | Confluence API token |
| `KF_SLACK_TOKEN` | Slack API token |
| `KF_TEAMS_TOKEN` | Microsoft Graph API token for Teams message search |
| `KF_POSTMAN_TOKEN` / `POSTMAN_API_KEY` | Postman API key for workspace, collection, and environment scanning |
| `KF_DOCKER_TOKEN` | Docker registry token (`user:pass` or bearer token). If unset, credentials from the Docker keychain are used |
| `KF_AWS_KEY`, `KF_AWS_SECRET`, and `KF_AWS_SESSION_TOKEN` | AWS credentials for S3 bucket scanning. Session token is optional, for temporary credentials |
@ -799,7 +804,7 @@ Since then it has evolved far beyond that starting point, introducing live valid
- **Hundreds of new built-in rules** and an expanded YAML rule schema
- **Baseline management** to suppress known findings over time
- **Parser-based context verification** layered on Hyperscan for language-aware detection
- **More scan targets** (GitLab, Bitbucket, Gitea, Jira, Confluence, Slack, Microsoft Teams, S3, GCS, Docker, Hugging Face, etc.)
- **More scan targets** (GitLab, Bitbucket, Gitea, Jira, Confluence, Slack, Microsoft Teams, Postman, S3, GCS, Docker, Hugging Face, etc.)
- **Compressed Files**, **SQLite database**, and **Python bytecode (.pyc)** scanning support
- **New storage model** (in-memory + Bloom filter, replacing SQLite)
- **Unified workflow** with JSON/BSON/SARIF outputs

View file

@ -38,7 +38,7 @@ bstr.workspace = true
memchr = "2.7"
# Git types (minimal, for ObjectId and Time)
gix = { version = "0.81", default-features = false, features = ["serde", "sha1"] }
gix = { version = "0.83", default-features = false, features = ["serde", "sha1"] }
# Console formatting
console = "0.16"

View file

@ -27,7 +27,7 @@ rules:
(?x)
\b
(
(?:sk|rk)_(?:test|live)_[A-Za-z0-9]{24,128}
(?:sk|rk)_live_[A-Za-z0-9]{24,128}
)
\b
pattern_requirements:

View file

@ -210,10 +210,10 @@ rand = { version = "0.10", optional = true }
[target.'cfg(all(windows, target_arch = "aarch64"))'.dependencies]
# ldap3's rustls backend still pulls ring 0.16, which fails to build on Windows ARM64.
# Use the platform TLS backend there to keep the raw LDAP validator available.
ldap3 = { version = "0.11.5", default-features = false, features = ["tls-native"], optional = true }
ldap3 = { version = "0.12.1", default-features = false, features = ["tls-native"], optional = true }
[target.'cfg(not(all(windows, target_arch = "aarch64")))'.dependencies]
ldap3 = { version = "0.11.5", default-features = false, features = ["tls-rustls"], optional = true }
ldap3 = { version = "0.12.1", default-features = false, features = ["tls-rustls-aws-lc-rs"], optional = true }
[dev-dependencies]
pretty_assertions = "1.4"

View file

@ -100,16 +100,16 @@ you've fished out of a paste or a customer report:
```bash
# What does this AWS keypair actually own?
kingfisher access-map aws ./aws.json --json-out aws.access-map.json
kingfisher access-map aws ./aws.json --format json > aws.access-map.json
# Same for a GitHub token
kingfisher access-map github ./github.token --json-out github.access-map.json
kingfisher access-map github ./github.token --format json > github.access-map.json
# Or a GCP service account
kingfisher access-map gcp ./service-account.json --json-out gcp.access-map.json
kingfisher access-map gcp ./service-account.json --format json > gcp.access-map.json
```
The HTML report viewer (`--format html`) renders the access map as a
The access-map HTML report renders the access map as a
clickable tree: identity at the root, then services, then individual
resources and permissions. It is a much faster way to explain severity to
an incident commander or manager than pasting IAM JSON into chat.

View file

@ -29,7 +29,7 @@ you can focus on what needs rotation first.
## What you need
- Kingfisher installed (`brew install mongodb/brew/kingfisher`, or grab a
- Kingfisher installed (`brew install kingfisher`, or grab a
release from [GitHub](https://github.com/mongodb/kingfisher/releases)).
- A GitHub personal access token exported as `KF_GITHUB_TOKEN`. A classic
token with `repo` and `read:org` scopes is enough for private repos; for

View file

@ -0,0 +1,250 @@
---
date: 2026-04-29
title: "Scanning Postman for Leaked Secrets — Including the Ones the UI Hides"
description: >
Postman workspaces are a quietly underrated leak surface. Kingfisher now
scans collections, environments, mocks, and monitors directly via the
Postman API — and reads the plaintext of "secret"-typed environment
variables that the Postman UI masks but the API does not.
categories:
- Features
tags:
- postman
- secret-scanning
- validation
- integrations
---
# Scanning Postman for Leaked Secrets — Including the Ones the UI Hides
Postman is everywhere — across backend teams, mobile teams, partner
integrations, and the public Postman API Network. It is also a quietly
prolific leak surface. CloudSEK's December 2024 audit found over **30,000
public Postman workspaces leaking access tokens** across GitHub, Slack,
Salesforce, Stripe, and Razorpay, among others. Postman themselves now run
server-side secret scans on public content, which tells you everything you
need to know about how often this happens.
Kingfisher now scans Postman workspaces directly — and finds credentials
that other scanners miss, because most other tools only scan collection
exports a developer has dropped into a repo. They never see the live
workspace.
<!-- more -->
## The leak surface inside a Postman workspace
A Postman workspace is more than a list of saved requests. Each of the
following can — and routinely does — contain hard-coded credentials:
- **Request `auth` blocks**: Bearer tokens, API keys, basic-auth passwords
pinned directly to a request.
- **Request headers and URLs**: `Authorization`, `X-Api-Key`, signed query
strings, pre-signed S3 URLs.
- **Request bodies**: form-encoded `client_secret`, JSON payloads with
service account JSON pasted in.
- **Pre-request and test scripts**: JavaScript snippets that hard-code an
AWS keypair "just for testing."
- **Saved example responses**: an example response with a real token that
was captured when the engineer was debugging.
- **Environment variables, including the "secret" type** — see below.
- **Globals**, **mocks**, and **monitors** — same shape as environments.
## The headline: Postman's "secret" type does not redact over the API
Postman environments support a `secret` variable type. In the UI, the value
is masked — you see `••••••••`. It feels like a vault.
It is not. The `secret` flag is a **UI-masking hint only**. When you call
`GET /environments/{uid}` with an API key that has read access, Postman
returns the value in plaintext:
```json
{
"environment": {
"name": "prod",
"values": [
{
"key": "STRIPE_SECRET",
"value": "sk_live_51H...",
"type": "secret",
"enabled": true
}
]
}
}
```
That means a Postman API key with workspace read access is, in practice,
a key to every "secret" variable across every environment that workspace
can see. Postman documents this — only **Postman Vault** secrets are
genuinely client-side and unreachable via the API. Anything stored as a
"secret" environment variable is fully exposed.
This is the surface Kingfisher now scans.
## Get an API key
1. Go to **postman.com → Settings → API keys → Generate API key**.
2. Copy the value (it starts with `PMAK-`).
3. Export it:
```bash
export KF_POSTMAN_TOKEN="PMAK-..."
```
`POSTMAN_API_KEY` also works as an alias if that's already in your shell —
Kingfisher checks both.
The key acts with the minting user's permissions — there are no per-scope
toggles. Rate limit is 300 req/min/user across all plans. Kingfisher honors
`X-RateLimit-RetryAfter` and backs off automatically on 429.
## Scan everything visible to the key
The fastest way to get a baseline of your team's exposure:
```bash
KF_POSTMAN_TOKEN="PMAK-..." kingfisher scan postman --all
```
That walks every workspace the key can see, fans out to each collection
and environment, writes the JSON to disk, and runs the full Kingfisher
ruleset against it. Live validation runs by default, so you get back a
list of credentials that **actually authenticate today**, not just regex
matches.
Mocks and monitors are off by default (lower-yield, more API calls). Add
them explicitly when you want a complete sweep:
```bash
kingfisher scan postman --all --include-mocks-monitors
```
## Scan a specific workspace
When you want to scope to one team's workspace — or to audit a public
workspace someone flagged in a bug bounty report — pass the workspace ID
or paste the URL straight from the browser:
```bash
# By workspace UID
kingfisher scan postman \
--workspace 11111111-2222-3333-4444-555555555555
# Or paste the web URL — Kingfisher extracts the UID
kingfisher scan postman \
--workspace https://www.postman.com/team-handle/workspace/abc-uid-123
```
Repeat the flag to scan multiple workspaces in one run.
## Scan a single collection or environment
For CI, you usually want to scan the specific collection that gets shared
with partners on every release, not the whole workspace:
```bash
# Single collection — useful in CI on a known-shared collection
kingfisher scan postman \
--collection 12345678-abcd-efgh-ijkl-mnopqrstuvwx
# Single environment — useful when you suspect one env in particular
kingfisher scan postman \
--environment 12345678-abcd-efgh-ijkl-mnopqrstuvwx
```
Both flags are repeatable.
## What a finding looks like
Findings come back tagged with the Postman web URL of the resource they
were found in. That makes triage one click — paste the URL into a browser
and you're looking at the exact collection or environment that needs
remediation:
```
GITHUB PERSONAL ACCESS TOKEN => [KINGFISHER.GITHUB.2]
|Finding.......: ghp_EZopZDMW...
|Confidence....: medium
|Validation....: Active
|Path..........: https://go.postman.co/environments/env-uid-1
```
In JSON output, the URL appears in the finding's source/origin block, so
it round-trips into your triage tooling alongside the validation verdict.
## The end-to-end response loop
Combine Postman scanning with the rest of Kingfisher's response chain and
you have a complete incident workflow:
```bash
# 1. Scan + validate + map blast radius across every workspace
KF_POSTMAN_TOKEN="PMAK-..." kingfisher scan postman --all \
--access-map \
--format json \
--output postman-findings.json
# 2. Pull just the live, high-blast-radius findings
jq '.findings
| map(select(.validation.status == "Active"))
| map(select(.access_map != null))' \
postman-findings.json > urgent.json
# 3. Revoke the most urgent ones in place — by rule
kingfisher revoke --rule github "$LEAKED_GITHUB_TOKEN"
kingfisher revoke --rule slack "$LEAKED_SLACK_TOKEN"
```
Find → prioritize → revoke, all without leaving the terminal.
## Self-hosted and enterprise
If your team runs Postman behind a corporate proxy or uses an enterprise
endpoint, override the API URL:
```bash
KF_POSTMAN_TOKEN="PMAK-..." kingfisher scan postman --all \
--api-url https://postman.internal.example.com/
```
## Out of scope (so you can plan around it)
- **Postman Vault secrets.** Vault values stay client-side and are not
reachable from the Postman API. If you've migrated everything sensitive
into Vault, those values are not in this scan's blast radius — by
design. Anything still in `type: secret` environment variables, however,
is fully exposed.
- **Postman API Network discovery.** Postman does not expose a public
search API for the API Network. If you want to scan a public workspace,
you have to hand Kingfisher its workspace ID. There is no `--query`
option that crawls all of Postman.
- **Postman request history.** Per-user, never API-accessible.
## Why this matters
Most secret scanners only see Postman content if a developer has manually
exported a collection JSON and committed it. That's the smallest fraction
of the actual exposure. The majority of leaked Postman credentials live in
the workspace itself: in a "secret" environment variable that someone set
six months ago, in a saved example response from a debugging session, in
a pre-request script that hard-codes an AWS keypair "just for now."
By scanning the API directly, Kingfisher sees the same surface a
compromised Postman API key would see — which is exactly the surface that
matters from a defender's perspective.
## Get started
```bash
# Install — see the README for other platforms
brew install kingfisher
# Scan
KF_POSTMAN_TOKEN="PMAK-..." kingfisher scan postman --all
```
If there's a Postman feature you want covered or you find a workflow that
doesn't fit, open an issue at
[mongodb/kingfisher](https://github.com/mongodb/kingfisher/issues).

View file

@ -8,6 +8,7 @@ description: "Kingfisher release history: new features, rules, bug fixes, and im
All notable changes to this project will be documented in this file.
## [unreleased 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.
- Fixed [#359](https://github.com/mongodb/kingfisher/issues/359): added `kingfisher.github.9` to detect the new ~520-character stateless GitHub App installation token format (`ghs_<APP_ID>_<JWT>`). The legacy 36-character `ghs_` rule (`kingfisher.github.5`) is retained for older / GHES-issued tokens that are still in circulation.
- Added provider endpoint overrides for validation and revocation via global `--endpoint PROVIDER=URL` and `--endpoint-config FILE`, with built-in support for self-hosted GitHub, GitLab, Gitea, Jira, Confluence, and Artifactory instances.

View file

@ -13,6 +13,11 @@ There are two ways to produce access maps:
Kingfisher validates detected secrets and automatically generates access-map entries for supported credential types.
- **Standalone**: `kingfisher access-map <provider> [credential_file]`
This reads a credential artifact from disk and maps it directly.
Standalone access-map defaults to JSON output. The examples below use
`--format json` explicitly so the output type stays unambiguous when
redirecting to a file. Use `--format html` for a standalone HTML report,
and `--output <PATH>` if you prefer writing directly instead of using shell
redirection.
> Access mapping runs additional network requests. Only use it when you are authorized to inspect the target account/workspace.
@ -27,8 +32,7 @@ flowchart LR
Dispatch --> Provider[Provider mapper]
Provider --> APIs[Provider APIs]
APIs --> Result[AccessMapResult]
Result --> JSON[JSON stdout or file]
Result --> HTML[Optional HTML report]
Result --> Output[JSON or HTML output]
```
### Scan-Time Flow
@ -82,7 +86,7 @@ Access map only runs for credential types Kingfisher knows how to authenticate w
```bash
printf '%s' 'ghp_example...' > ./github.token
kingfisher access-map github ./github.token --json-out github.access-map.json
kingfisher access-map github ./github.token --format json > github.access-map.json
```
#### Notes (GitHub)
@ -99,7 +103,7 @@ kingfisher access-map github ./github.token --json-out github.access-map.json
```bash
printf '%s' 'glpat-example...' > ./gitlab.token
kingfisher access-map gitlab ./gitlab.token --json-out gitlab.access-map.json
kingfisher access-map gitlab ./gitlab.token --format json > gitlab.access-map.json
```
#### Notes (GitLab)
@ -116,7 +120,7 @@ kingfisher access-map gitlab ./gitlab.token --json-out gitlab.access-map.json
```bash
printf '%s' 'xoxp-example...' > ./slack.token
kingfisher access-map slack ./slack.token --json-out slack.access-map.json
kingfisher access-map slack ./slack.token --format json > slack.access-map.json
```
### AWS (`aws`)
@ -143,7 +147,7 @@ cat > ./aws.json <<'EOF'
}
EOF
kingfisher access-map aws ./aws.json --json-out aws.access-map.json
kingfisher access-map aws ./aws.json --format json > aws.access-map.json
```
```bash
@ -153,7 +157,7 @@ aws_secret_access_key=....
aws_session_token=....
EOF
kingfisher access-map aws ./aws.env --json-out aws.access-map.json
kingfisher access-map aws ./aws.env --format json > aws.access-map.json
```
Kingfisher performs read-only enumeration for the IAM principal and, when allowed by the credential, visible resources in several common AWS services including S3, EC2, IAM, Lambda, DynamoDB, KMS, Secrets Manager, SQS, SNS, RDS, ECR, and SSM Parameter Store.
@ -182,7 +186,7 @@ cat > ./alibaba.json <<'EOF'
}
EOF
kingfisher access-map alibaba ./alibaba.json --json-out alibaba.access-map.json
kingfisher access-map alibaba ./alibaba.json --format json > alibaba.access-map.json
```
```bash
@ -192,7 +196,7 @@ access_key_secret=....
security_token=....
EOF
kingfisher access-map alibaba ./alibaba.env --json-out alibaba.access-map.json
kingfisher access-map alibaba ./alibaba.env --format json > alibaba.access-map.json
```
Kingfisher resolves the Alibaba Cloud caller identity with `sts:GetCallerIdentity` for both long-lived access key pairs and STS temporary credentials discovered during scanning. Current coverage is identity-focused: it maps the account and resolved RAM principal, and records that broader Alibaba service enumeration is not yet available.
@ -204,7 +208,7 @@ Kingfisher resolves the Alibaba Cloud caller identity with `sts:GetCallerIdentit
#### Standalone example (GCP)
```bash
kingfisher access-map gcp ./service-account.json --json-out gcp.access-map.json
kingfisher access-map gcp ./service-account.json --format json > gcp.access-map.json
```
### Azure Storage (`azure`)
@ -223,7 +227,7 @@ cat > ./azure-storage.json <<'EOF'
}
EOF
kingfisher access-map azure ./azure-storage.json --json-out azure.access-map.json
kingfisher access-map azure ./azure-storage.json --format json > azure.access-map.json
```
Kingfisher treats the account key as full-control Storage credentials and performs best-effort enumeration across Blob containers, File shares, and Queue resources reachable with that key.
@ -240,7 +244,7 @@ Azure DevOps access mapping is supported when a **validated Azure DevOps PAT** i
```bash
printf '%s' 'postgres://user:pass@db.example.com:5432/mydb' > ./postgres.uri
kingfisher access-map postgres ./postgres.uri --json-out postgres.access-map.json
kingfisher access-map postgres ./postgres.uri --format json > postgres.access-map.json
```
### MongoDB (`mongodb` / `mongo`)
@ -251,7 +255,7 @@ kingfisher access-map postgres ./postgres.uri --json-out postgres.access-map.jso
```bash
printf '%s' 'mongodb+srv://user:pass@cluster.example.net/?retryWrites=true&w=majority' > ./mongodb.uri
kingfisher access-map mongodb ./mongodb.uri --json-out mongodb.access-map.json
kingfisher access-map mongodb ./mongodb.uri --format json > mongodb.access-map.json
```
### Hugging Face (`huggingface` / `hf`)
@ -267,7 +271,7 @@ Kingfisher queries the `/api/whoami-v2` endpoint to resolve the token identity,
```bash
printf '%s' 'hf_example...' > ./huggingface.token
kingfisher access-map huggingface ./huggingface.token --json-out huggingface.access-map.json
kingfisher access-map huggingface ./huggingface.token --format json > huggingface.access-map.json
```
#### Notes (Hugging Face)
@ -286,7 +290,7 @@ Kingfisher queries `/api/v1/user` for identity, enumerates organizations via `/a
```bash
printf '%s' 'your_gitea_pat...' > ./gitea.token
kingfisher access-map gitea ./gitea.token --json-out gitea.access-map.json
kingfisher access-map gitea ./gitea.token --format json > gitea.access-map.json
```
#### Notes (Gitea)
@ -305,7 +309,7 @@ Kingfisher queries `/2.0/user` for identity, enumerates workspace memberships an
```bash
printf '%s' 'your_bitbucket_token...' > ./bitbucket.token
kingfisher access-map bitbucket ./bitbucket.token --json-out bitbucket.access-map.json
kingfisher access-map bitbucket ./bitbucket.token --format json > bitbucket.access-map.json
```
#### Notes (Bitbucket)
@ -324,7 +328,7 @@ Kingfisher queries `/v2/access-token` for token metadata and scopes, `/v2/user`
```bash
printf '%s' 'bkua_example...' > ./buildkite.token
kingfisher access-map buildkite ./buildkite.token --json-out buildkite.access-map.json
kingfisher access-map buildkite ./buildkite.token --format json > buildkite.access-map.json
```
#### Notes (Buildkite)
@ -348,7 +352,7 @@ If organizations/projects are not enumerable (scope-limited keys), Kingfisher st
```bash
printf '%s' 'pat.example...' > ./harness.token
kingfisher access-map harness ./harness.token --json-out harness.access-map.json
kingfisher access-map harness ./harness.token --format json > harness.access-map.json
```
#### Notes (Harness)
@ -373,7 +377,7 @@ Kingfisher performs read-only scope probing and best-effort resource enumeration
```bash
printf '%s' 'sk-example...' > ./openai.token
kingfisher access-map openai ./openai.token --json-out openai.access-map.json
kingfisher access-map openai ./openai.token --format json > openai.access-map.json
```
#### Notes (OpenAI)
@ -395,7 +399,7 @@ Kingfisher performs read-only enumeration via:
```bash
printf '%s' 'sk-ant-api-example...' > ./anthropic.token
kingfisher access-map anthropic ./anthropic.token --json-out anthropic.access-map.json
kingfisher access-map anthropic ./anthropic.token --format json > anthropic.access-map.json
```
#### Notes (Anthropic)
@ -430,7 +434,7 @@ cat > ./salesforce.json <<'EOF'
}
EOF
kingfisher access-map salesforce ./salesforce.json --json-out salesforce.access-map.json
kingfisher access-map salesforce ./salesforce.json --format json > salesforce.access-map.json
```
#### Notes (Salesforce)
@ -452,7 +456,7 @@ Kingfisher performs read-only identity resolution via:
```bash
printf '%s' 'wandb_v1_example...' > ./wandb.token
kingfisher access-map weightsandbiases ./wandb.token --json-out wandb.access-map.json
kingfisher access-map weightsandbiases ./wandb.token --format json > wandb.access-map.json
```
#### Notes (Weights & Biases)
@ -473,7 +477,7 @@ Kingfisher parses the webhook URL to extract the tenant ID and webhook identity,
```bash
printf '%s' 'https://contoso.webhook.office.com/webhookb2/...' > ./teams.webhook
kingfisher access-map microsoftteams ./teams.webhook --json-out teams.access-map.json
kingfisher access-map microsoftteams ./teams.webhook --format json > teams.access-map.json
```
#### Notes (Microsoft Teams)
@ -499,7 +503,7 @@ Severity is Critical for account administrators, High for standard members with
```bash
printf '%s' 'eyJhbGciOi...' > ./monday.token
kingfisher access-map monday ./monday.token --json-out monday.access-map.json
kingfisher access-map monday ./monday.token --format json > monday.access-map.json
```
#### Notes (monday.com)
@ -529,7 +533,7 @@ Severity is High when the token reaches an organization workspace with more than
```bash
printf '%s' '2/12345.../abcdef...' > ./asana.token
kingfisher access-map asana ./asana.token --json-out asana.access-map.json
kingfisher access-map asana ./asana.token --format json > asana.access-map.json
```
#### Notes (Asana)

View file

@ -519,12 +519,11 @@ kingfisher scan ./my-project \
## Scanning Platform-Specific Targets
> **Deprecated**
> Legacy scan flags such as `--github-user`, `--gitlab-group`,
> `--bitbucket-workspace`, `--azure-organization`, `--huggingface-user`,
> Older documentation may refer to legacy provider flags such as
> `--github-user`, `--gitlab-group`, `--bitbucket-workspace`,
> `--slack-query`, `--jira-url`, `--confluence-url`, `--s3-bucket`,
> `--gcs-bucket`, and `--docker-image` still work for now, but they trigger a
> warning and will be removed in a future release. Migrate to the
> `kingfisher scan <provider>` subcommands below to future-proof your automations.
> `--gcs-bucket`, and `--docker-image`. Use the
> `kingfisher scan <provider>` subcommands below instead.
---
@ -766,10 +765,10 @@ kingfisher scan gitlab --group my-group --gitlab-exclude my-group/**/legacy-* --
### Scan Azure Repos organization or collection (requires `KF_AZURE_TOKEN` or `KF_AZURE_PAT`)
```bash
kingfisher scan azure --organization my-org
kingfisher scan azure --azure-organization my-org
# Azure Repos Server example
KF_AZURE_PAT="pat" kingfisher scan azure --organization DefaultCollection --azure-base-url https://ado.internal.example/tfs/
KF_AZURE_PAT="pat" kingfisher scan azure --azure-organization DefaultCollection --base-url https://ado.internal.example/tfs/
```
### Scan specific Azure Repos projects
@ -777,8 +776,8 @@ KF_AZURE_PAT="pat" kingfisher scan azure --organization DefaultCollection --azur
Projects are specified as `ORGANIZATION/PROJECT`. Repeat the flag for multiple projects.
```bash
kingfisher scan azure --project my-org/payments \
--project my-org/core-platform
kingfisher scan azure --azure-project my-org/payments \
--azure-project my-org/core-platform
```
### Skip specific Azure repositories during enumeration
@ -786,7 +785,7 @@ kingfisher scan azure --project my-org/payments \
Repeat `--azure-exclude` to ignore repositories when scanning organizations or projects. Use identifiers like `ORGANIZATION/PROJECT/REPOSITORY`. Repositories that share the same name as their project can be excluded with `ORGANIZATION/PROJECT`, and gitignore-style patterns such as `my-org/*/archive-*` are also supported.
```bash
kingfisher scan azure --organization my-org \
kingfisher scan azure --azure-organization my-org \
--azure-exclude my-org/payments/legacy-service \
--azure-exclude my-org/**/archive-*
```
@ -794,11 +793,11 @@ kingfisher scan azure --organization my-org \
### List Azure repositories
```bash
kingfisher scan azure --organization my-org --list-only
kingfisher scan azure --azure-organization my-org --list-only
# list repositories for specific projects
kingfisher scan azure --project my-org/app --project my-org/api --list-only
kingfisher scan azure --azure-project my-org/app --azure-project my-org/api --list-only
# skip specific repositories while listing (supports glob patterns)
kingfisher scan azure --organization my-org --azure-exclude my-org/**/experimental-* --list-only
kingfisher scan azure --azure-organization my-org --azure-exclude my-org/**/experimental-* --list-only
```
---
@ -810,7 +809,7 @@ kingfisher scan azure --organization my-org --azure-exclude my-org/**/experiment
```bash
kingfisher scan gitea --organization my-org
# self-hosted example
KF_GITEA_TOKEN="gtoken" kingfisher scan gitea --organization platform --gitea-api-url https://gitea.internal.example/api/v1/
KF_GITEA_TOKEN="gtoken" kingfisher scan gitea --organization platform --api-url https://gitea.internal.example/api/v1/
```
### Scan Gitea user
@ -847,9 +846,9 @@ KF_GITEA_TOKEN="gtoken" KF_GITEA_USERNAME="org" \
```bash
kingfisher scan gitea --organization my-org --list-only
# enumerate every organization visible to the authenticated user
KF_GITEA_TOKEN="gtoken" kingfisher scan gitea --all-gitea-organizations --list-only
KF_GITEA_TOKEN="gtoken" kingfisher scan gitea --all-organizations --list-only
# self-hosted example
KF_GITEA_TOKEN="gtoken" kingfisher scan gitea --user johndoe --gitea-api-url https://gitea.internal.example/api/v1/ --list-only
KF_GITEA_TOKEN="gtoken" kingfisher scan gitea --user johndoe --api-url https://gitea.internal.example/api/v1/ --list-only
```
---
@ -922,7 +921,7 @@ Bitbucket no longer supports App Tokens as of September 9, 2025: https://support
### Self-hosted Bitbucket Server
Use `--bitbucket-api-url` to point Kingfisher at your server's REST endpoint, for example `https://bitbucket.example.com/rest/api/1.0/`. Provide credentials with `KF_BITBUCKET_USERNAME` plus either `KF_BITBUCKET_TOKEN` or `KF_BITBUCKET_PASSWORD`, and pass `--tls-mode=off` (or the legacy `--ignore-certs`) when connecting to HTTP or otherwise insecure instances.
Use `--api-url` to point Kingfisher at your server's REST endpoint, for example `https://bitbucket.example.com/rest/api/1.0/`. Provide credentials with `KF_BITBUCKET_USERNAME` plus either `KF_BITBUCKET_TOKEN` or `KF_BITBUCKET_PASSWORD`, and pass `--tls-mode=off` (or the legacy `--ignore-certs`) when connecting to HTTP or otherwise insecure instances.
---
@ -933,13 +932,13 @@ Hugging Face hosts git repositories for models, datasets, and Spaces. Kingfisher
### Scan Hugging Face user
```bash
kingfisher scan huggingface --user <username>
kingfisher scan huggingface --huggingface-user <username>
```
### Scan Hugging Face organization
```bash
kingfisher scan huggingface --organization <orgname>
kingfisher scan huggingface --huggingface-organization <orgname>
```
### Scan specific Hugging Face resources
@ -947,9 +946,9 @@ kingfisher scan huggingface --organization <orgname>
Scan individual repositories by ID (owner/name) or by passing the full HTTPS URL:
```bash
kingfisher scan huggingface --model <owner/model>
kingfisher scan huggingface --dataset https://huggingface.co/datasets/<owner>/<dataset>
kingfisher scan huggingface --space <owner/space>
kingfisher scan huggingface --huggingface-model <owner/model>
kingfisher scan huggingface --huggingface-dataset https://huggingface.co/datasets/<owner>/<dataset>
kingfisher scan huggingface --huggingface-space <owner/space>
```
Use `--huggingface-exclude` to omit results returned by user or organization enumeration. Prefix values with `model:`, `dataset:`, or `space:` when you only want to skip a specific resource type.
@ -957,7 +956,7 @@ Use `--huggingface-exclude` to omit results returned by user or organization enu
### List Hugging Face repositories
```bash
kingfisher scan huggingface --user <username> --list-only
kingfisher scan huggingface --huggingface-user <username> --list-only
```
### Authenticate to Hugging Face
@ -1015,7 +1014,7 @@ KF_CONFLUENCE_USER="user@example.com" KF_CONFLUENCE_TOKEN="token" \
--max-results 500
```
Use the base URL of your Confluence site for `--confluence-url`. Kingfisher automatically adds `/rest/api` to the end, so `https://example.com/wiki` and `https://example.com` both work depending on your server configuration.
Use the base URL of your Confluence site for `--url`. Kingfisher automatically adds `/rest/api` to the end, so `https://example.com/wiki` and `https://example.com` both work depending on your server configuration.
Generate a personal access token and set it in the `KF_CONFLUENCE_TOKEN` environment variable. By default, Kingfisher sends the token as a bearer token in the `Authorization` header.

View file

@ -22,6 +22,7 @@ This guide covers how to scan various platforms and services with Kingfisher.
- [Confluence](#confluence)
- [Slack](#slack)
- [Microsoft Teams](#microsoft-teams)
- [Postman](#postman)
- [Environment Variables](#environment-variables)
## AWS S3
@ -130,12 +131,11 @@ kingfisher scan docker private.registry.example.com/my-image:tag
```
> **Deprecated**
> Legacy scan flags such as `--github-user`, `--gitlab-group`,
> `--bitbucket-workspace`, `--azure-organization`, `--huggingface-user`,
> Older documentation may refer to legacy provider flags such as
> `--github-user`, `--gitlab-group`, `--bitbucket-workspace`,
> `--slack-query`, `--jira-url`, `--confluence-url`, `--s3-bucket`,
> `--gcs-bucket`, and `--docker-image` still work for now, but they trigger a
> warning and will be removed in a future release. Migrate to the
> `kingfisher scan <provider>` subcommands below to future-proof your automations.
> `--gcs-bucket`, and `--docker-image`. Use the
> `kingfisher scan <provider>` subcommands below instead.
## GitHub
@ -297,10 +297,10 @@ kingfisher scan gitlab --group my-group --gitlab-exclude my-group/**/legacy-* --
### Scan Azure Repos organization or collection (requires `KF_AZURE_TOKEN` or `KF_AZURE_PAT`)
```bash
kingfisher scan azure --organization my-org
kingfisher scan azure --azure-organization my-org
# Azure Repos Server example
KF_AZURE_PAT="pat" kingfisher scan azure --organization DefaultCollection --azure-base-url https://ado.internal.example/tfs/
KF_AZURE_PAT="pat" kingfisher scan azure --azure-organization DefaultCollection --base-url https://ado.internal.example/tfs/
```
### Scan specific Azure Repos projects
@ -308,8 +308,8 @@ KF_AZURE_PAT="pat" kingfisher scan azure --organization DefaultCollection --azur
Projects are specified as `ORGANIZATION/PROJECT`. Repeat the flag for multiple projects.
```bash
kingfisher scan azure --project my-org/payments \
--project my-org/core-platform
kingfisher scan azure --azure-project my-org/payments \
--azure-project my-org/core-platform
```
### Skip specific Azure repositories during enumeration
@ -320,7 +320,7 @@ name as their project can be excluded with `ORGANIZATION/PROJECT`, and gitignore
patterns such as `my-org/*/archive-*` are also supported.
```bash
kingfisher scan azure --organization my-org \
kingfisher scan azure --azure-organization my-org \
--azure-exclude my-org/payments/legacy-service \
--azure-exclude my-org/**/archive-*
```
@ -328,11 +328,11 @@ kingfisher scan azure --organization my-org \
### List Azure repositories
```bash
kingfisher scan azure --organization my-org --list-only
kingfisher scan azure --azure-organization my-org --list-only
# list repositories for specific projects
kingfisher scan azure --project my-org/app --project my-org/api --list-only
kingfisher scan azure --azure-project my-org/app --azure-project my-org/api --list-only
# skip specific repositories while listing (supports glob patterns)
kingfisher scan azure --organization my-org --azure-exclude my-org/**/experimental-* --list-only
kingfisher scan azure --azure-organization my-org --azure-exclude my-org/**/experimental-* --list-only
```
## Gitea
@ -342,7 +342,7 @@ kingfisher scan azure --organization my-org --azure-exclude my-org/**/experiment
```bash
kingfisher scan gitea --organization my-org
# self-hosted example
KF_GITEA_TOKEN="gtoken" kingfisher scan gitea --organization platform --gitea-api-url https://gitea.internal.example/api/v1/
KF_GITEA_TOKEN="gtoken" kingfisher scan gitea --organization platform --api-url https://gitea.internal.example/api/v1/
```
### Scan Gitea user
@ -383,9 +383,9 @@ KF_GITEA_TOKEN="gtoken" KF_GITEA_USERNAME="org" \
```bash
kingfisher scan gitea --organization my-org --list-only
# enumerate every organization visible to the authenticated user
KF_GITEA_TOKEN="gtoken" kingfisher scan gitea --all-gitea-organizations --list-only
KF_GITEA_TOKEN="gtoken" kingfisher scan gitea --all-organizations --list-only
# self-hosted example
KF_GITEA_TOKEN="gtoken" kingfisher scan gitea --user johndoe --gitea-api-url https://gitea.internal.example/api/v1/ --list-only
KF_GITEA_TOKEN="gtoken" kingfisher scan gitea --user johndoe --api-url https://gitea.internal.example/api/v1/ --list-only
```
## Bitbucket
@ -464,7 +464,7 @@ https://support.atlassian.com/bitbucket-cloud/docs/api-tokens/
### Self-hosted Bitbucket Server
Use `--bitbucket-api-url` to point Kingfisher at your server's REST endpoint, for example
Use `--api-url` to point Kingfisher at your server's REST endpoint, for example
`https://bitbucket.example.com/rest/api/1.0/`. Provide credentials with
`KF_BITBUCKET_USERNAME` plus either `KF_BITBUCKET_TOKEN` or `KF_BITBUCKET_PASSWORD`,
and pass `--ignore-certs` when connecting to HTTP or otherwise insecure instances.
@ -476,13 +476,13 @@ Hugging Face hosts git repositories for models, datasets, and Spaces. Kingfisher
### Scan Hugging Face user
```bash
kingfisher scan huggingface --user <username>
kingfisher scan huggingface --huggingface-user <username>
```
### Scan Hugging Face organization
```bash
kingfisher scan huggingface --organization <orgname>
kingfisher scan huggingface --huggingface-organization <orgname>
```
### Scan specific Hugging Face resources
@ -490,9 +490,9 @@ kingfisher scan huggingface --organization <orgname>
Scan individual repositories by ID (owner/name) or by passing the full HTTPS URL:
```bash
kingfisher scan huggingface --model <owner/model>
kingfisher scan huggingface --dataset https://huggingface.co/datasets/<owner>/<dataset>
kingfisher scan huggingface --space <owner/space>
kingfisher scan huggingface --huggingface-model <owner/model>
kingfisher scan huggingface --huggingface-dataset https://huggingface.co/datasets/<owner>/<dataset>
kingfisher scan huggingface --huggingface-space <owner/space>
```
Use `--huggingface-exclude` to omit results returned by user or organization enumeration. Prefix values with `model:`, `dataset:`, or `space:` when you only want to skip a specific resource type.
@ -500,7 +500,7 @@ Use `--huggingface-exclude` to omit results returned by user or organization enu
### List Hugging Face repositories
```bash
kingfisher scan huggingface --user <username> --list-only
kingfisher scan huggingface --huggingface-user <username> --list-only
```
### Authenticate to Hugging Face
@ -554,7 +554,7 @@ KF_CONFLUENCE_USER="user@example.com" KF_CONFLUENCE_TOKEN="token" \
--max-results 500
```
Use the base URL of your Confluence site for `--confluence-url`. Kingfisher
Use the base URL of your Confluence site for `--url`. Kingfisher
automatically adds `/rest/api` to the end, so `https://example.com/wiki` and
`https://example.com` both work depending on your server configuration.
@ -601,6 +601,63 @@ The token must be a Microsoft Graph API access token with `ChannelMessage.Read.A
- Client credentials flow for application permissions
- Authorization code flow for delegated permissions
## Postman
Kingfisher fetches Postman workspaces, collections, and environments via the public Postman API (`https://api.getpostman.com/`) and scans the JSON for hard-coded credentials. The most common high-value findings are:
- Bearer tokens, API keys, and basic-auth passwords inside collection request `auth` blocks
- Hard-coded credentials inside pre-request and test scripts
- Saved example responses that echo tokens
- **"Secret"-typed environment variables** — Postman's `secret` flag is a UI-mask only, the API returns the plaintext value to any read-capable key
### Scan every workspace, collection, and environment visible to the API key
```bash
KF_POSTMAN_TOKEN="PMAK-..." kingfisher scan postman --all
```
### Scan a specific workspace (by ID or web URL)
```bash
KF_POSTMAN_TOKEN="PMAK-..." kingfisher scan postman \
--workspace 11111111-2222-3333-4444-555555555555
KF_POSTMAN_TOKEN="PMAK-..." kingfisher scan postman \
--workspace https://www.postman.com/team-handle/workspace/abc-uid-123
```
### Scan a single collection or environment
```bash
KF_POSTMAN_TOKEN="PMAK-..." kingfisher scan postman \
--collection 12345678-abcd-efgh-ijkl-mnopqrstuvwx
KF_POSTMAN_TOKEN="PMAK-..." kingfisher scan postman \
--environment 12345678-abcd-efgh-ijkl-mnopqrstuvwx
```
### Include mocks and monitors
Mocks and monitors are scanned only when explicitly requested (they are lower-yield surfaces):
```bash
KF_POSTMAN_TOKEN="PMAK-..." kingfisher scan postman --all \
--include-mocks-monitors
```
### Self-hosted / enterprise endpoint
```bash
KF_POSTMAN_TOKEN="PMAK-..." kingfisher scan postman --all \
--api-url https://postman.internal.example.com/
```
The token is sent as the `X-Api-Key` header. Either `KF_POSTMAN_TOKEN` or `POSTMAN_API_KEY` is accepted (the latter matches the env var Postman's own docs reference). Mint a key from postman.com → Settings → API keys.
> Top-level `kingfisher scan --postman-*` flags remain accepted as hidden aliases for backward compatibility, but new usage should prefer the `kingfisher scan postman` subcommand shown above.
**Out of scope:** Postman Vault secrets are client-side and not reachable via the API. The Postman API Network does not expose a search endpoint; supply specific public-workspace IDs via `kingfisher scan postman --workspace` to scan public surfaces.
## Environment Variables
| Variable | Purpose |
@ -621,6 +678,7 @@ The token must be a Microsoft Graph API access token with `ChannelMessage.Read.A
| `KF_CONFLUENCE_TOKEN` | Confluence API token |
| `KF_SLACK_TOKEN` | Slack API token |
| `KF_TEAMS_TOKEN` | Microsoft Graph API token for Teams message search |
| `KF_POSTMAN_TOKEN` / `POSTMAN_API_KEY` | Postman API key (sent as `X-Api-Key`) for workspace, collection, and environment scanning |
| `KF_DOCKER_TOKEN` | Docker registry token (`user:pass` or bearer token). If unset, credentials from the Docker keychain are used |
| `KF_AWS_KEY`, `KF_AWS_SECRET`, and `KF_AWS_SESSION_TOKEN` | AWS credentials for S3 bucket scanning. Session token is optional, for temporary credentials |

View file

@ -8,6 +8,11 @@ There are two ways to produce access maps:
Kingfisher validates detected secrets and automatically generates access-map entries for supported credential types.
- **Standalone**: `kingfisher access-map <provider> [credential_file]`
This reads a credential artifact from disk and maps it directly.
Standalone access-map defaults to JSON output. The examples below use
`--format json` explicitly so the output type stays unambiguous when
redirecting to a file. Use `--format html` for a standalone HTML report,
and `--output <PATH>` if you prefer writing directly instead of using shell
redirection.
> Access mapping runs additional network requests. Only use it when you are authorized to inspect the target account/workspace.
@ -22,8 +27,7 @@ flowchart LR
Dispatch --> Provider[Provider mapper]
Provider --> APIs[Provider APIs]
APIs --> Result[AccessMapResult]
Result --> JSON[JSON stdout or file]
Result --> HTML[Optional HTML report]
Result --> Output[JSON or HTML output]
```
### Scan-Time Flow
@ -77,7 +81,7 @@ Access map only runs for credential types Kingfisher knows how to authenticate w
```bash
printf '%s' 'ghp_example...' > ./github.token
kingfisher access-map github ./github.token --json-out github.access-map.json
kingfisher access-map github ./github.token --format json > github.access-map.json
```
#### Notes (GitHub)
@ -94,7 +98,7 @@ kingfisher access-map github ./github.token --json-out github.access-map.json
```bash
printf '%s' 'glpat-example...' > ./gitlab.token
kingfisher access-map gitlab ./gitlab.token --json-out gitlab.access-map.json
kingfisher access-map gitlab ./gitlab.token --format json > gitlab.access-map.json
```
#### Notes (GitLab)
@ -111,7 +115,7 @@ kingfisher access-map gitlab ./gitlab.token --json-out gitlab.access-map.json
```bash
printf '%s' 'xoxp-example...' > ./slack.token
kingfisher access-map slack ./slack.token --json-out slack.access-map.json
kingfisher access-map slack ./slack.token --format json > slack.access-map.json
```
### AWS (`aws`)
@ -138,7 +142,7 @@ cat > ./aws.json <<'EOF'
}
EOF
kingfisher access-map aws ./aws.json --json-out aws.access-map.json
kingfisher access-map aws ./aws.json --format json > aws.access-map.json
```
```bash
@ -148,7 +152,7 @@ aws_secret_access_key=....
aws_session_token=....
EOF
kingfisher access-map aws ./aws.env --json-out aws.access-map.json
kingfisher access-map aws ./aws.env --format json > aws.access-map.json
```
Kingfisher performs read-only enumeration for the IAM principal and, when allowed by the credential, visible resources in several common AWS services including S3, EC2, IAM, Lambda, DynamoDB, KMS, Secrets Manager, SQS, SNS, RDS, ECR, and SSM Parameter Store.
@ -177,7 +181,7 @@ cat > ./alibaba.json <<'EOF'
}
EOF
kingfisher access-map alibaba ./alibaba.json --json-out alibaba.access-map.json
kingfisher access-map alibaba ./alibaba.json --format json > alibaba.access-map.json
```
```bash
@ -187,7 +191,7 @@ access_key_secret=....
security_token=....
EOF
kingfisher access-map alibaba ./alibaba.env --json-out alibaba.access-map.json
kingfisher access-map alibaba ./alibaba.env --format json > alibaba.access-map.json
```
Kingfisher resolves the Alibaba Cloud caller identity with `sts:GetCallerIdentity` for both long-lived access key pairs and STS temporary credentials discovered during scanning. Current coverage is identity-focused: it maps the account and resolved RAM principal, and records that broader Alibaba service enumeration is not yet available.
@ -199,7 +203,7 @@ Kingfisher resolves the Alibaba Cloud caller identity with `sts:GetCallerIdentit
#### Standalone example (GCP)
```bash
kingfisher access-map gcp ./service-account.json --json-out gcp.access-map.json
kingfisher access-map gcp ./service-account.json --format json > gcp.access-map.json
```
### Azure Storage (`azure`)
@ -218,7 +222,7 @@ cat > ./azure-storage.json <<'EOF'
}
EOF
kingfisher access-map azure ./azure-storage.json --json-out azure.access-map.json
kingfisher access-map azure ./azure-storage.json --format json > azure.access-map.json
```
Kingfisher treats the account key as full-control Storage credentials and performs best-effort enumeration across Blob containers, File shares, and Queue resources reachable with that key.
@ -235,7 +239,7 @@ Azure DevOps access mapping is supported when a **validated Azure DevOps PAT** i
```bash
printf '%s' 'postgres://user:pass@db.example.com:5432/mydb' > ./postgres.uri
kingfisher access-map postgres ./postgres.uri --json-out postgres.access-map.json
kingfisher access-map postgres ./postgres.uri --format json > postgres.access-map.json
```
### MongoDB (`mongodb` / `mongo`)
@ -246,7 +250,7 @@ kingfisher access-map postgres ./postgres.uri --json-out postgres.access-map.jso
```bash
printf '%s' 'mongodb+srv://user:pass@cluster.example.net/?retryWrites=true&w=majority' > ./mongodb.uri
kingfisher access-map mongodb ./mongodb.uri --json-out mongodb.access-map.json
kingfisher access-map mongodb ./mongodb.uri --format json > mongodb.access-map.json
```
### Hugging Face (`huggingface` / `hf`)
@ -262,7 +266,7 @@ Kingfisher queries the `/api/whoami-v2` endpoint to resolve the token identity,
```bash
printf '%s' 'hf_example...' > ./huggingface.token
kingfisher access-map huggingface ./huggingface.token --json-out huggingface.access-map.json
kingfisher access-map huggingface ./huggingface.token --format json > huggingface.access-map.json
```
#### Notes (Hugging Face)
@ -281,7 +285,7 @@ Kingfisher queries `/api/v1/user` for identity, enumerates organizations via `/a
```bash
printf '%s' 'your_gitea_pat...' > ./gitea.token
kingfisher access-map gitea ./gitea.token --json-out gitea.access-map.json
kingfisher access-map gitea ./gitea.token --format json > gitea.access-map.json
```
#### Notes (Gitea)
@ -300,7 +304,7 @@ Kingfisher queries `/2.0/user` for identity, enumerates workspace memberships an
```bash
printf '%s' 'your_bitbucket_token...' > ./bitbucket.token
kingfisher access-map bitbucket ./bitbucket.token --json-out bitbucket.access-map.json
kingfisher access-map bitbucket ./bitbucket.token --format json > bitbucket.access-map.json
```
#### Notes (Bitbucket)
@ -319,7 +323,7 @@ Kingfisher queries `/v2/access-token` for token metadata and scopes, `/v2/user`
```bash
printf '%s' 'bkua_example...' > ./buildkite.token
kingfisher access-map buildkite ./buildkite.token --json-out buildkite.access-map.json
kingfisher access-map buildkite ./buildkite.token --format json > buildkite.access-map.json
```
#### Notes (Buildkite)
@ -343,7 +347,7 @@ If organizations/projects are not enumerable (scope-limited keys), Kingfisher st
```bash
printf '%s' 'pat.example...' > ./harness.token
kingfisher access-map harness ./harness.token --json-out harness.access-map.json
kingfisher access-map harness ./harness.token --format json > harness.access-map.json
```
#### Notes (Harness)
@ -368,7 +372,7 @@ Kingfisher performs read-only scope probing and best-effort resource enumeration
```bash
printf '%s' 'sk-example...' > ./openai.token
kingfisher access-map openai ./openai.token --json-out openai.access-map.json
kingfisher access-map openai ./openai.token --format json > openai.access-map.json
```
#### Notes (OpenAI)
@ -390,7 +394,7 @@ Kingfisher performs read-only enumeration via:
```bash
printf '%s' 'sk-ant-api-example...' > ./anthropic.token
kingfisher access-map anthropic ./anthropic.token --json-out anthropic.access-map.json
kingfisher access-map anthropic ./anthropic.token --format json > anthropic.access-map.json
```
#### Notes (Anthropic)
@ -425,7 +429,7 @@ cat > ./salesforce.json <<'EOF'
}
EOF
kingfisher access-map salesforce ./salesforce.json --json-out salesforce.access-map.json
kingfisher access-map salesforce ./salesforce.json --format json > salesforce.access-map.json
```
#### Notes (Salesforce)
@ -447,7 +451,7 @@ Kingfisher performs read-only identity resolution via:
```bash
printf '%s' 'wandb_v1_example...' > ./wandb.token
kingfisher access-map weightsandbiases ./wandb.token --json-out wandb.access-map.json
kingfisher access-map weightsandbiases ./wandb.token --format json > wandb.access-map.json
```
#### Notes (Weights & Biases)
@ -468,7 +472,7 @@ Kingfisher parses the webhook URL to extract the tenant ID and webhook identity,
```bash
printf '%s' 'https://contoso.webhook.office.com/webhookb2/...' > ./teams.webhook
kingfisher access-map microsoftteams ./teams.webhook --json-out teams.access-map.json
kingfisher access-map microsoftteams ./teams.webhook --format json > teams.access-map.json
```
#### Notes (Microsoft Teams)
@ -494,7 +498,7 @@ Severity is Critical for account administrators, High for standard members with
```bash
printf '%s' 'eyJhbGciOi...' > ./monday.token
kingfisher access-map monday ./monday.token --json-out monday.access-map.json
kingfisher access-map monday ./monday.token --format json > monday.access-map.json
```
#### Notes (monday.com)
@ -524,7 +528,7 @@ Severity is High when the token reaches an organization workspace with more than
```bash
printf '%s' '2/12345.../abcdef...' > ./asana.token
kingfisher access-map asana ./asana.token --json-out asana.access-map.json
kingfisher access-map asana ./asana.token --format json > asana.access-map.json
```
#### Notes (Asana)

View file

@ -19,6 +19,7 @@ This guide covers how to scan various platforms and services with Kingfisher.
- [Confluence](#confluence)
- [Slack](#slack)
- [Microsoft Teams](#microsoft-teams)
- [Postman](#postman)
- [Environment Variables](#environment-variables)
## AWS S3
@ -127,12 +128,11 @@ kingfisher scan docker private.registry.example.com/my-image:tag
```
> **Deprecated**
> Legacy scan flags such as `--github-user`, `--gitlab-group`,
> `--bitbucket-workspace`, `--azure-organization`, `--huggingface-user`,
> Older documentation may refer to legacy provider flags such as
> `--github-user`, `--gitlab-group`, `--bitbucket-workspace`,
> `--slack-query`, `--jira-url`, `--confluence-url`, `--s3-bucket`,
> `--gcs-bucket`, and `--docker-image` still work for now, but they trigger a
> warning and will be removed in a future release. Migrate to the
> `kingfisher scan <provider>` subcommands below to future-proof your automations.
> `--gcs-bucket`, and `--docker-image`. Use the
> `kingfisher scan <provider>` subcommands below instead.
## GitHub
@ -294,10 +294,10 @@ kingfisher scan gitlab --group my-group --gitlab-exclude my-group/**/legacy-* --
### Scan Azure Repos organization or collection (requires `KF_AZURE_TOKEN` or `KF_AZURE_PAT`)
```bash
kingfisher scan azure --organization my-org
kingfisher scan azure --azure-organization my-org
# Azure Repos Server example
KF_AZURE_PAT="pat" kingfisher scan azure --organization DefaultCollection --azure-base-url https://ado.internal.example/tfs/
KF_AZURE_PAT="pat" kingfisher scan azure --azure-organization DefaultCollection --base-url https://ado.internal.example/tfs/
```
### Scan specific Azure Repos projects
@ -305,8 +305,8 @@ KF_AZURE_PAT="pat" kingfisher scan azure --organization DefaultCollection --azur
Projects are specified as `ORGANIZATION/PROJECT`. Repeat the flag for multiple projects.
```bash
kingfisher scan azure --project my-org/payments \
--project my-org/core-platform
kingfisher scan azure --azure-project my-org/payments \
--azure-project my-org/core-platform
```
### Skip specific Azure repositories during enumeration
@ -317,7 +317,7 @@ name as their project can be excluded with `ORGANIZATION/PROJECT`, and gitignore
patterns such as `my-org/*/archive-*` are also supported.
```bash
kingfisher scan azure --organization my-org \
kingfisher scan azure --azure-organization my-org \
--azure-exclude my-org/payments/legacy-service \
--azure-exclude my-org/**/archive-*
```
@ -325,11 +325,11 @@ kingfisher scan azure --organization my-org \
### List Azure repositories
```bash
kingfisher scan azure --organization my-org --list-only
kingfisher scan azure --azure-organization my-org --list-only
# list repositories for specific projects
kingfisher scan azure --project my-org/app --project my-org/api --list-only
kingfisher scan azure --azure-project my-org/app --azure-project my-org/api --list-only
# skip specific repositories while listing (supports glob patterns)
kingfisher scan azure --organization my-org --azure-exclude my-org/**/experimental-* --list-only
kingfisher scan azure --azure-organization my-org --azure-exclude my-org/**/experimental-* --list-only
```
## Gitea
@ -339,7 +339,7 @@ kingfisher scan azure --organization my-org --azure-exclude my-org/**/experiment
```bash
kingfisher scan gitea --organization my-org
# self-hosted example
KF_GITEA_TOKEN="gtoken" kingfisher scan gitea --organization platform --gitea-api-url https://gitea.internal.example/api/v1/
KF_GITEA_TOKEN="gtoken" kingfisher scan gitea --organization platform --api-url https://gitea.internal.example/api/v1/
```
### Scan Gitea user
@ -380,9 +380,9 @@ KF_GITEA_TOKEN="gtoken" KF_GITEA_USERNAME="org" \
```bash
kingfisher scan gitea --organization my-org --list-only
# enumerate every organization visible to the authenticated user
KF_GITEA_TOKEN="gtoken" kingfisher scan gitea --all-gitea-organizations --list-only
KF_GITEA_TOKEN="gtoken" kingfisher scan gitea --all-organizations --list-only
# self-hosted example
KF_GITEA_TOKEN="gtoken" kingfisher scan gitea --user johndoe --gitea-api-url https://gitea.internal.example/api/v1/ --list-only
KF_GITEA_TOKEN="gtoken" kingfisher scan gitea --user johndoe --api-url https://gitea.internal.example/api/v1/ --list-only
```
## Bitbucket
@ -461,7 +461,7 @@ https://support.atlassian.com/bitbucket-cloud/docs/api-tokens/
### Self-hosted Bitbucket Server
Use `--bitbucket-api-url` to point Kingfisher at your server's REST endpoint, for example
Use `--api-url` to point Kingfisher at your server's REST endpoint, for example
`https://bitbucket.example.com/rest/api/1.0/`. Provide credentials with
`KF_BITBUCKET_USERNAME` plus either `KF_BITBUCKET_TOKEN` or `KF_BITBUCKET_PASSWORD`,
and pass `--ignore-certs` when connecting to HTTP or otherwise insecure instances.
@ -473,13 +473,13 @@ Hugging Face hosts git repositories for models, datasets, and Spaces. Kingfisher
### Scan Hugging Face user
```bash
kingfisher scan huggingface --user <username>
kingfisher scan huggingface --huggingface-user <username>
```
### Scan Hugging Face organization
```bash
kingfisher scan huggingface --organization <orgname>
kingfisher scan huggingface --huggingface-organization <orgname>
```
### Scan specific Hugging Face resources
@ -487,9 +487,9 @@ kingfisher scan huggingface --organization <orgname>
Scan individual repositories by ID (owner/name) or by passing the full HTTPS URL:
```bash
kingfisher scan huggingface --model <owner/model>
kingfisher scan huggingface --dataset https://huggingface.co/datasets/<owner>/<dataset>
kingfisher scan huggingface --space <owner/space>
kingfisher scan huggingface --huggingface-model <owner/model>
kingfisher scan huggingface --huggingface-dataset https://huggingface.co/datasets/<owner>/<dataset>
kingfisher scan huggingface --huggingface-space <owner/space>
```
Use `--huggingface-exclude` to omit results returned by user or organization enumeration. Prefix values with `model:`, `dataset:`, or `space:` when you only want to skip a specific resource type.
@ -497,7 +497,7 @@ Use `--huggingface-exclude` to omit results returned by user or organization enu
### List Hugging Face repositories
```bash
kingfisher scan huggingface --user <username> --list-only
kingfisher scan huggingface --huggingface-user <username> --list-only
```
### Authenticate to Hugging Face
@ -551,7 +551,7 @@ KF_CONFLUENCE_USER="user@example.com" KF_CONFLUENCE_TOKEN="token" \
--max-results 500
```
Use the base URL of your Confluence site for `--confluence-url`. Kingfisher
Use the base URL of your Confluence site for `--url`. Kingfisher
automatically adds `/rest/api` to the end, so `https://example.com/wiki` and
`https://example.com` both work depending on your server configuration.
@ -598,6 +598,63 @@ The token must be a Microsoft Graph API access token with `ChannelMessage.Read.A
- Client credentials flow for application permissions
- Authorization code flow for delegated permissions
## Postman
Kingfisher fetches Postman workspaces, collections, and environments via the public Postman API (`https://api.getpostman.com/`) and scans the JSON for hard-coded credentials. The most common high-value findings are:
- Bearer tokens, API keys, and basic-auth passwords inside collection request `auth` blocks
- Hard-coded credentials inside pre-request and test scripts
- Saved example responses that echo tokens
- **"Secret"-typed environment variables** — Postman's `secret` flag is a UI-mask only, the API returns the plaintext value to any read-capable key
### Scan every workspace, collection, and environment visible to the API key
```bash
KF_POSTMAN_TOKEN="PMAK-..." kingfisher scan postman --all
```
### Scan a specific workspace (by ID or web URL)
```bash
KF_POSTMAN_TOKEN="PMAK-..." kingfisher scan postman \
--workspace 11111111-2222-3333-4444-555555555555
KF_POSTMAN_TOKEN="PMAK-..." kingfisher scan postman \
--workspace https://www.postman.com/team-handle/workspace/abc-uid-123
```
### Scan a single collection or environment
```bash
KF_POSTMAN_TOKEN="PMAK-..." kingfisher scan postman \
--collection 12345678-abcd-efgh-ijkl-mnopqrstuvwx
KF_POSTMAN_TOKEN="PMAK-..." kingfisher scan postman \
--environment 12345678-abcd-efgh-ijkl-mnopqrstuvwx
```
### Include mocks and monitors
Mocks and monitors are scanned only when explicitly requested (they are lower-yield surfaces):
```bash
KF_POSTMAN_TOKEN="PMAK-..." kingfisher scan postman --all \
--include-mocks-monitors
```
### Self-hosted / enterprise endpoint
```bash
KF_POSTMAN_TOKEN="PMAK-..." kingfisher scan postman --all \
--api-url https://postman.internal.example.com/
```
The token is sent as the `X-Api-Key` header. Either `KF_POSTMAN_TOKEN` or `POSTMAN_API_KEY` is accepted (the latter matches the env var Postman's own docs reference). Mint a key from postman.com → Settings → API keys.
> Top-level `kingfisher scan --postman-*` flags remain accepted as hidden aliases for backward compatibility, but new usage should prefer the `kingfisher scan postman` subcommand shown above.
**Out of scope:** Postman Vault secrets are client-side and not reachable via the API. The Postman API Network does not expose a search endpoint; supply specific public-workspace IDs via `--postman-workspace` to scan public surfaces.
## Environment Variables
| Variable | Purpose |
@ -618,6 +675,7 @@ The token must be a Microsoft Graph API access token with `ChannelMessage.Read.A
| `KF_CONFLUENCE_TOKEN` | Confluence API token |
| `KF_SLACK_TOKEN` | Slack API token |
| `KF_TEAMS_TOKEN` | Microsoft Graph API token for Teams message search |
| `KF_POSTMAN_TOKEN` / `POSTMAN_API_KEY` | Postman API key (sent as `X-Api-Key`) for workspace, collection, and environment scanning |
| `KF_DOCKER_TOKEN` | Docker registry token (`user:pass` or bearer token). If unset, credentials from the Docker keychain are used |
| `KF_AWS_KEY`, `KF_AWS_SECRET`, and `KF_AWS_SESSION_TOKEN` | AWS credentials for S3 bucket scanning. Session token is optional, for temporary credentials |

View file

@ -514,12 +514,11 @@ kingfisher scan ./my-project \
## Scanning Platform-Specific Targets
> **Deprecated**
> Legacy scan flags such as `--github-user`, `--gitlab-group`,
> `--bitbucket-workspace`, `--azure-organization`, `--huggingface-user`,
> Older documentation may refer to legacy provider flags such as
> `--github-user`, `--gitlab-group`, `--bitbucket-workspace`,
> `--slack-query`, `--jira-url`, `--confluence-url`, `--s3-bucket`,
> `--gcs-bucket`, and `--docker-image` still work for now, but they trigger a
> warning and will be removed in a future release. Migrate to the
> `kingfisher scan <provider>` subcommands below to future-proof your automations.
> `--gcs-bucket`, and `--docker-image`. Use the
> `kingfisher scan <provider>` subcommands below instead.
---
@ -761,10 +760,10 @@ kingfisher scan gitlab --group my-group --gitlab-exclude my-group/**/legacy-* --
### Scan Azure Repos organization or collection (requires `KF_AZURE_TOKEN` or `KF_AZURE_PAT`)
```bash
kingfisher scan azure --organization my-org
kingfisher scan azure --azure-organization my-org
# Azure Repos Server example
KF_AZURE_PAT="pat" kingfisher scan azure --organization DefaultCollection --azure-base-url https://ado.internal.example/tfs/
KF_AZURE_PAT="pat" kingfisher scan azure --azure-organization DefaultCollection --base-url https://ado.internal.example/tfs/
```
### Scan specific Azure Repos projects
@ -772,8 +771,8 @@ KF_AZURE_PAT="pat" kingfisher scan azure --organization DefaultCollection --azur
Projects are specified as `ORGANIZATION/PROJECT`. Repeat the flag for multiple projects.
```bash
kingfisher scan azure --project my-org/payments \
--project my-org/core-platform
kingfisher scan azure --azure-project my-org/payments \
--azure-project my-org/core-platform
```
### Skip specific Azure repositories during enumeration
@ -781,7 +780,7 @@ kingfisher scan azure --project my-org/payments \
Repeat `--azure-exclude` to ignore repositories when scanning organizations or projects. Use identifiers like `ORGANIZATION/PROJECT/REPOSITORY`. Repositories that share the same name as their project can be excluded with `ORGANIZATION/PROJECT`, and gitignore-style patterns such as `my-org/*/archive-*` are also supported.
```bash
kingfisher scan azure --organization my-org \
kingfisher scan azure --azure-organization my-org \
--azure-exclude my-org/payments/legacy-service \
--azure-exclude my-org/**/archive-*
```
@ -789,11 +788,11 @@ kingfisher scan azure --organization my-org \
### List Azure repositories
```bash
kingfisher scan azure --organization my-org --list-only
kingfisher scan azure --azure-organization my-org --list-only
# list repositories for specific projects
kingfisher scan azure --project my-org/app --project my-org/api --list-only
kingfisher scan azure --azure-project my-org/app --azure-project my-org/api --list-only
# skip specific repositories while listing (supports glob patterns)
kingfisher scan azure --organization my-org --azure-exclude my-org/**/experimental-* --list-only
kingfisher scan azure --azure-organization my-org --azure-exclude my-org/**/experimental-* --list-only
```
---
@ -805,7 +804,7 @@ kingfisher scan azure --organization my-org --azure-exclude my-org/**/experiment
```bash
kingfisher scan gitea --organization my-org
# self-hosted example
KF_GITEA_TOKEN="gtoken" kingfisher scan gitea --organization platform --gitea-api-url https://gitea.internal.example/api/v1/
KF_GITEA_TOKEN="gtoken" kingfisher scan gitea --organization platform --api-url https://gitea.internal.example/api/v1/
```
### Scan Gitea user
@ -842,9 +841,9 @@ KF_GITEA_TOKEN="gtoken" KF_GITEA_USERNAME="org" \
```bash
kingfisher scan gitea --organization my-org --list-only
# enumerate every organization visible to the authenticated user
KF_GITEA_TOKEN="gtoken" kingfisher scan gitea --all-gitea-organizations --list-only
KF_GITEA_TOKEN="gtoken" kingfisher scan gitea --all-organizations --list-only
# self-hosted example
KF_GITEA_TOKEN="gtoken" kingfisher scan gitea --user johndoe --gitea-api-url https://gitea.internal.example/api/v1/ --list-only
KF_GITEA_TOKEN="gtoken" kingfisher scan gitea --user johndoe --api-url https://gitea.internal.example/api/v1/ --list-only
```
---
@ -917,7 +916,7 @@ Bitbucket no longer supports App Tokens as of September 9, 2025: https://support
### Self-hosted Bitbucket Server
Use `--bitbucket-api-url` to point Kingfisher at your server's REST endpoint, for example `https://bitbucket.example.com/rest/api/1.0/`. Provide credentials with `KF_BITBUCKET_USERNAME` plus either `KF_BITBUCKET_TOKEN` or `KF_BITBUCKET_PASSWORD`, and pass `--tls-mode=off` (or the legacy `--ignore-certs`) when connecting to HTTP or otherwise insecure instances.
Use `--api-url` to point Kingfisher at your server's REST endpoint, for example `https://bitbucket.example.com/rest/api/1.0/`. Provide credentials with `KF_BITBUCKET_USERNAME` plus either `KF_BITBUCKET_TOKEN` or `KF_BITBUCKET_PASSWORD`, and pass `--tls-mode=off` (or the legacy `--ignore-certs`) when connecting to HTTP or otherwise insecure instances.
---
@ -928,13 +927,13 @@ Hugging Face hosts git repositories for models, datasets, and Spaces. Kingfisher
### Scan Hugging Face user
```bash
kingfisher scan huggingface --user <username>
kingfisher scan huggingface --huggingface-user <username>
```
### Scan Hugging Face organization
```bash
kingfisher scan huggingface --organization <orgname>
kingfisher scan huggingface --huggingface-organization <orgname>
```
### Scan specific Hugging Face resources
@ -942,9 +941,9 @@ kingfisher scan huggingface --organization <orgname>
Scan individual repositories by ID (owner/name) or by passing the full HTTPS URL:
```bash
kingfisher scan huggingface --model <owner/model>
kingfisher scan huggingface --dataset https://huggingface.co/datasets/<owner>/<dataset>
kingfisher scan huggingface --space <owner/space>
kingfisher scan huggingface --huggingface-model <owner/model>
kingfisher scan huggingface --huggingface-dataset https://huggingface.co/datasets/<owner>/<dataset>
kingfisher scan huggingface --huggingface-space <owner/space>
```
Use `--huggingface-exclude` to omit results returned by user or organization enumeration. Prefix values with `model:`, `dataset:`, or `space:` when you only want to skip a specific resource type.
@ -952,7 +951,7 @@ Use `--huggingface-exclude` to omit results returned by user or organization enu
### List Hugging Face repositories
```bash
kingfisher scan huggingface --user <username> --list-only
kingfisher scan huggingface --huggingface-user <username> --list-only
```
### Authenticate to Hugging Face
@ -1010,7 +1009,7 @@ KF_CONFLUENCE_USER="user@example.com" KF_CONFLUENCE_TOKEN="token" \
--max-results 500
```
Use the base URL of your Confluence site for `--confluence-url`. Kingfisher automatically adds `/rest/api` to the end, so `https://example.com/wiki` and `https://example.com` both work depending on your server configuration.
Use the base URL of your Confluence site for `--url`. Kingfisher automatically adds `/rest/api` to the end, so `https://example.com/wiki` and `https://example.com` both work depending on your server configuration.
Generate a personal access token and set it in the `KF_CONFLUENCE_TOKEN` environment variable. By default, Kingfisher sends the token as a bearer token in the `Authorization` header.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

16
fuzz/Cargo.lock generated
View file

@ -142,6 +142,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "base32"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076"
[[package]]
name = "base64"
version = "0.22.1"
@ -1997,7 +2003,6 @@ dependencies = [
"hex",
"memchr",
"memmap2",
"once_cell",
"parking_lot",
"rustc-hash",
"schemars",
@ -2024,17 +2029,17 @@ name = "kingfisher-rules"
version = "0.1.0"
dependencies = [
"anyhow",
"base32",
"base64",
"crc32fast",
"hmac",
"ignore",
"include_dir",
"kingfisher-core",
"lazy_static",
"liquid",
"liquid-core",
"percent-encoding",
"rand 0.10.0",
"rand 0.10.1",
"regex",
"schemars",
"serde",
@ -2062,7 +2067,6 @@ dependencies = [
"http",
"kingfisher-core",
"kingfisher-rules",
"once_cell",
"parking_lot",
"regex",
"rustc-hash",
@ -2503,9 +2507,9 @@ dependencies = [
[[package]]
name = "rand"
version = "0.10.0"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8"
checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
dependencies = [
"chacha20",
"getrandom 0.4.2",

View file

@ -1,8 +1,10 @@
use std::io::Write;
use anyhow::Result;
use schemars::JsonSchema;
use serde::Serialize;
use crate::cli::commands::access_map::{AccessMapArgs, AccessMapProvider};
use crate::cli::commands::access_map::{AccessMapArgs, AccessMapOutputFormat, AccessMapProvider};
mod airtable;
mod algolia;
@ -112,15 +114,16 @@ pub async fn run(args: AccessMapArgs) -> Result<()> {
AccessMapProvider::Asana => asana::map_access(&args).await?,
};
let json = serde_json::to_string_pretty(&result)?;
if let Some(path) = args.json_out {
std::fs::write(path, json)?;
} else {
println!("{json}");
}
if let Some(path) = args.html_out {
report::generate_html_report_multi(&[result], &path)?;
let mut writer = args.output_args.get_writer()?;
match args.output_args.format {
AccessMapOutputFormat::Json => {
serde_json::to_writer_pretty(&mut writer, &result)?;
writeln!(writer)?;
}
AccessMapOutputFormat::Html => {
let html = report::render_html_report_multi(&[result])?;
writer.write_all(html.as_bytes())?;
}
}
Ok(())

View file

@ -10,13 +10,18 @@ use super::AccessMapResult;
/// Generate a standalone HTML report with a simple, collapsible tree view (no D3 dependency).
pub fn generate_html_report_multi(results: &[AccessMapResult], path: &Path) -> Result<()> {
let json = serde_json::to_string(results)?;
let compressed = gzip_base64(&json)?;
let html = build_html(&json, &compressed);
let html = render_html_report_multi(results)?;
std::fs::write(path, html)?;
Ok(())
}
/// Render a standalone HTML report for a collection of access-map results.
pub fn render_html_report_multi(results: &[AccessMapResult]) -> Result<String> {
let json = serde_json::to_string(results)?;
let compressed = gzip_base64(&json)?;
Ok(build_html(&json, &compressed))
}
fn gzip_base64(json_str: &str) -> Result<String> {
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
encoder.write_all(json_str.as_bytes())?;

View file

@ -1,6 +1,9 @@
use std::path::PathBuf;
use clap::{Args, ValueEnum};
use clap::{Args, ValueEnum, ValueHint};
use strum::Display;
use crate::util::get_writer_for_file_or_stdout;
/// Inspect a cloud credential and derive the effective identity and blast radius.
#[derive(Args, Debug)]
@ -13,13 +16,37 @@ pub struct AccessMapArgs {
#[clap(value_parser, value_name = "CREDENTIAL", required = false)]
pub credential_path: Option<PathBuf>,
/// Optional path to write an interactive D3.js HTML report
#[clap(long, value_name = "PATH")]
pub html_out: Option<PathBuf>,
#[command(flatten)]
pub output_args: AccessMapOutputArgs,
}
/// Optional path to write JSON output (otherwise JSON goes to stdout)
#[clap(long, value_name = "PATH")]
pub json_out: Option<PathBuf>,
#[derive(Args, Debug, Clone)]
#[command(next_help_heading = "Output Options")]
pub struct AccessMapOutputArgs {
/// Write output to the specified path (stdout if not given)
#[arg(long, short = 'o', value_hint = ValueHint::FilePath)]
pub output: Option<PathBuf>,
/// Output format
#[arg(long, short = 'f', default_value = "json")]
pub format: AccessMapOutputFormat,
}
impl AccessMapOutputArgs {
/// Return a writer for the specified output destination
pub fn get_writer(&self) -> std::io::Result<Box<dyn std::io::Write>> {
get_writer_for_file_or_stdout(self.output.as_ref())
}
}
#[derive(Copy, Clone, Debug, Display, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
#[strum(serialize_all = "kebab-case")]
pub enum AccessMapOutputFormat {
/// Pretty-printed JSON
Json,
/// Standalone HTML access-map report
Html,
}
/// Supported cloud providers for identity mapping.

View file

@ -22,6 +22,7 @@ const DEFAULT_BITBUCKET_API_URL: &str = "https://api.bitbucket.org/2.0/";
const DEFAULT_AZURE_BASE_URL: &str = "https://dev.azure.com/";
const DEFAULT_SLACK_API_URL: &str = "https://slack.com/api/";
const DEFAULT_TEAMS_API_URL: &str = "https://graph.microsoft.com/";
const DEFAULT_POSTMAN_API_URL: &str = "https://api.getpostman.com/";
// -----------------------------------------------------------------------------
// Inputs
@ -295,7 +296,40 @@ pub struct InputSpecifierArgs {
#[arg(long, default_value = "https://graph.microsoft.com/", value_hint = ValueHint::Url, hide = true)]
pub teams_api_url: Url,
/// Maximum number of Slack, Teams, Jira, or Confluence results to fetch
/// Scan a Postman workspace by ID or web URL (repeatable)
#[arg(long = "postman-workspace", value_name = "ID_OR_URL", hide = true)]
pub postman_workspaces: Vec<String>,
/// Scan a single Postman collection by UID or web URL (repeatable)
#[arg(long = "postman-collection", value_name = "UID_OR_URL", hide = true)]
pub postman_collections: Vec<String>,
/// Scan a single Postman environment by UID (repeatable)
#[arg(long = "postman-environment", value_name = "UID", hide = true)]
pub postman_environments: Vec<String>,
/// Scan every workspace, collection, and environment visible to the API key
#[arg(
long = "postman-all",
hide = true,
conflicts_with_all = ["postman_workspaces", "postman_collections", "postman_environments"],
)]
pub postman_all: bool,
/// Include Postman mocks and monitors when scanning a workspace (off by default)
#[arg(long = "postman-include-mocks-monitors", hide = true)]
pub postman_include_mocks_monitors: bool,
/// Use the specified base URL for the Postman API (e.g. self-hosted)
#[arg(
long = "postman-api-url",
default_value = DEFAULT_POSTMAN_API_URL,
value_hint = ValueHint::Url,
hide = true,
)]
pub postman_api_url: Url,
/// Maximum number of Slack, Teams, Jira, Confluence, or Postman results to fetch
#[arg(long, default_value_t = 100, hide = true)]
pub max_results: usize,
@ -433,6 +467,10 @@ impl InputSpecifierArgs {
|| self.confluence_url.is_some()
|| self.slack_query.is_some()
|| self.teams_query.is_some()
|| !self.postman_workspaces.is_empty()
|| !self.postman_collections.is_empty()
|| !self.postman_environments.is_empty()
|| self.postman_all
|| self.s3_bucket.is_some()
|| self.gcs_bucket.is_some()
|| !self.docker_image.is_empty()

View file

@ -508,6 +508,26 @@ impl ScanCommandArgs {
scan_args.input_specifier_args.max_results = args.max_results;
None
}
ScanInputCommand::Postman(args) => {
if !args.all
&& args.workspaces.is_empty()
&& args.collections.is_empty()
&& args.environments.is_empty()
{
bail!(
"Specify --workspace, --collection, --environment, or --all when using the postman subcommand"
);
}
scan_args.input_specifier_args.postman_workspaces = args.workspaces;
scan_args.input_specifier_args.postman_collections = args.collections;
scan_args.input_specifier_args.postman_environments = args.environments;
scan_args.input_specifier_args.postman_all = args.all;
scan_args.input_specifier_args.postman_include_mocks_monitors =
args.include_mocks_monitors;
scan_args.input_specifier_args.postman_api_url = args.api_url;
scan_args.input_specifier_args.max_results = args.max_results;
None
}
ScanInputCommand::S3(args) => {
scan_args.input_specifier_args.s3_bucket = Some(args.bucket);
scan_args.input_specifier_args.s3_prefix = args.prefix;
@ -649,6 +669,9 @@ pub enum ScanInputCommand {
/// Scan Confluence content using CQL
Confluence(ConfluenceScanArgs),
/// Scan Postman workspaces, collections, and environments
Postman(PostmanScanArgs),
/// Scan an S3 bucket
S3(S3ScanArgs),
@ -869,6 +892,46 @@ pub struct ConfluenceScanArgs {
pub max_results: usize,
}
#[derive(Args, Debug, Clone)]
pub struct PostmanScanArgs {
/// Scan a Postman workspace by ID or web URL (repeatable)
#[arg(long = "workspace", alias = "postman-workspace", value_name = "ID_OR_URL")]
pub workspaces: Vec<String>,
/// Scan a single Postman collection by UID or web URL (repeatable)
#[arg(long = "collection", alias = "postman-collection", value_name = "UID_OR_URL")]
pub collections: Vec<String>,
/// Scan a single Postman environment by UID (repeatable)
#[arg(long = "environment", alias = "postman-environment", value_name = "UID")]
pub environments: Vec<String>,
/// Scan every workspace, collection, and environment visible to the API key
#[arg(
long = "all",
alias = "postman-all",
conflicts_with_all = ["workspaces", "collections", "environments"],
)]
pub all: bool,
/// Include Postman mocks and monitors when scanning a workspace (off by default)
#[arg(long = "include-mocks-monitors", alias = "postman-include-mocks-monitors")]
pub include_mocks_monitors: bool,
/// Override the Postman API base URL
#[arg(
long = "api-url",
alias = "postman-api-url",
default_value = "https://api.getpostman.com/",
value_hint = ValueHint::Url,
)]
pub api_url: Url,
/// Maximum number of resources to fetch
#[arg(long = "max-results", default_value_t = 100)]
pub max_results: usize,
}
#[derive(Args, Debug, Clone)]
pub struct S3ScanArgs {
/// S3 bucket to scan

View file

@ -990,6 +990,12 @@ pub(crate) fn create_minimal_scan_args() -> crate::cli::commands::scan::ScanArgs
slack_api_url: Url::parse("https://slack.com/api/").unwrap(),
teams_query: None,
teams_api_url: Url::parse("https://graph.microsoft.com/").unwrap(),
postman_workspaces: Vec::new(),
postman_collections: Vec::new(),
postman_environments: Vec::new(),
postman_all: false,
postman_include_mocks_monitors: false,
postman_api_url: Url::parse("https://api.getpostman.com/").unwrap(),
docker_image: Vec::new(),
git_clone: GitCloneMode::Bare,
git_history: GitHistoryMode::Full,

View file

@ -108,6 +108,7 @@ pub struct FindingsStore {
slack_links: FxHashMap<PathBuf, String>,
teams_links: FxHashMap<PathBuf, String>,
confluence_links: FxHashMap<PathBuf, String>,
postman_links: FxHashMap<PathBuf, String>,
s3_buckets: FxHashMap<PathBuf, String>,
repo_links: FxHashMap<PathBuf, String>,
access_map_results: Vec<AccessMapResult>,
@ -129,6 +130,7 @@ impl FindingsStore {
slack_links: FxHashMap::default(),
teams_links: FxHashMap::default(),
confluence_links: FxHashMap::default(),
postman_links: FxHashMap::default(),
s3_buckets: FxHashMap::default(),
repo_links: FxHashMap::default(),
access_map_results: Vec::new(),
@ -396,6 +398,14 @@ impl FindingsStore {
&self.confluence_links
}
pub fn register_postman_resource(&mut self, path: PathBuf, link: String) {
self.postman_links.insert(path, link);
}
pub fn postman_links(&self) -> &FxHashMap<PathBuf, String> {
&self.postman_links
}
pub fn register_repo_link(&mut self, path: PathBuf, link: String) {
self.repo_links.insert(path, link);
}
@ -437,6 +447,10 @@ impl FindingsStore {
self.confluence_links.entry(dir.clone()).or_insert_with(|| link.clone());
}
for (dir, link) in other.postman_links() {
self.postman_links.entry(dir.clone()).or_insert_with(|| link.clone());
}
let batch: Vec<_> = other
.get_matches()
.iter()
@ -511,14 +525,19 @@ mod tests {
#[test]
fn dedup_filter_remains_monotonic_across_growth() {
// capacity=2 triggers growth after two insertions. With a 2-item Bloom
// filter (~29 bits, 10 hash functions), the third item has ~2.5% FP
// probability, so we only assert "new" for items 1-2 (inserted into
// an empty or near-empty filter where FP is negligible) and just
// trigger growth for item 3 without asserting its novelty.
let mut filter = DedupBloomSet::with_capacity(2);
assert!(!filter.contains_or_insert(11));
assert!(!filter.contains_or_insert(22));
assert!(!filter.contains_or_insert(33));
let _ = filter.contains_or_insert(33); // triggers growth; FP-rate too high to assert
assert!(filter.contains_or_insert(11));
assert!(filter.contains_or_insert(22));
assert!(filter.contains_or_insert(33));
assert!(filter.contains_or_insert(11), "item 11 must be found after growth");
assert!(filter.contains_or_insert(22), "item 22 must be found after growth");
assert!(filter.contains_or_insert(33), "item 33 must be found after growth");
}
}

View file

@ -40,6 +40,7 @@ pub mod location;
pub mod matcher;
pub mod origin;
pub mod parser;
pub mod postman;
pub mod provider_endpoints;
pub mod pyc;
pub mod reporter;

View file

@ -585,6 +585,12 @@ fn create_default_scan_args() -> cli::commands::scan::ScanArgs {
teams_query: None,
teams_api_url: Url::parse("https://graph.microsoft.com/").unwrap(),
postman_workspaces: Vec::new(),
postman_collections: Vec::new(),
postman_environments: Vec::new(),
postman_all: false,
postman_include_mocks_monitors: false,
postman_api_url: Url::parse("https://api.getpostman.com/").unwrap(),
// Docker image scanning
docker_image: Vec::new(),

447
src/postman.rs Normal file
View file

@ -0,0 +1,447 @@
use anyhow::{Context, Result, bail};
use reqwest::{Client, StatusCode};
use serde::Deserialize;
use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::time::sleep;
use tracing::{debug, warn};
use url::Url;
#[derive(Debug, Clone, Default)]
pub struct PostmanSelectors {
pub workspaces: Vec<String>,
pub collections: Vec<String>,
pub environments: Vec<String>,
pub all: bool,
pub include_mocks_monitors: bool,
}
impl PostmanSelectors {
pub fn is_empty(&self) -> bool {
!self.all
&& self.workspaces.is_empty()
&& self.collections.is_empty()
&& self.environments.is_empty()
}
}
#[derive(Debug, Deserialize)]
struct WorkspacesEnvelope {
#[serde(default)]
workspaces: Vec<WorkspaceSummary>,
}
#[derive(Debug, Deserialize)]
struct WorkspaceSummary {
id: String,
}
#[derive(Debug, Deserialize)]
struct WorkspaceDetailEnvelope {
workspace: WorkspaceDetail,
}
#[derive(Debug, Deserialize)]
struct WorkspaceDetail {
id: String,
#[serde(default)]
collections: Vec<RefItem>,
#[serde(default)]
environments: Vec<RefItem>,
#[serde(default)]
mocks: Vec<RefItem>,
#[serde(default)]
monitors: Vec<RefItem>,
}
#[derive(Debug, Deserialize)]
struct RefItem {
#[serde(default)]
id: Option<String>,
#[serde(default)]
uid: Option<String>,
}
impl RefItem {
fn pick(&self) -> Option<&str> {
self.uid.as_deref().or(self.id.as_deref())
}
}
const MAX_RETRIES: usize = 5;
fn token_from_env() -> Result<String> {
if let Ok(t) = std::env::var("KF_POSTMAN_TOKEN")
&& !t.is_empty()
{
return Ok(t);
}
if let Ok(t) = std::env::var("POSTMAN_API_KEY")
&& !t.is_empty()
{
return Ok(t);
}
bail!("KF_POSTMAN_TOKEN (or POSTMAN_API_KEY) environment variable must be set");
}
/// Best-effort UID extraction. Accepts:
/// - bare UID strings (returned unchanged)
/// - Postman web URLs of the form `.../{workspace,collection,environment,mock,monitor}[s]/<uid>[/<suffix>]`:
/// the UID following the type marker is preferred. Falls back to the last
/// non-suffix path segment if no type marker is present.
fn resolve_uid(input: &str) -> String {
if !input.starts_with("http://") && !input.starts_with("https://") {
return input.to_string();
}
let Ok(parsed) = Url::parse(input) else {
return input.to_string();
};
let Some(segs) = parsed.path_segments() else {
return input.to_string();
};
let segs: Vec<&str> = segs.filter(|s| !s.is_empty()).collect();
// Prefer the segment immediately after the *last* known type marker.
// Postman web URLs commonly nest workspace + collection + suffix; the deepest
// type marker is the one the user pasted the URL to scan.
const TYPE_MARKERS: &[&str] = &[
"workspace",
"workspaces",
"collection",
"collections",
"environment",
"environments",
"mock",
"mocks",
"monitor",
"monitors",
];
if let Some(window) = segs.windows(2).rev().find(|w| TYPE_MARKERS.contains(&w[0])) {
return window[1].to_string();
}
// Fall back to the last segment that is not a known terminal suffix
// (e.g. /overview, /edit, /run on Postman web URLs).
const TERMINAL_SUFFIXES: &[&str] = &[
"overview",
"edit",
"run",
"documentation",
"info",
"history",
"tests",
"request",
"fork",
"watch",
"comments",
];
if let Some(last) = segs.iter().rev().find(|s| !TERMINAL_SUFFIXES.contains(s)) {
return last.to_string();
}
input.to_string()
}
async fn get_with_retries(
client: &Client,
url: Url,
token: &str,
) -> Result<Option<serde_json::Value>> {
let mut attempt = 0;
loop {
attempt += 1;
let resp = client
.get(url.clone())
.header("X-Api-Key", token)
.header("Accept", "application/json")
.send()
.await
.with_context(|| format!("Failed to send Postman request to {}", url))?;
let status = resp.status();
if status == StatusCode::TOO_MANY_REQUESTS && attempt <= MAX_RETRIES {
let retry_after = resp
.headers()
.get("X-RateLimit-RetryAfter")
.or_else(|| resp.headers().get("Retry-After"))
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(1);
warn!(
"Postman API rate-limited at {} (attempt {}). Sleeping {}s",
url, attempt, retry_after
);
sleep(Duration::from_secs(retry_after)).await;
continue;
}
if status == StatusCode::NOT_FOUND || status == StatusCode::FORBIDDEN {
debug!("Postman API returned {} for {} (skipping)", status, url);
return Ok(None);
}
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
bail!("Postman API request to {} failed with status {}: {}", url, status, body);
}
let value: serde_json::Value =
resp.json().await.with_context(|| format!("Failed to parse JSON from {}", url))?;
return Ok(Some(value));
}
}
fn web_url_for_collection(uid: &str) -> String {
format!("https://go.postman.co/collection/{}", uid)
}
fn web_url_for_environment(uid: &str) -> String {
format!("https://go.postman.co/environments/{}", uid)
}
fn web_url_for_workspace(id: &str) -> String {
format!("https://go.postman.co/workspace/{}", id)
}
fn web_url_for_mock(uid: &str) -> String {
format!("https://go.postman.co/mock/{}", uid)
}
fn web_url_for_monitor(uid: &str) -> String {
format!("https://go.postman.co/monitor/{}", uid)
}
async fn fetch_workspace_ids(client: &Client, api_url: &Url, token: &str) -> Result<Vec<String>> {
let url = api_url.join("workspaces").context("Failed to build workspaces URL")?;
let Some(value) = get_with_retries(client, url, token).await? else {
return Ok(Vec::new());
};
let envelope: WorkspacesEnvelope =
serde_json::from_value(value).context("Failed to parse Postman workspaces response")?;
Ok(envelope.workspaces.into_iter().map(|w| w.id).collect())
}
async fn fetch_workspace_detail(
client: &Client,
api_url: &Url,
token: &str,
id: &str,
) -> Result<Option<(serde_json::Value, WorkspaceDetail)>> {
let url = api_url
.join(&format!("workspaces/{}", id))
.with_context(|| format!("Failed to build workspace URL for {}", id))?;
let Some(value) = get_with_retries(client, url, token).await? else {
return Ok(None);
};
let envelope: WorkspaceDetailEnvelope = serde_json::from_value(value.clone())
.with_context(|| format!("Failed to parse workspace {} response", id))?;
Ok(Some((value, envelope.workspace)))
}
async fn fetch_resource(
client: &Client,
api_url: &Url,
token: &str,
path: &str,
) -> Result<Option<serde_json::Value>> {
let url = api_url.join(path).with_context(|| format!("Failed to build URL for {}", path))?;
get_with_retries(client, url, token).await
}
async fn write_json(dir: &Path, name: &str, value: &serde_json::Value) -> Result<PathBuf> {
tokio::fs::create_dir_all(dir).await?;
let path = dir.join(name);
tokio::fs::write(&path, serde_json::to_vec_pretty(value)?).await?;
Ok(path)
}
pub async fn download_postman_to_dir(
api_url: Url,
selectors: PostmanSelectors,
max_results: usize,
ignore_certs: bool,
output_dir: &Path,
) -> Result<Vec<(PathBuf, String)>> {
let token = token_from_env()?;
let client = Client::builder()
.danger_accept_invalid_certs(ignore_certs)
.build()
.context("Failed to build HTTP client")?;
tokio::fs::create_dir_all(output_dir).await?;
let mut paths: Vec<(PathBuf, String)> = Vec::new();
// Track UIDs we've already fetched to avoid duplicate API calls when
// the same collection/environment is referenced from multiple workspaces.
let mut seen_collections = std::collections::HashSet::new();
let mut seen_environments = std::collections::HashSet::new();
let mut seen_mocks = std::collections::HashSet::new();
let mut seen_monitors = std::collections::HashSet::new();
let mut seen_workspaces = std::collections::HashSet::new();
// Resolve workspace selectors (explicit list and/or --all)
let mut workspace_ids: Vec<String> =
selectors.workspaces.iter().map(|s| resolve_uid(s)).collect();
if selectors.all {
let listed = fetch_workspace_ids(&client, &api_url, &token).await?;
for id in listed {
if !workspace_ids.contains(&id) {
workspace_ids.push(id);
}
}
}
// Walk workspaces -> collect collection/environment/mock/monitor UIDs
let mut collection_uids: Vec<String> =
selectors.collections.iter().map(|s| resolve_uid(s)).collect();
let mut environment_uids: Vec<String> =
selectors.environments.iter().map(|s| resolve_uid(s)).collect();
let mut mock_uids: Vec<String> = Vec::new();
let mut monitor_uids: Vec<String> = Vec::new();
for ws_id in workspace_ids {
if !seen_workspaces.insert(ws_id.clone()) {
continue;
}
let Some((raw, detail)) = fetch_workspace_detail(&client, &api_url, &token, &ws_id).await?
else {
continue;
};
let path = write_json(output_dir, &format!("workspace_{}.json", detail.id), &raw).await?;
paths.push((path, web_url_for_workspace(&detail.id)));
for c in detail.collections {
if let Some(uid) = c.pick() {
let uid = uid.to_string();
if !collection_uids.contains(&uid) {
collection_uids.push(uid);
}
}
}
for e in detail.environments {
if let Some(uid) = e.pick() {
let uid = uid.to_string();
if !environment_uids.contains(&uid) {
environment_uids.push(uid);
}
}
}
if selectors.include_mocks_monitors {
for m in detail.mocks {
if let Some(uid) = m.pick() {
mock_uids.push(uid.to_string());
}
}
for m in detail.monitors {
if let Some(uid) = m.pick() {
monitor_uids.push(uid.to_string());
}
}
}
}
let limit_hit = |paths: &Vec<(PathBuf, String)>| max_results > 0 && paths.len() >= max_results;
for uid in collection_uids {
if limit_hit(&paths) {
break;
}
if !seen_collections.insert(uid.clone()) {
continue;
}
let Some(value) =
fetch_resource(&client, &api_url, &token, &format!("collections/{}", uid)).await?
else {
continue;
};
let path = write_json(output_dir, &format!("collection_{}.json", uid), &value).await?;
paths.push((path, web_url_for_collection(&uid)));
}
for uid in environment_uids {
if limit_hit(&paths) {
break;
}
if !seen_environments.insert(uid.clone()) {
continue;
}
let Some(value) =
fetch_resource(&client, &api_url, &token, &format!("environments/{}", uid)).await?
else {
continue;
};
let path = write_json(output_dir, &format!("environment_{}.json", uid), &value).await?;
paths.push((path, web_url_for_environment(&uid)));
}
for uid in mock_uids {
if limit_hit(&paths) {
break;
}
if !seen_mocks.insert(uid.clone()) {
continue;
}
let Some(value) =
fetch_resource(&client, &api_url, &token, &format!("mocks/{}", uid)).await?
else {
continue;
};
let path = write_json(output_dir, &format!("mock_{}.json", uid), &value).await?;
paths.push((path, web_url_for_mock(&uid)));
}
for uid in monitor_uids {
if limit_hit(&paths) {
break;
}
if !seen_monitors.insert(uid.clone()) {
continue;
}
let Some(value) =
fetch_resource(&client, &api_url, &token, &format!("monitors/{}", uid)).await?
else {
continue;
};
let path = write_json(output_dir, &format!("monitor_{}.json", uid), &value).await?;
paths.push((path, web_url_for_monitor(&uid)));
}
Ok(paths)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_uid_passes_through_bare_ids() {
assert_eq!(resolve_uid("12345-abc"), "12345-abc");
assert_eq!(
resolve_uid("11111111-2222-3333-4444-555555555555"),
"11111111-2222-3333-4444-555555555555"
);
}
#[test]
fn resolve_uid_extracts_uid_after_type_marker() {
assert_eq!(
resolve_uid("https://www.postman.com/team/workspace/abc-uid-123"),
"abc-uid-123"
);
// Terminal `/overview` must not be mistaken for the UID.
assert_eq!(
resolve_uid("https://www.postman.com/team/workspace/abc-uid-123/overview"),
"abc-uid-123"
);
// Type marker preference: the segment after `collection/` is the UID, not the trailing segment.
assert_eq!(
resolve_uid("https://go.postman.co/workspace/wks-1/collection/col-9/run"),
"col-9"
);
assert_eq!(resolve_uid("https://go.postman.co/workspace/wks-1/environment/env-9"), "env-9");
}
#[test]
fn selectors_is_empty_by_default() {
assert!(PostmanSelectors::default().is_empty());
let sel = PostmanSelectors { workspaces: vec!["a".into()], ..PostmanSelectors::default() };
assert!(!sel.is_empty());
}
}

View file

@ -706,6 +706,11 @@ impl DetailsReporter {
ds.teams_links().get(path).cloned()
}
fn postman_resource_url(&self, path: &std::path::Path) -> Option<String> {
let ds = self.datastore.lock().ok()?;
ds.postman_links().get(path).cloned()
}
fn repo_artifact_url(&self, path: &std::path::Path) -> Option<String> {
let ds = self.datastore.lock().ok()?;
ds.repo_links().get(path).cloned()
@ -1064,6 +1069,7 @@ impl DetailsReporter {
.or_else(|| self.confluence_page_url(&e.path).and_then(Self::non_empty_string))
.or_else(|| self.slack_message_url(&e.path).and_then(Self::non_empty_string))
.or_else(|| self.teams_message_url(&e.path).and_then(Self::non_empty_string))
.or_else(|| self.postman_resource_url(&e.path).and_then(Self::non_empty_string))
.or_else(|| self.s3_display_path(&e.path).and_then(Self::non_empty_string))
.or_else(|| self.docker_display_path(&e.path).and_then(Self::non_empty_string))
.or_else(|| Self::non_empty_string(e.path.display().to_string())),
@ -1726,6 +1732,12 @@ mod tests {
slack_api_url: Url::parse("https://slack.com/api/").unwrap(),
teams_query: None,
teams_api_url: Url::parse("https://graph.microsoft.com/").unwrap(),
postman_workspaces: Vec::new(),
postman_collections: Vec::new(),
postman_environments: Vec::new(),
postman_all: false,
postman_include_mocks_monitors: false,
postman_api_url: Url::parse("https://api.getpostman.com/").unwrap(),
max_results: 100,
s3_bucket: None,
s3_prefix: None,

View file

@ -151,6 +151,12 @@ mod tests {
slack_api_url: Url::parse("https://slack.com/api/").unwrap(),
teams_query: None,
teams_api_url: Url::parse("https://graph.microsoft.com/").unwrap(),
postman_workspaces: Vec::new(),
postman_collections: Vec::new(),
postman_environments: Vec::new(),
postman_all: false,
postman_include_mocks_monitors: false,
postman_api_url: Url::parse("https://api.getpostman.com/").unwrap(),
// s3
s3_bucket: None,
s3_prefix: None,

View file

@ -25,6 +25,7 @@ use crate::{
gitea, github, gitlab, huggingface, jira,
matcher::{Match, Matcher, MatcherStats},
origin::{Origin, OriginSet},
postman,
rules_database::RulesDatabase,
s3,
scanner::processing::BlobProcessor,
@ -741,6 +742,45 @@ pub async fn fetch_slack_messages(
Ok(vec![output_dir])
}
pub async fn fetch_postman_resources(
args: &scan::ScanArgs,
global_args: &global::GlobalArgs,
datastore: &Arc<Mutex<findings_store::FindingsStore>>,
) -> Result<Vec<PathBuf>> {
let selectors = postman::PostmanSelectors {
workspaces: args.input_specifier_args.postman_workspaces.clone(),
collections: args.input_specifier_args.postman_collections.clone(),
environments: args.input_specifier_args.postman_environments.clone(),
all: args.input_specifier_args.postman_all,
include_mocks_monitors: args.input_specifier_args.postman_include_mocks_monitors,
};
if selectors.is_empty() {
return Ok(Vec::new());
}
let api_url = args.input_specifier_args.postman_api_url.clone();
let max_results = args.input_specifier_args.max_results;
let output_root = {
let ds = datastore.lock().unwrap();
ds.clone_root()
};
let output_dir = output_root.join("postman");
let paths = postman::download_postman_to_dir(
api_url,
selectors,
max_results,
global_args.ignore_certs,
&output_dir,
)
.await?;
{
let mut ds = datastore.lock().unwrap();
for (path, link) in &paths {
ds.register_postman_resource(path.clone(), link.clone());
}
}
Ok(vec![output_dir])
}
pub async fn fetch_teams_messages(
args: &scan::ScanArgs,
global_args: &global::GlobalArgs,

View file

@ -36,8 +36,8 @@ use crate::{
enumerate_huggingface_repos,
repos::{
enumerate_gitea_repos, enumerate_gitlab_repos, fetch_confluence_pages,
fetch_gcs_objects, fetch_git_host_artifacts, fetch_jira_issues, fetch_s3_objects,
fetch_slack_messages, fetch_teams_messages,
fetch_gcs_objects, fetch_git_host_artifacts, fetch_jira_issues,
fetch_postman_resources, fetch_s3_objects, fetch_slack_messages, fetch_teams_messages,
},
run_secret_validation, save_docker_images,
summary::{compute_scan_totals, print_scan_summary},
@ -380,6 +380,10 @@ async fn fetch_all_artifacts(
let teams_dirs = fetch_teams_messages(args, global_args, datastore).await?;
input_roots.extend(teams_dirs);
// Fetch Postman resources if requested
let postman_dirs = fetch_postman_resources(args, global_args, datastore).await?;
input_roots.extend(postman_dirs);
// Save Docker images if specified
if !args.input_specifier_args.docker_image.is_empty() {
let clone_root = {

View file

@ -0,0 +1,56 @@
use clap::Parser;
use kingfisher::cli::{
commands::access_map::AccessMapOutputFormat,
global::{Command, CommandLineArgs},
};
#[test]
fn access_map_accepts_format_and_output_flags() -> anyhow::Result<()> {
let args = CommandLineArgs::try_parse_from([
"kingfisher",
"access-map",
"gitlab",
"./gitlab.token",
"--format",
"json",
"--output",
"gitlab.access-map.json",
"--no-update-check",
])?;
let command = match args.command {
Command::AccessMap(args) => args,
other => panic!("unexpected command parsed: {:?}", other),
};
assert_eq!(command.output_args.format, AccessMapOutputFormat::Json);
assert_eq!(
command.output_args.output.as_deref(),
Some(std::path::Path::new("gitlab.access-map.json"))
);
Ok(())
}
#[test]
fn access_map_rejects_legacy_output_flags() {
for legacy_flag in ["--json-out", "--html-out"] {
let err = CommandLineArgs::try_parse_from([
"kingfisher",
"access-map",
"gitlab",
"./gitlab.token",
legacy_flag,
"out.json",
"--no-update-check",
])
.expect_err("legacy access-map output flags should be rejected");
let rendered = err.to_string();
assert!(
rendered.contains(legacy_flag),
"expected error to mention {legacy_flag}: {rendered}"
);
}
}

View file

@ -128,6 +128,12 @@ fn run_skiplist(skip_regex: Vec<String>, skip_skipword: Vec<String>) -> Result<u
slack_api_url: Url::parse("https://slack.com/api/").unwrap(),
teams_query: None,
teams_api_url: Url::parse("https://graph.microsoft.com/").unwrap(),
postman_workspaces: Vec::new(),
postman_collections: Vec::new(),
postman_environments: Vec::new(),
postman_all: false,
postman_include_mocks_monitors: false,
postman_api_url: Url::parse("https://api.getpostman.com/").unwrap(),
max_results: 100,
s3_bucket: None,
s3_prefix: None,

View file

@ -114,6 +114,12 @@ fn test_bitbucket_remote_scan() -> Result<()> {
slack_api_url: Url::parse("https://slack.com/api/").unwrap(),
teams_query: None,
teams_api_url: Url::parse("https://graph.microsoft.com/").unwrap(),
postman_workspaces: Vec::new(),
postman_collections: Vec::new(),
postman_environments: Vec::new(),
postman_all: false,
postman_include_mocks_monitors: false,
postman_api_url: Url::parse("https://api.getpostman.com/").unwrap(),
s3_bucket: None,
s3_prefix: None,
role_arn: None,

View file

@ -131,6 +131,12 @@ rules:
slack_api_url: Url::parse("https://slack.com/api/").unwrap(),
teams_query: None,
teams_api_url: Url::parse("https://graph.microsoft.com/").unwrap(),
postman_workspaces: Vec::new(),
postman_collections: Vec::new(),
postman_environments: Vec::new(),
postman_all: false,
postman_include_mocks_monitors: false,
postman_api_url: Url::parse("https://api.getpostman.com/").unwrap(),
// s3
s3_bucket: None,
s3_prefix: None,

View file

@ -118,6 +118,12 @@ fn test_github_remote_scan() -> Result<()> {
slack_api_url: Url::parse("https://slack.com/api/").unwrap(),
teams_query: None,
teams_api_url: Url::parse("https://graph.microsoft.com/").unwrap(),
postman_workspaces: Vec::new(),
postman_collections: Vec::new(),
postman_environments: Vec::new(),
postman_all: false,
postman_include_mocks_monitors: false,
postman_api_url: Url::parse("https://api.getpostman.com/").unwrap(),
// s3
s3_bucket: None,
s3_prefix: None,

View file

@ -117,6 +117,12 @@ fn test_gitlab_remote_scan() -> Result<()> {
slack_api_url: Url::parse("https://slack.com/api/").unwrap(),
teams_query: None,
teams_api_url: Url::parse("https://graph.microsoft.com/").unwrap(),
postman_workspaces: Vec::new(),
postman_collections: Vec::new(),
postman_environments: Vec::new(),
postman_all: false,
postman_include_mocks_monitors: false,
postman_api_url: Url::parse("https://api.getpostman.com/").unwrap(),
// s3
s3_bucket: None,
s3_prefix: None,
@ -297,6 +303,12 @@ fn test_gitlab_remote_scan_no_history() -> Result<()> {
slack_api_url: Url::parse("https://slack.com/api/").unwrap(),
teams_query: None,
teams_api_url: Url::parse("https://graph.microsoft.com/").unwrap(),
postman_workspaces: Vec::new(),
postman_collections: Vec::new(),
postman_environments: Vec::new(),
postman_all: false,
postman_include_mocks_monitors: false,
postman_api_url: Url::parse("https://api.getpostman.com/").unwrap(),
s3_bucket: None,
s3_prefix: None,
role_arn: None,

299
tests/int_postman.rs Normal file
View file

@ -0,0 +1,299 @@
use std::sync::{Arc, Mutex};
use anyhow::Result;
use kingfisher::{
cli::{
GlobalArgs,
commands::{
azure::AzureRepoType,
bitbucket::{BitbucketAuthArgs, BitbucketRepoType},
gitea::GiteaRepoType,
github::{GitCloneMode, GitHistoryMode, GitHubRepoType},
gitlab::GitLabRepoType,
inputs::{ContentFilteringArgs, InputSpecifierArgs},
output::{OutputArgs, ReportOutputFormat},
rules::RuleSpecifierArgs,
scan::{ConfidenceLevel, ScanArgs},
},
global::{Mode, TlsMode},
},
findings_store::FindingsStore,
rule_loader::RuleLoader,
rules_database::RulesDatabase,
scanner::run_async_scan,
update::UpdateStatus,
};
use tempfile::TempDir;
use url::Url;
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{method, path},
};
#[tokio::test]
async fn test_scan_postman_all() -> Result<()> {
use std::env;
let server = MockServer::start().await;
// Workspace listing
let workspaces_response = serde_json::json!({
"workspaces": [
{ "id": "ws-uid-1", "name": "demo", "type": "team", "visibility": "private" }
]
});
Mock::given(method("GET"))
.and(path("/workspaces"))
.respond_with(ResponseTemplate::new(200).set_body_json(workspaces_response))
.mount(&server)
.await;
// Workspace detail (collections + environments embedded)
let workspace_detail = serde_json::json!({
"workspace": {
"id": "ws-uid-1",
"name": "demo",
"type": "team",
"collections": [
{ "id": "col-uid-1", "uid": "col-uid-1", "name": "demo collection" }
],
"environments": [
{ "id": "env-uid-1", "uid": "env-uid-1", "name": "prod" }
],
"mocks": [],
"monitors": []
}
});
Mock::given(method("GET"))
.and(path("/workspaces/ws-uid-1"))
.respond_with(ResponseTemplate::new(200).set_body_json(workspace_detail))
.mount(&server)
.await;
// Collection detail — plant a GitHub PAT in a request bearer token
let collection_response = serde_json::json!({
"collection": {
"info": { "name": "demo collection" },
"item": [{
"name": "auth call",
"request": {
"method": "GET",
"header": [],
"url": { "raw": "https://api.example.com/v1/me" },
"auth": {
"type": "bearer",
"bearer": [{
"key": "token",
"value": "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",
"type": "string"
}]
}
}
}]
}
});
Mock::given(method("GET"))
.and(path("/collections/col-uid-1"))
.respond_with(ResponseTemplate::new(200).set_body_json(collection_response))
.mount(&server)
.await;
// Environment detail — plant a second GitHub PAT in a "secret"-typed variable
// (this exercises the headline finding: the API returns plaintext for "secret"
// env vars, so Kingfisher can detect what the UI would otherwise mask).
let environment_response = serde_json::json!({
"environment": {
"id": "env-uid-1",
"name": "prod",
"values": [
{
"key": "API_TOKEN",
"value": "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",
"type": "secret",
"enabled": true
}
]
}
});
Mock::given(method("GET"))
.and(path("/environments/env-uid-1"))
.respond_with(ResponseTemplate::new(200).set_body_json(environment_response))
.mount(&server)
.await;
// TODO: Audit that the environment access only happens in single-threaded code.
unsafe { env::set_var("KF_POSTMAN_TOKEN", "test-key") };
let temp_dir = TempDir::new()?;
let clone_dir = temp_dir.path().to_path_buf();
let scan_args = ScanArgs {
num_jobs: 2,
rules: RuleSpecifierArgs {
rules_path: Vec::new(),
rule: vec!["all".into()],
load_builtins: true,
},
input_specifier_args: InputSpecifierArgs {
path_inputs: Vec::new(),
git_url: Vec::new(),
git_clone_dir: None,
keep_clones: false,
repo_clone_limit: None,
include_contributors: false,
github_user: Vec::new(),
github_organization: Vec::new(),
github_exclude: Vec::new(),
all_github_organizations: false,
github_api_url: Url::parse("https://api.github.com/").unwrap(),
github_repo_type: GitHubRepoType::Source,
gitlab_user: Vec::new(),
gitlab_group: Vec::new(),
gitlab_exclude: Vec::new(),
all_gitlab_groups: false,
gitlab_api_url: Url::parse("https://gitlab.com/").unwrap(),
gitlab_repo_type: GitLabRepoType::Owner,
gitlab_include_subgroups: false,
huggingface_user: Vec::new(),
huggingface_organization: Vec::new(),
huggingface_model: Vec::new(),
huggingface_dataset: Vec::new(),
huggingface_space: Vec::new(),
huggingface_exclude: Vec::new(),
gitea_user: Vec::new(),
gitea_organization: Vec::new(),
gitea_exclude: Vec::new(),
all_gitea_organizations: false,
gitea_api_url: Url::parse("https://gitea.com/api/v1/").unwrap(),
gitea_repo_type: GiteaRepoType::Source,
bitbucket_user: Vec::new(),
bitbucket_workspace: Vec::new(),
bitbucket_project: Vec::new(),
bitbucket_exclude: Vec::new(),
all_bitbucket_workspaces: false,
bitbucket_api_url: Url::parse("https://api.bitbucket.org/2.0/").unwrap(),
bitbucket_repo_type: BitbucketRepoType::Source,
bitbucket_auth: BitbucketAuthArgs::default(),
azure_organization: Vec::new(),
azure_project: Vec::new(),
azure_exclude: Vec::new(),
all_azure_projects: false,
azure_base_url: Url::parse("https://dev.azure.com/").unwrap(),
azure_repo_type: AzureRepoType::Source,
jira_url: None,
jql: None,
jira_include_comments: false,
jira_include_changelog: false,
confluence_url: None,
cql: None,
slack_query: None,
slack_api_url: Url::parse("https://slack.com/api/").unwrap(),
teams_query: None,
teams_api_url: Url::parse("https://graph.microsoft.com/").unwrap(),
postman_workspaces: Vec::new(),
postman_collections: Vec::new(),
postman_environments: Vec::new(),
postman_all: true,
postman_include_mocks_monitors: false,
postman_api_url: Url::parse(&format!("{}/", server.uri()))?,
max_results: 10,
s3_bucket: None,
s3_prefix: None,
role_arn: None,
aws_local_profile: None,
gcs_bucket: None,
gcs_prefix: None,
gcs_service_account: None,
docker_image: Vec::new(),
git_clone: GitCloneMode::Bare,
git_history: GitHistoryMode::Full,
commit_metadata: true,
repo_artifacts: false,
scan_nested_repos: true,
since_commit: None,
branch: None,
branch_root: false,
branch_root_commit: None,
staged: false,
},
content_filtering_args: ContentFilteringArgs {
max_file_size_mb: 25.0,
extraction_depth: 2,
no_binary: true,
no_extract_archives: false,
exclude: Vec::new(),
},
confidence: ConfidenceLevel::Low,
no_validate: true,
access_map: false,
rule_stats: false,
only_valid: false,
min_entropy: Some(0.0),
redact: false,
git_repo_timeout: 1800,
output_args: OutputArgs { output: None, format: ReportOutputFormat::Pretty },
no_dedup: true,
baseline_file: None,
manage_baseline: false,
skip_regex: Vec::new(),
skip_word: Vec::new(),
skip_aws_account: Vec::new(),
skip_aws_account_file: None,
no_base64: false,
turbo: false,
extra_ignore_comments: Vec::new(),
no_inline_ignore: false,
no_ignore_if_contains: false,
view_report: false,
view_report_port: 7890,
view_report_address: "127.0.0.1".to_string(),
validation_retries: 1,
validation_rps: None,
validation_rps_rule: Vec::new(),
validation_timeout: 10,
full_validation_response: false,
max_validation_response_length: 2048,
};
let global_args = GlobalArgs {
verbose: 0,
quiet: true,
color: Mode::Auto,
no_update_check: false,
self_update: false,
progress: Mode::Never,
ignore_certs: false,
user_agent_suffix: None,
tls_mode: TlsMode::Strict,
allow_internal_ips: false,
endpoint: Vec::new(),
endpoint_config: None,
};
let loaded = RuleLoader::from_rule_specifiers(&scan_args.rules).load(&scan_args)?;
let resolved = loaded.resolve_enabled_rules()?;
let rules_db = Arc::new(RulesDatabase::from_rules(resolved.into_iter().cloned().collect())?);
let datastore = Arc::new(Mutex::new(FindingsStore::new(clone_dir)));
let update_status = UpdateStatus::default();
run_async_scan(&global_args, &scan_args, Arc::clone(&datastore), &rules_db, &update_status)
.await?;
let ds = datastore.lock().unwrap();
let findings = ds.get_matches().len();
assert!(
findings >= 2,
"expected at least two findings (collection bearer + secret env value), got {}",
findings
);
// Both findings should resolve to a Postman web URL via the link map.
let postman_link_count = ds.postman_links().len();
assert!(
postman_link_count >= 2,
"expected postman_links registered for collection + environment (got {})",
postman_link_count
);
Ok(())
}

View file

@ -97,6 +97,12 @@ async fn test_redact_hashes_finding_values() -> Result<()> {
slack_api_url: Url::parse("https://slack.com/api/").unwrap(),
teams_query: None,
teams_api_url: Url::parse("https://graph.microsoft.com/").unwrap(),
postman_workspaces: Vec::new(),
postman_collections: Vec::new(),
postman_environments: Vec::new(),
postman_all: false,
postman_include_mocks_monitors: false,
postman_api_url: Url::parse("https://api.getpostman.com/").unwrap(),
s3_bucket: None,
s3_prefix: None,
role_arn: None,

View file

@ -101,6 +101,12 @@ impl TestContext {
slack_api_url: Url::parse("https://slack.com/api/").unwrap(),
teams_query: None,
teams_api_url: Url::parse("https://graph.microsoft.com/").unwrap(),
postman_workspaces: Vec::new(),
postman_collections: Vec::new(),
postman_environments: Vec::new(),
postman_all: false,
postman_include_mocks_monitors: false,
postman_api_url: Url::parse("https://api.getpostman.com/").unwrap(),
s3_bucket: None,
s3_prefix: None,
role_arn: None,
@ -262,6 +268,12 @@ async fn test_scan_slack_messages() -> Result<()> {
slack_api_url: Url::parse(&format!("{}/", server.uri()))?,
teams_query: None,
teams_api_url: Url::parse("https://graph.microsoft.com/").unwrap(),
postman_workspaces: Vec::new(),
postman_collections: Vec::new(),
postman_environments: Vec::new(),
postman_all: false,
postman_include_mocks_monitors: false,
postman_api_url: Url::parse("https://api.getpostman.com/").unwrap(),
max_results: 10,
// s3
s3_bucket: None,

View file

@ -137,6 +137,12 @@ async fn test_scan_teams_messages() -> Result<()> {
slack_api_url: Url::parse("https://slack.com/api/").unwrap(),
teams_query: Some("secret".into()),
teams_api_url: Url::parse(&format!("{}/", server.uri()))?,
postman_workspaces: Vec::new(),
postman_collections: Vec::new(),
postman_environments: Vec::new(),
postman_all: false,
postman_include_mocks_monitors: false,
postman_api_url: Url::parse("https://api.getpostman.com/").unwrap(),
max_results: 10,
s3_bucket: None,
s3_prefix: None,

View file

@ -174,6 +174,12 @@ async fn test_validation_cache_and_depvars() -> Result<()> {
slack_api_url: Url::parse("https://slack.com/api/").unwrap(),
teams_query: None,
teams_api_url: Url::parse("https://graph.microsoft.com/").unwrap(),
postman_workspaces: Vec::new(),
postman_collections: Vec::new(),
postman_environments: Vec::new(),
postman_all: false,
postman_include_mocks_monitors: false,
postman_api_url: Url::parse("https://api.getpostman.com/").unwrap(),
// s3
s3_bucket: None,
s3_prefix: None,

View file

@ -117,6 +117,12 @@ impl TestContext {
slack_api_url: Url::parse("https://slack.com/api/").unwrap(),
teams_query: None,
teams_api_url: Url::parse("https://graph.microsoft.com/").unwrap(),
postman_workspaces: Vec::new(),
postman_collections: Vec::new(),
postman_environments: Vec::new(),
postman_all: false,
postman_include_mocks_monitors: false,
postman_api_url: Url::parse("https://api.getpostman.com/").unwrap(),
// s3
s3_bucket: None,
s3_prefix: None,
@ -264,6 +270,12 @@ impl TestContext {
slack_api_url: Url::parse("https://slack.com/api/").unwrap(),
teams_query: None,
teams_api_url: Url::parse("https://graph.microsoft.com/").unwrap(),
postman_workspaces: Vec::new(),
postman_collections: Vec::new(),
postman_environments: Vec::new(),
postman_all: false,
postman_include_mocks_monitors: false,
postman_api_url: Url::parse("https://api.getpostman.com/").unwrap(),
// s3
s3_bucket: None,
s3_prefix: None,