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.

This commit is contained in:
Mick Grove 2026-04-29 08:12:08 -07:00
commit 997480ffc7
39 changed files with 1613 additions and 184 deletions

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,

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