From 4ab5932d5706fe97904609d816a0a8f7e9d43fc6 Mon Sep 17 00:00:00 2001 From: Mick Grove Date: Wed, 11 Feb 2026 13:56:17 -0800 Subject: [PATCH] - Added Vercel credential rules for new token formats introduced February 2026: vcp_ (personal access), vci_ (integration), vca_ (app access), vcr_ (app refresh), vck_ (AI Gateway API key). All use CRC32/Base62 checksum validation. Legacy 24-char format retained as kingfisher.vercel.1. - Added revocation support for Vercel app tokens (vca_, vcr_) via https://api.vercel.com/login/oauth/token/revoke. Requires VERCEL_APP_CLIENT_ID (or NEXT_PUBLIC_VERCEL_APP_CLIENT_ID) and VERCEL_APP_CLIENT_SECRET. - Fixed validate/revoke command generation to omit regex named captures (e.g., BODY, CHECKSUM) when they are not used by validation/revocation templates, so rules like Vercel no longer produce unnecessary --var BODY=... arguments. --- CHANGELOG.md | 4 + crates/kingfisher-rules/data/rules/vercel.yml | 255 +++++++++++++++++- docs/TOKEN_REVOCATION_SUPPORT.md | 10 +- src/reporter.rs | 191 ++++++++++++- src/validation.rs | 9 +- 5 files changed, 455 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cf9f5d..fc47461 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ All notable changes to this project will be documented in this file. ## [v1.82.0] +- Added Vercel credential rules for new token formats introduced February 2026: `vcp_` (personal access), `vci_` (integration), `vca_` (app access), `vcr_` (app refresh), `vck_` (AI Gateway API key). All use CRC32/Base62 checksum validation. Legacy 24-char format retained as `kingfisher.vercel.1`. +- Added revocation support for Vercel app tokens (`vca_`, `vcr_`) via `https://api.vercel.com/login/oauth/token/revoke`. Requires `VERCEL_APP_CLIENT_ID` (or `NEXT_PUBLIC_VERCEL_APP_CLIENT_ID`) and `VERCEL_APP_CLIENT_SECRET`. +- Fixed validate/revoke command generation to omit regex named captures (e.g., `BODY`, `CHECKSUM`) when they are not used by validation/revocation templates, so rules like Vercel no longer produce unnecessary `--var BODY=...` arguments. +- 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. diff --git a/crates/kingfisher-rules/data/rules/vercel.yml b/crates/kingfisher-rules/data/rules/vercel.yml index 97646a8..7538d47 100644 --- a/crates/kingfisher-rules/data/rules/vercel.yml +++ b/crates/kingfisher-rules/data/rules/vercel.yml @@ -1,5 +1,5 @@ rules: - - name: Vercel API Token + - name: Vercel API Token (legacy 24-char) id: kingfisher.vercel.1 pattern: | (?xi) @@ -35,9 +35,260 @@ rules: - '"email":' match_all_words: true references: - - https://vercel.com/docs/rest-api#authentication + - https://vercel.com/kb/guide/how-do-i-use-a-vercel-api-access-token + - https://docs.vercel.com/docs/rest-api/reference/welcome#authentication examples: - "vercel-key = DdZV6ZDZW6Vpl7n7JqtrCE5i" - "vercel_token = zyMBA1qVEMAf4UNNZtCAbg6u" - "vercel_api_key = MTg0AW799OY1HmyDdn84or3C" - "vercel_secret = A7n9Xfp3tBz7D0XpOTMWpiOM" + + - name: Vercel Personal Access Token (vcp_) + id: kingfisher.vercel.2 + pattern: | + (?x) + \b + ( + vcp_(?P[A-Za-z0-9_-]{50})(?P[A-Za-z0-9]{6}) + ) + \b + pattern_requirements: + min_digits: 3 + checksum: + actual: + template: "{{ checksum }}" + requires_capture: checksum + # Vercel token checksum format: Base62(CRC32(body)) padded to 6 chars. + # The "body" is the 50-char payload portion after the prefix. + expected: "{{ body | crc32 | base62: 6 }}" + skip_if_missing: true + confidence: medium + min_entropy: 3.5 + validation: + type: Http + content: + request: + method: GET + url: https://api.vercel.com/v2/user + headers: + Authorization: "Bearer {{TOKEN}}" + response_matcher: + - report_response: true + - type: StatusMatch + status: [200] + - type: WordMatch + words: + - '"user":' + - '"email":' + match_all_words: true + references: + - https://vercel.com/kb/guide/how-do-i-use-a-vercel-api-access-token + - https://docs.vercel.com/docs/rest-api/reference/welcome#authentication + - https://vercel.com/changelog/new-token-formats-and-secret-scanning + examples: + - "vcp_35UYJwYZDigYATKhxJUAhPqRhit2Xe3dtiG60LsUTHeklEXDQ94Jafpu" + - "vercel_access_token=vcp_4mcjwVDwqtVCVGWCcxRjdzGpkGZ3NkwXZv8ktcoQ0EG0dnjpMP1Rzi71" + + - name: Vercel Integration Token (vci_) + id: kingfisher.vercel.3 + pattern: | + (?x) + \b + ( + vci_(?P[A-Za-z0-9_-]{50})(?P[A-Za-z0-9]{6}) + ) + \b + pattern_requirements: + min_digits: 3 + checksum: + actual: + template: "{{ checksum }}" + requires_capture: checksum + expected: "{{ body | crc32 | base62: 6 }}" + skip_if_missing: true + confidence: medium + min_entropy: 3.5 + validation: + type: Http + content: + request: + method: GET + url: https://api.vercel.com/v2/user + headers: + Authorization: "Bearer {{TOKEN}}" + response_matcher: + - report_response: true + - type: StatusMatch + status: [200] + - type: WordMatch + words: + - '"user":' + match_all_words: false + references: + - https://vercel.com/docs/integrations/vercel-api-integrations#create-an-access-token + - https://vercel.com/changelog/new-token-formats-and-secret-scanning + examples: + - "Vercel Integration Token: vci_35UYJwYZDigYATKhxJUAhPqRhit2Xe3dtiG60LsUTHeklEXDQ94Jafpu" + + - name: Vercel App Access Token (vca_) + id: kingfisher.vercel.4 + pattern: | + (?x) + \b + ( + vca_(?P[A-Za-z0-9_-]{50})(?P[A-Za-z0-9]{6}) + ) + \b + pattern_requirements: + min_digits: 3 + checksum: + actual: + template: "{{ checksum }}" + requires_capture: checksum + expected: "{{ body | crc32 | base62: 6 }}" + skip_if_missing: true + confidence: medium + min_entropy: 3.5 + validation: + type: Http + content: + request: + method: POST + url: https://api.vercel.com/login/oauth/userinfo + headers: + Authorization: "Bearer {{TOKEN}}" + response_matcher: + - report_response: true + - type: StatusMatch + status: [200] + - type: JsonValid + - type: WordMatch + words: + - '"sub":' + match_all_words: true + revocation: + type: Http + content: + request: + method: POST + url: https://api.vercel.com/login/oauth/token/revoke + headers: + # Requires the Vercel App's client_id and client_secret (Sign in with Vercel). + # Docs use NEXT_PUBLIC_VERCEL_APP_CLIENT_ID; support both. + Authorization: "Basic {{ NEXT_PUBLIC_VERCEL_APP_CLIENT_ID | default: VERCEL_APP_CLIENT_ID | append: ':' | append: VERCEL_APP_CLIENT_SECRET | b64enc }}" + Content-Type: application/x-www-form-urlencoded + body: "token={{ TOKEN | url_encode }}" + response_matcher: + - report_response: true + - type: StatusMatch + status: [200, 204] + references: + - https://vercel.com/docs/sign-in-with-vercel/tokens#access-token + - https://vercel.com/docs/sign-in-with-vercel/authorization-server-api#user-info-endpoint + - https://vercel.com/docs/sign-in-with-vercel/authorization-server-api#revoke-token-endpoint + - https://vercel.com/changelog/new-token-formats-and-secret-scanning + examples: + - "vca_BQuu9ChDu3n6Pfh6YQnCshpoYkWDSFKogLqmBtQ0tC8NAA5rXt340sjz" + + - name: Vercel App Refresh Token (vcr_) + id: kingfisher.vercel.5 + pattern: | + (?x) + \b + ( + vcr_(?P[A-Za-z0-9_-]{50})(?P[A-Za-z0-9]{6}) + ) + \b + pattern_requirements: + min_digits: 3 + checksum: + actual: + template: "{{ checksum }}" + requires_capture: checksum + expected: "{{ body | crc32 | base62: 6 }}" + skip_if_missing: true + confidence: medium + min_entropy: 3.5 + validation: + type: Http + content: + request: + method: POST + url: https://api.vercel.com/login/oauth/token/introspect + headers: + Content-Type: application/x-www-form-urlencoded + body: "token={{ TOKEN | url_encode }}" + response_matcher: + - report_response: true + - type: StatusMatch + status: [200] + - type: JsonValid + - type: WordMatch + words: + - '"active":true' + match_all_words: true + revocation: + type: Http + content: + request: + method: POST + url: https://api.vercel.com/login/oauth/token/revoke + headers: + Authorization: "Basic {{ NEXT_PUBLIC_VERCEL_APP_CLIENT_ID | default: VERCEL_APP_CLIENT_ID | append: ':' | append: VERCEL_APP_CLIENT_SECRET | b64enc }}" + Content-Type: application/x-www-form-urlencoded + body: "token={{ TOKEN | url_encode }}" + response_matcher: + - report_response: true + - type: StatusMatch + status: [200, 204] + references: + - https://vercel.com/docs/sign-in-with-vercel/tokens#refresh-token + - https://vercel.com/docs/sign-in-with-vercel/authorization-server-api#token-introspection-endpoint + - https://vercel.com/docs/sign-in-with-vercel/authorization-server-api#revoke-token-endpoint + - https://vercel.com/changelog/new-token-formats-and-secret-scanning + examples: + - "vcr_BQuu9ChDu3n6Pfh6YQnCshpoYkWDSFKogLqmBtQ0tC8NAA5rXt340sjz" + + - name: Vercel AI Gateway API Key (vck_) + id: kingfisher.vercel.6 + pattern: | + (?x) + \b + ( + vck_(?P[A-Za-z0-9_-]{50})(?P[A-Za-z0-9]{6}) + ) + \b + pattern_requirements: + min_digits: 3 + checksum: + actual: + template: "{{ checksum }}" + requires_capture: checksum + expected: "{{ body | crc32 | base62: 6 }}" + skip_if_missing: true + confidence: medium + min_entropy: 3.5 + validation: + type: Http + content: + request: + method: GET + url: https://ai-gateway.vercel.sh/v1/models + headers: + Authorization: "Bearer {{TOKEN}}" + Content-Type: application/json + response_matcher: + - report_response: true + - type: StatusMatch + status: [200] + - type: JsonValid + - type: WordMatch + words: + - '"data"' + match_all_words: true + references: + - https://vercel.com/docs/ai-gateway/authentication-and-byok/authentication + - https://vercel.com/docs/ai-gateway/openai-compat/rest-api + - https://vercel.com/changelog/new-token-formats-and-secret-scanning + examples: + - "vck_2YkmQj1uHqCVNoUx5a9uvRTe81gmAcln5hoRMPWFBU4tulufUf0OzP2K" diff --git a/docs/TOKEN_REVOCATION_SUPPORT.md b/docs/TOKEN_REVOCATION_SUPPORT.md index 2f463c4..3354d51 100644 --- a/docs/TOKEN_REVOCATION_SUPPORT.md +++ b/docs/TOKEN_REVOCATION_SUPPORT.md @@ -4,7 +4,7 @@ This document provides an overview of the revocation support added to Kingfisher ## Overview -Revocation support has been added for **6 services** that provide verified, documented programmatic API endpoints to delete or revoke access tokens/keys. Most implementations use the **HttpMultiStep** revocation type because they require a two-step process: +Revocation support has been added for **7 services** that provide verified, documented programmatic API endpoints to delete or revoke access tokens/keys. Most implementations use the **HttpMultiStep** revocation type because they require a two-step process: 1. **Step 1 (Lookup)**: Query the API to retrieve an internal ID or token identifier 2. **Step 2 (Delete)**: Use the extracted ID to perform the actual revocation @@ -66,6 +66,14 @@ Revocation support has been added for **6 services** that provide verified, docu 2. Revoke the token using its key - **Alternative**: Can also use `npm token revoke ` CLI command +### 7. Vercel (Sign in with Vercel) (`vercel.yml`) + +- **Rule IDs**: `kingfisher.vercel.3` (App Access Token `vca_...`), `kingfisher.vercel.4` (App Refresh Token `vcr_...`) +- **Revocation Type**: Http (single-step) +- **Endpoint**: `POST https://api.vercel.com/login/oauth/token/revoke` +- **Authentication**: HTTP Basic using the Vercel App's `client_id:client_secret` +- **Required vars**: `VERCEL_APP_CLIENT_ID`, `VERCEL_APP_CLIENT_SECRET` +- **Note**: This is the Authorization Server API used by "Sign in with Vercel" and is separate from the general REST API access tokens (PATs). ## Testing Revocation diff --git a/src/reporter.rs b/src/reporter.rs index 77b3709..d91401a 100644 --- a/src/reporter.rs +++ b/src/reporter.rs @@ -44,24 +44,135 @@ fn escape_for_shell(s: &str) -> String { format!("'{}'", s.replace('\'', "'\\''")) } -/// Build the --var arguments string from dependent captures. +fn extract_template_vars(text: &str) -> BTreeSet { + // Match {{ VAR }} or {{ VAR | filter }} patterns; return VAR uppercased. + let re = regex::Regex::new(r"\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*(?:\|[^}]*)?\}\}") + .expect("template var regex should compile"); + re.captures_iter(text) + .filter_map(|cap| cap.get(1).map(|m| m.as_str().to_uppercase())) + .collect() +} + +fn required_vars_for_validation(validation: &crate::rules::Validation) -> BTreeSet { + use crate::rules::Validation; + let mut vars = BTreeSet::new(); + + match validation { + Validation::Http(http) => { + vars.extend(extract_template_vars(&http.request.url)); + for (k, v) in &http.request.headers { + vars.extend(extract_template_vars(k)); + vars.extend(extract_template_vars(v)); + } + if let Some(body) = &http.request.body { + vars.extend(extract_template_vars(body)); + } + } + Validation::Grpc(grpc) => { + vars.extend(extract_template_vars(&grpc.request.url)); + for (k, v) in &grpc.request.headers { + vars.extend(extract_template_vars(k)); + vars.extend(extract_template_vars(v)); + } + if let Some(body) = &grpc.request.body { + vars.extend(extract_template_vars(body)); + } + } + Validation::AWS => { + vars.insert("AKID".to_string()); + vars.insert("TOKEN".to_string()); + } + Validation::GCP => { + vars.insert("TOKEN".to_string()); + } + Validation::MongoDB + | Validation::MySQL + | Validation::Postgres + | Validation::Jdbc + | Validation::JWT => { + vars.insert("TOKEN".to_string()); + } + Validation::AzureStorage => { + vars.insert("TOKEN".to_string()); + vars.insert("AZURENAME".to_string()); + } + Validation::Coinbase => { + vars.insert("TOKEN".to_string()); + vars.insert("CRED_NAME".to_string()); + } + Validation::Raw(_) => { + vars.insert("TOKEN".to_string()); + } + } + + vars +} + +fn required_vars_for_revocation(revocation: &Revocation) -> BTreeSet { + let mut vars = BTreeSet::new(); + + match revocation { + Revocation::AWS => { + vars.insert("AKID".to_string()); + vars.insert("TOKEN".to_string()); + } + Revocation::GCP => { + vars.insert("TOKEN".to_string()); + } + Revocation::Http(http) => { + vars.extend(extract_template_vars(&http.request.url)); + for (k, v) in &http.request.headers { + vars.extend(extract_template_vars(k)); + vars.extend(extract_template_vars(v)); + } + if let Some(body) = &http.request.body { + vars.extend(extract_template_vars(body)); + } + } + Revocation::HttpMultiStep(multi) => { + for step in &multi.steps { + vars.extend(extract_template_vars(&step.request.url)); + for (k, v) in &step.request.headers { + vars.extend(extract_template_vars(k)); + vars.extend(extract_template_vars(v)); + } + if let Some(body) = &step.request.body { + vars.extend(extract_template_vars(body)); + } + } + } + } + + vars +} + +/// Build the --var arguments string from dependent captures, but only for variables that are +/// required by the validation/revocation templates. fn build_var_args( dependent_captures: &std::collections::BTreeMap, akid_from_captures: Option<&str>, akid_from_validation_body: Option<&str>, + required_vars: &BTreeSet, ) -> String { let mut var_args = Vec::new(); // Add AKID if available (for AWS) if let Some(akid) = akid_from_captures.or(akid_from_validation_body) { - if !akid.is_empty() && !dependent_captures.contains_key("AKID") { + if !akid.is_empty() + && required_vars.contains("AKID") + && !dependent_captures.contains_key("AKID") + { var_args.push(format!("--var AKID={}", escape_for_shell(akid))); } } - // Add all dependent captures as --var arguments + // Add dependent captures only when required by the templates. + // This avoids generating commands like `--var BODY=...` for tokens whose named captures + // are just internal parsing aids (e.g., checksum payloads). for (name, value) in dependent_captures { - var_args.push(format!("--var {}={}", name, escape_for_shell(value))); + if required_vars.contains(name) && !name.eq_ignore_ascii_case("TOKEN") { + var_args.push(format!("--var {}={}", name, escape_for_shell(value))); + } } if var_args.is_empty() { @@ -85,8 +196,13 @@ fn build_revoke_command( akid_from_captures: Option<&str>, akid_from_validation_body: Option<&str>, ) -> Option { - let var_args = - build_var_args(dependent_captures, akid_from_captures, akid_from_validation_body); + let required_vars = required_vars_for_revocation(revocation); + let var_args = build_var_args( + dependent_captures, + akid_from_captures, + akid_from_validation_body, + &required_vars, + ); match revocation { Revocation::AWS => { @@ -146,8 +262,13 @@ fn build_validate_command( ) -> Option { use crate::rules::Validation; - let var_args = - build_var_args(dependent_captures, akid_from_captures, akid_from_validation_body); + let required_vars = required_vars_for_validation(validation); + let var_args = build_var_args( + dependent_captures, + akid_from_captures, + akid_from_validation_body, + &required_vars, + ); match validation { Validation::AWS => { @@ -1164,9 +1285,63 @@ mod tests { }; use gix::{date::Time, ObjectId}; use smallvec::SmallVec; + use std::collections::BTreeMap; use std::path::PathBuf; use tempfile::tempdir; + #[test] + fn build_var_args_ignores_unrequired_named_captures() { + let dependent = BTreeMap::from([ + ("BODY".to_string(), "payload-part".to_string()), + ("CHECKSUM".to_string(), "abc123".to_string()), + ]); + let required = BTreeSet::from(["TOKEN".to_string()]); + + let args = build_var_args(&dependent, None, None, &required); + assert_eq!(args, ""); + } + + #[test] + fn build_validate_command_omits_body_checksum_vars_for_vercel_like_http_rule() { + let validation = crate::rules::Validation::Http(crate::rules::HttpValidation { + request: crate::rules::HttpRequest { + method: "GET".to_string(), + url: "https://api.vercel.com/v2/user".to_string(), + headers: BTreeMap::from([( + "Authorization".to_string(), + "Bearer {{TOKEN}}".to_string(), + )]), + body: None, + response_matcher: None, + multipart: None, + response_is_html: false, + }, + multipart: None, + }); + let dependent = BTreeMap::from([ + ("BODY".to_string(), "payload-part".to_string()), + ("CHECKSUM".to_string(), "abc123".to_string()), + ]); + + let cmd = build_validate_command( + "kingfisher.vercel.1", + &validation, + "vcp_testtoken", + &dependent, + None, + None, + ) + .expect("validate command should be generated"); + + assert!(!cmd.contains("--var BODY="), "command should not include BODY var: {}", cmd); + assert!( + !cmd.contains("--var CHECKSUM="), + "command should not include CHECKSUM var: {}", + cmd + ); + assert!(cmd.contains("kingfisher validate --rule kingfisher.vercel.1")); + } + fn sample_scan_args() -> ScanArgs { ScanArgs { num_jobs: 1, diff --git a/src/validation.rs b/src/validation.rs index 82afedc..d8990db 100644 --- a/src/validation.rs +++ b/src/validation.rs @@ -675,7 +675,7 @@ async fn timed_validate_single_match<'a>( Ok(resp) => { let status = resp.status(); let headers = resp.headers().clone(); - let mut body = match resp.text().await { + let body = match resp.text().await { Ok(b) => b, Err(e) => { m.validation_success = false; @@ -688,10 +688,13 @@ async fn timed_validate_single_match<'a>( return; } }; - truncate_to_char_boundary(&mut body, MAX_VALIDATION_BODY_LEN); + // Validate against the full response body, but keep a truncated preview for + // reporting/storage to avoid huge outputs. + let mut display_body = body.clone(); + truncate_to_char_boundary(&mut display_body, MAX_VALIDATION_BODY_LEN); m.validation_response_status = status; - let body_opt = validation_body::from_string(body.clone()); + let body_opt = validation_body::from_string(display_body.clone()); m.validation_response_body = body_opt.clone(); let matchers = match http_validation.request.response_matcher.as_ref() { Some(m) => m,