- 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.
This commit is contained in:
Mick Grove 2026-02-11 13:56:17 -08:00
commit 4ab5932d57
5 changed files with 455 additions and 14 deletions

View file

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

View file

@ -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<body>[A-Za-z0-9_-]{50})(?P<checksum>[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<body>[A-Za-z0-9_-]{50})(?P<checksum>[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<body>[A-Za-z0-9_-]{50})(?P<checksum>[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<body>[A-Za-z0-9_-]{50})(?P<checksum>[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<body>[A-Za-z0-9_-]{50})(?P<checksum>[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"

View file

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

View file

@ -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<String> {
// 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<String> {
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<String> {
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<String, String>,
akid_from_captures: Option<&str>,
akid_from_validation_body: Option<&str>,
required_vars: &BTreeSet<String>,
) -> 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<String> {
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<String> {
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,

View file

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