- 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.
This commit is contained in:
Mick Grove 2026-02-11 23:27:05 -08:00
commit ec44d9b60b
5 changed files with 104 additions and 8 deletions

View file

@ -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.

View file

@ -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 |

View file

@ -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());
}
}

View file

@ -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

View file

@ -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<String> {
fn is_blocked_ip(ip: std::net::IpAddr) -> bool {
BLOCKED_NETS.iter().filter_map(|cidr| cidr.parse::<IpNet>().ok()).any(|net| net.contains(&ip))
}
fn normalize_issuer_url(issuer: &str) -> Result<Url> {
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"))
}