diff --git a/CHANGELOG.md b/CHANGELOG.md index fc47461..5ff6d43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ All notable changes to this project will be documented in this file. - Fixed HTTP validation incorrectly marking valid credentials as inactive when response bodies exceeded 2048 bytes. Matchers (`JsonValid`, `WordMatch`, etc.) now run against the full response; only the stored preview remains truncated for reporting. - Fixed validation flakiness under service rate limiting by retrying HTTP validations on 429/408 in addition to transient 5xx failures. - Prevented transient HTTP validation failures (429/5xx) from being cached, avoiding cache poisoning that could suppress later successful validations in the same scan. +- Added `kingfisher.temporal.1` rule for Temporal Cloud API keys (namespace-scoped and user-scoped JWT formats) with Temporal-specific pattern matching. +- Added Temporal Cloud active credential validation via `GET https://saas-api.tmprl.cloud/cloud/current-identity` using bearer auth, so Temporal keys validate against provider APIs instead of generic OIDC discovery. +- Fixed JWT issuer normalization to treat bare host issuers (e.g. `iss: "temporal.io"`) as HTTPS URLs during discovery, avoiding low-level URL builder failures. +- Added `crates/kingfisher-rules/build.rs` to ensure embedded rule assets rebuild when files under `crates/kingfisher-rules/data` change. ## [v1.81.0] - Fixed checksum-template evaluation for prefixed tokens by using explicit checksum/body captures in NPM, GitHub, Confluent, and GitLab rules. diff --git a/README.md b/README.md index 7c3aa29..cd4b44c 100644 --- a/README.md +++ b/README.md @@ -260,8 +260,8 @@ Kingfisher ships with [hundreds of rules](crates/kingfisher-rules/data/rules/) t | Category | What we catch | |----------|---------------| | **AI SaaS APIs** | OpenAI, Anthropic, Google Gemini, Cohere, Mistral, Stability AI, Replicate, xAI (Grok), Ollama, Langchain, Perplexity, Weights & Biases, Cerebras, Friendli, Fireworks.ai, NVIDIA NIM, together.ai, Zhipu, and more | -| **Cloud Providers** | AWS, Azure, GCP, Alibaba Cloud, DigitalOcean, IBM Cloud, Cloudflare, and more | -| **Dev & CI/CD** | GitHub/GitLab tokens, CircleCI, TravisCI, TeamCity, Docker Hub, npm, PyPI, and more | +| **Cloud Providers** | AWS, Azure, GCP, Alibaba Cloud, DigitalOcean, IBM Cloud, Cloudflare, Temporal Cloud, and more | +| **Dev & CI/CD** | GitHub/GitLab tokens, CircleCI, TravisCI, TeamCity, Docker Hub, npm, PyPI, Vercel, and more | | **Messaging & Comms** | Slack, Discord, Microsoft Teams, Twilio, Mailgun, SendGrid, Mailchimp, and more | | **Databases & Data Ops** | MongoDB Atlas, PlanetScale, Postgres DSNs, Grafana Cloud, Datadog, Dynatrace, and more | | **Payments & Billing** | Stripe, PayPal, Square, GoCardless, and more | diff --git a/crates/kingfisher-rules/build.rs b/crates/kingfisher-rules/build.rs new file mode 100644 index 0000000..57152f0 --- /dev/null +++ b/crates/kingfisher-rules/build.rs @@ -0,0 +1,24 @@ +use std::fs; +use std::path::Path; + +fn main() { + let data_dir = Path::new("data"); + println!("cargo:rerun-if-changed={}", data_dir.display()); + emit_rerun_for_tree(data_dir); +} + +fn emit_rerun_for_tree(path: &Path) { + let Ok(entries) = fs::read_dir(path) else { + return; + }; + + for entry in entries.flatten() { + let p = entry.path(); + if p.is_dir() { + emit_rerun_for_tree(&p); + continue; + } + + println!("cargo:rerun-if-changed={}", p.display()); + } +} diff --git a/crates/kingfisher-rules/data/rules/temporal.yml b/crates/kingfisher-rules/data/rules/temporal.yml new file mode 100644 index 0000000..c00cbe9 --- /dev/null +++ b/crates/kingfisher-rules/data/rules/temporal.yml @@ -0,0 +1,47 @@ +rules: + - name: Temporal Cloud API Key + id: kingfisher.temporal.1 + pattern: | + (?x) + \b + ( + eyJ[A-Za-z0-9_-]{10,} + \. + [A-Za-z0-9_-]*Y2NvdW50X2lk (?# payload contains "account_id" ) + [A-Za-z0-9_-]*InRlbXBvcmFsLmlv (?# payload contains "temporal.io" ) + [A-Za-z0-9_-]*(?:ICJrZXlfaWQiOi|a2V5X2lk|rZXlfaWQi) (?# payload contains "key_id" ) + [A-Za-z0-9_-]{20,} + \. + [A-Za-z0-9_-]{20,} + ) + \b + pattern_requirements: + min_digits: 3 + min_entropy: 3.2 + confidence: medium + examples: + - 'temporal_api_key="eyJhbGciOiJFUzI1NiIsImtpZCI6IlNhbXBsZSJ9.eyJhY2NvdW50X2lkIjoic2FtcGxlIiwiYXVkIjpbInRlbXBvcmFsLmlvIl0sImlzcyI6InRlbXBvcmFsLmlvIiwia2V5X2lkIjoic2FtcGxlLWtleSIsInN1YiI6InVzZXItMTIzIiwiZXhwIjoyMDAwMDAwMDAwfQ.c2lnbmF0dXJlX3BsYWNlaG9sZGVyXzEyMzQ1Njc4OTA"' + - 'temporal --profile cloud config set --prop api_key --value "eyJhbGciOiJFUzI1NiIsImtpZCI6IkFub3RoZXJLZXkifQ.eyJhY2NvdW50X2lkIjoidGVzdC1hY2N0IiwiYXVkIjpbInRlbXBvcmFsLmlvIl0sImlzcyI6InRlbXBvcmFsLmlvIiwia2V5X2lkIjoidGVzdC1rZXktaWQiLCJzdWIiOiJ1c2VyLXRlc3QiLCJleHAiOjIwMDAwMDAwMDB9.c2lnbmF0dXJlX3Rlc3RfdmFsdWVfMDEyMzQ1Njc4OTAi' + negative_examples: + - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInN1YiI6InVzZXIiLCJleHAiOjE5NzIxNzI0NjF9.WQWcwBAQFNE259f2o8ruFln_UMLTFEnEaUD7KHrs9Aw' + references: + - https://docs.temporal.io/cloud/api-keys + - https://docs.temporal.io/cli/env-config + validation: + type: Http + content: + request: + method: GET + url: https://saas-api.tmprl.cloud/cloud/current-identity + headers: + Authorization: "Bearer {{ TOKEN }}" + Accept: application/json + response_matcher: + - report_response: true + - type: StatusMatch + status: [200] + - type: WordMatch + words: + - '"user"' + - '"serviceAccount"' + match_all_words: false diff --git a/crates/kingfisher-scanner/src/validation/jwt.rs b/crates/kingfisher-scanner/src/validation/jwt.rs index 4900512..455ef1e 100644 --- a/crates/kingfisher-scanner/src/validation/jwt.rs +++ b/crates/kingfisher-scanner/src/validation/jwt.rs @@ -162,7 +162,9 @@ pub async fn validate_jwt_with( return Ok((false, "no kid in header".into())); }; - let config_url = format!("{}/.well-known/openid-configuration", issuer.trim_end_matches('/')); + let issuer_url = normalize_issuer_url(&issuer)?; + let config_url = + format!("{}/.well-known/openid-configuration", issuer_url.as_str().trim_end_matches('/')); let cfg_resp = client .get(&config_url) .send() @@ -186,11 +188,7 @@ pub async fn validate_jwt_with( return Ok((false, "jwks_uri must use https".to_string())); } - let iss_host = Url::parse(&issuer) - .map_err(|e| anyhow!("invalid iss: {e}"))? - .host_str() - .unwrap_or_default() - .to_ascii_lowercase(); + let iss_host = issuer_url.host_str().unwrap_or_default().to_ascii_lowercase(); let jwks_host = url.host_str().unwrap_or_default().to_ascii_lowercase(); if jwks_host != iss_host { return Ok(( @@ -245,3 +243,26 @@ fn extract_aud_strings(claims: &Claims) -> Vec { fn is_blocked_ip(ip: std::net::IpAddr) -> bool { BLOCKED_NETS.iter().filter_map(|cidr| cidr.parse::().ok()).any(|net| net.contains(&ip)) } + +fn normalize_issuer_url(issuer: &str) -> Result { + let trimmed = issuer.trim(); + if trimmed.is_empty() { + return Err(anyhow!("invalid iss: empty issuer")); + } + + if let Ok(url) = Url::parse(trimmed) { + if url.host_str().is_some() { + return Ok(url); + } + } + + if !trimmed.contains("://") { + let with_https = format!("https://{trimmed}"); + let url = Url::parse(&with_https).map_err(|e| anyhow!("invalid iss: {e}"))?; + if url.host_str().is_some() { + return Ok(url); + } + } + + Err(anyhow!("invalid iss: missing host")) +}