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,