- Fixed issue when more than 1 named capture group is used in a rule variable

- Added 2 new liquid template filters: 'b64dec' and 'es256_sign'
- Added custom validator for Coinbase, and a Coinbase rule that uses it
This commit is contained in:
Mick Grove 2025-07-31 16:49:46 -07:00
commit e73aec9d70
11 changed files with 369 additions and 22 deletions

View file

@ -2,6 +2,11 @@
All notable changes to this project will be documented in this file.
## [1.29.0]
- Fixed issue when more than 1 named capture group is used in a rule variable
- Added 2 new liquid template filters: `b64dec` and `es256_sign`
- Added custom validator for Coinbase, and a Coinbase rule that uses it
## [1.28.0]
- Added support for scanning Slack

View file

@ -188,6 +188,9 @@ ipnet = "2.11.0"
jira_query = "1.6.0"
oci-client = { version = "0.15", default-features = false, features = ["rustls-tls"] }
walkdir = "2.5.0"
p256 = "0.13.2"
sec1 = "0.7.3"
rand_core = "0.9.3"
[dependencies.tikv-jemallocator]
version = "0.6"

67
data/rules/coinbase.yml Normal file
View file

@ -0,0 +1,67 @@
rules:
- name: Coinbase Access Token
id: kingfisher.coinbase.1
pattern: |
(?xi)
\b
coinbase
(?:.|[\n\r]){0,16}?
(?:SECRET|PRIVATE|ACCESS|KEY|TOKEN)
(?:.|[\n\r]){0,16}?
\b
(
[a-zA-Z-0-9]{32}
)
\b
min_entropy: 3.5
examples:
- coinbase_token = 32iAkQCcHHYxXGx20VogBZoj27PC1ouI
references:
- https://docs.cloud.coinbase.com/wallet-sdk/docs/api-keys
validation:
type: Http
content:
request:
method: GET
url: https://api.coinbase.com/v2/user
headers:
Authorization: "Bearer {{ TOKEN }}"
response_matcher:
- report_response: true
- type: StatusMatch
status: [200]
- name: Coinbase CDP API Key
id: kingfisher.coinbase.2
pattern: |
(?xims)
"name"\s*:\s*"
(?P<CRED_NAME>organizations/[0-9a-f-]{36}/apiKeys/[0-9a-f-]{36})"
.*"privateKey"\s*:\s*"
(?P<PRIVATE_KEY>
-----BEGIN\sEC\s{0,1}
PRIVATE\sKEY
(\sBLOCK)?
-----
[a-z0-9 /+=\r\n\\n]{32,}?
-----END\s
(?:
RSA |
PGP |
DSA |
OPENSSH |
ENCRYPTED |
EC
)?
\s{0,1}
PRIVATE\sKEY
(\sBLOCK)?
-----
)
validation:
type: Coinbase
examples:
- |
{
"name": "organizations/243873d8-c14e-436d-9cea-10d530cbe201/apiKeys/d29bb143-ad4c-234f-9bd7-c705c16b6d19",
"privateKey": "-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIDs+vr9F40Mer+qYksK3QhkSMfUXOZsbRVSrelWGnMh3oAoGCCqGSM49\nAwEHoUQDQgAEOXj2qKzLYx21D3plbOa81ilURS/4K1jzLXBvgwfUe4hWDgBdKQvq\nIiet5qqZEwVlR/LqKQEUlP8YLrjLFU8Unw==\n-----END EC PRIVATE KEY-----\n"
}

View file

@ -45,8 +45,7 @@ rules:
- name: Contains Private Key
id: kingfisher.privkey.2
pattern: |
(?xi)
(?ims)
(?xims)
(
-----BEGIN\s
(?:
@ -68,7 +67,8 @@ rules:
PGP |
DSA |
OPENSSH |
ENCRYPTED
ENCRYPTED |
EC
)?
\s{0,1}
PRIVATE\sKEY

View file

@ -107,6 +107,8 @@ Below is the complete list of Liquid filters available in Kingfisher, along with
| --------------------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
| `b64enc` | | Base64-encodes the input using the standard alphabet. | `{{ TOKEN \| b64enc }}` |
| `b64url_enc` | | URL-safe Base64 (no padding). Useful for JWT headers & payloads. | `{{ TOKEN \| b64url_enc }}` |
| `b64dec` | | Decodes a Base64 string. | `{{ "aGVsbG8=" \| b64dec }}` |
| `es256_sign` | `key` (string) | Signs the input with an ECDSA P-256 private key and returns a Base64URL signature. | `{{ "data" \| es256_sign: PRIVKEY }}` |
| `sha256` | | Computes the SHA-256 hex digest of the input. | `{{ TOKEN \| sha256 }}` |
| `hmac_sha1` | `key` (string) | Computes HMAC-SHA1 over the input, returns Base64-encoded result. | `{{ TOKEN \| hmac_sha1: "secret-key" }}` |
| `hmac_sha256` | `key` (string) | Computes HMAC-SHA256 over the input, returns Base64-encoded result. | `{{ TOKEN \| hmac_sha256: "secret-key" }}` |

View file

@ -6,6 +6,10 @@ use liquid_core::{
Display_filter, Error as LiquidError, Expression, Filter, FilterParameters, FilterReflection,
FromFilterParameters, ParseFilter, Result, Runtime, Value, ValueView,
};
use p256::ecdsa::{signature::Signer, SigningKey};
use p256::pkcs8::DecodePrivateKey;
use sec1::DecodeEcPrivateKey;
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
use rand::{distr::Alphanumeric, Rng};
use sha1::Sha1;
@ -267,6 +271,69 @@ impl Filter for B64EncFilter {
}
}
#[derive(Debug, Clone, Default, FilterReflection, ParseFilter)]
#[filter(name = "b64dec", description = "Decodes a Base64 string", parsed(B64DecFilter))]
pub struct B64DecFilter;
impl std::fmt::Display for B64DecFilter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "b64dec")
}
}
impl Filter for B64DecFilter {
fn evaluate(
&self,
input: &dyn ValueView,
_runtime: &dyn Runtime,
) -> Result<Value, LiquidError> {
let input_str = input.to_kstr();
match general_purpose::STANDARD.decode(input_str.as_bytes()) {
Ok(bytes) => Ok(Value::scalar(String::from_utf8_lossy(&bytes).to_string())),
Err(e) => Err(LiquidError::with_msg(e.to_string())),
}
}
}
#[derive(Debug, FilterParameters)]
struct Es256Args {
#[parameter(description = "PEM EC private key", arg_type = "str")]
key: Expression,
}
#[derive(Clone, ParseFilter, FilterReflection, Default)]
#[filter(
name = "es256_sign",
description = "ECDSA P-256 SHA-256 signature (Base64URL)",
parameters(Es256Args),
parsed(Es256SignFilter)
)]
pub struct Es256Sign;
#[derive(Debug, FromFilterParameters, Display_filter)]
#[name = "es256_sign"]
struct Es256SignFilter {
#[parameters]
args: Es256Args,
}
impl Filter for Es256SignFilter {
fn evaluate(&self, input: &dyn ValueView, runtime: &dyn Runtime) -> Result<Value, LiquidError> {
let args = self.args.evaluate(runtime)?;
let key_pem = args.key.to_kstr();
let signing_key = SigningKey::from_sec1_pem(&key_pem)
.or_else(|_| SigningKey::from_pkcs8_pem(&key_pem))
.map_err(|e| LiquidError::with_msg(e.to_string()))?;
let sig: p256::ecdsa::Signature = signing_key.sign(input.to_kstr().as_bytes());
// turn the signature into raw bytes…
let raw = sig.to_bytes();
// …then Base64-URL encode
let b64 = general_purpose::URL_SAFE_NO_PAD.encode(raw);
Ok(Value::scalar(b64))
}
}
// -----------------------------------------------------------------------------
// Authentication & Security
// -----------------------------------------------------------------------------
@ -388,6 +455,8 @@ pub fn register_all(builder: liquid::ParserBuilder) -> liquid::ParserBuilder {
.filter(UuidFilter::default())
.filter(JwtHeaderFilter::default())
.filter(B64EncFilter::default())
.filter(B64DecFilter::default())
.filter(Es256Sign::default())
.filter(RandomStringFilter::default())
.filter(HmacSha256::default())
.filter(HmacSha1::default())
@ -424,6 +493,11 @@ mod tests {
assert_eq!(render(r#"{{ "hello" | b64enc }}"#), "aGVsbG8=");
}
#[test]
fn b64dec_filter() {
assert_eq!(render(r#"{{ "aGVsbG8=" | b64dec }}"#), "hello");
}
#[test]
fn sha256_filter() {
let expect = format!("{:x}", Sha256::digest(b"hello"));
@ -441,6 +515,18 @@ mod tests {
assert_eq!(render(r#"{{ "data" | hmac_sha1: "key1" }}"#), expect);
}
#[test]
fn es256_sign_filter() {
let key = "-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIDs+vr9F40Mer+qYksK3QhkSMfUXOZsbRVSrelWGnMh3oAoGCCqGSM49\nAwEHoUQDQgAEOXj2qKzLYx21D3plbOa81ilURS/4K1jzLXBvgwfUe4hWDgBdKQvq\nIiet5qqZEwVlR/LqKQEUlP8YLrjLFU8Unw==\n-----END EC PRIVATE KEY-----";
use p256::ecdsa::{signature::Signer, SigningKey};
let sk = SigningKey::from_sec1_pem(key).unwrap();
let sig: p256::ecdsa::Signature = sk.sign(b"hello");
let expect = general_purpose::URL_SAFE_NO_PAD.encode(sig.to_bytes());
let tmpl = format!(r#"{{ "hello" | es256_sign: "{}" }}"#, key.replace('\n', "\\n"));
assert_eq!(render(&tmpl), expect);
}
#[test]
fn b64url_enc_filter() {
assert_eq!(

View file

@ -5,20 +5,24 @@
// * Fallback - system allocator (`system-alloc` feature)
// ────────────────────────────────────────────────────────────
// --- jemalloc (opt-in) ---
#[cfg(feature = "use-jemalloc")]
#[global_allocator]
static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;
// // --- jemalloc (opt-in) ---
// #[cfg(feature = "use-jemalloc")]
// #[global_allocator]
// static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;
// --- mimalloc (default) ---
#[cfg(all(not(feature = "use-jemalloc"), not(feature = "system-alloc")))]
#[global_allocator]
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
// // --- mimalloc (default) ---
// #[cfg(all(not(feature = "use-jemalloc"), not(feature = "system-alloc")))]
// #[global_allocator]
// static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
// // --- system allocator (explicit opt-out) ---
// #[cfg(feature = "system-alloc")]
// use std::alloc::System;
// #[cfg(feature = "system-alloc")]
// #[global_allocator]
// static GLOBAL: System = System;
// --- system allocator (explicit opt-out) ---
#[cfg(feature = "system-alloc")]
use std::alloc::System;
#[cfg(feature = "system-alloc")]
#[global_allocator]
static GLOBAL: System = System;

View file

@ -35,6 +35,7 @@ fn default_true() -> bool {
pub enum Validation {
AWS,
AzureStorage,
Coinbase,
GCP,
MongoDB,
Postgres,

View file

@ -25,6 +25,7 @@ use crate::{
mod aws;
mod azure;
mod coinbase;
mod gcp;
mod httpvalidation;
mod jwt;
@ -254,7 +255,7 @@ async fn timed_validate_single_match<'a>(
if !missing.is_empty() {
m.validation_success = false;
m.validation_response_body =
format!("Validation skipped missing dependent rules: {}", missing.join(", "));
format!("Validation skipped - missing dependent rules: {}", missing.join(", "));
m.validation_response_status = StatusCode::PRECONDITION_REQUIRED;
commit_and_return(m);
return;
@ -828,7 +829,43 @@ async fn timed_validate_single_match<'a>(
},
);
}
// ----------------------------------------------------- Coinbase validator
Some(Validation::Coinbase) => {
let cred_name = globals
.get("CRED_NAME")
.and_then(|v| v.as_scalar())
.map(|s| s.into_owned().to_kstr().to_string())
.unwrap_or_default();
let private_key = globals
.get("PRIVATE_KEY")
.and_then(|v| v.as_scalar())
.map(|s| s.into_owned().to_kstr().to_string())
.unwrap_or_default();
if cred_name.is_empty() || private_key.is_empty() {
m.validation_success = false;
m.validation_response_body = "Missing key name or private key.".to_string();
m.validation_response_status = StatusCode::BAD_REQUEST;
commit_and_return(m);
return;
}
match coinbase::validate_cdp_api_key(&cred_name, &private_key, client, parser, cache)
.await
{
Ok((ok, msg)) => {
m.validation_success = ok;
m.validation_response_body = msg;
m.validation_response_status =
if ok { StatusCode::OK } else { StatusCode::UNAUTHORIZED };
}
Err(e) => {
m.validation_success = false;
m.validation_response_body = format!("Coinbase validation error: {}", e);
m.validation_response_status = StatusCode::BAD_GATEWAY;
}
}
}
// --------------------------------------------------------- Raw / none
Some(Validation::Raw(raw)) => {
debug!("Raw validation not implemented: {}", raw);

113
src/validation/coinbase.rs Normal file
View file

@ -0,0 +1,113 @@
use std::collections::BTreeMap;
use std::time::Duration;
use anyhow::{anyhow, Result};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use chrono::Utc;
use p256::{
ecdsa::{signature::Signer, SigningKey},
pkcs8::DecodePrivateKey,
SecretKey,
};
use rand::TryRngCore;
use rand::rngs::OsRng;
use reqwest::{Client, StatusCode, Url};
use sha1::{Digest, Sha1};
use crate::validation::{httpvalidation, Cache, CachedResponse, VALIDATION_CACHE_SECONDS};
pub fn generate_coinbase_cache_key(cred_name: &str, private_key: &str) -> String {
let mut h = Sha1::new();
h.update(cred_name.as_bytes());
h.update(b"\0");
h.update(private_key.as_bytes());
format!("COINBASE:{:x}", h.finalize())
}
pub async fn validate_cdp_api_key(
cred_name: &str,
private_key_pem: &str,
client: &Client,
parser: &liquid::Parser,
cache: &Cache,
) -> Result<(bool, String)> {
let cache_key = generate_coinbase_cache_key(cred_name, private_key_pem);
if let Some(entry) = cache.get(&cache_key) {
let c = entry.value();
if c.timestamp.elapsed() < Duration::from_secs(VALIDATION_CACHE_SECONDS) {
return Ok((c.is_valid, c.body.clone()));
}
}
let jwt = build_jwt("GET", "api.coinbase.com", "/v2/user", cred_name, private_key_pem)?;
let url = Url::parse("https://api.coinbase.com/v2/user")?;
let headers = BTreeMap::from([("Authorization".to_string(), format!("Bearer {}", jwt))]);
let rb = httpvalidation::build_request_builder(
client,
"GET",
&url,
&headers,
&None,
parser,
&liquid::Object::new(),
)
.map_err(|e| anyhow!(e))?;
let resp =
httpvalidation::retry_request(rb, 1, Duration::from_millis(500), Duration::from_secs(2))
.await?;
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
let ok = status == StatusCode::OK;
let msg = format!("{body}");
cache.insert(cache_key.clone(), CachedResponse::new(msg.clone(), status, ok));
Ok((ok, msg))
}
fn build_jwt(
method: &str,
host: &str,
endpoint: &str,
cred_name: &str,
pem: &str,
) -> Result<String> {
let pem =
pem.replace("\r\n", "\n").replace("\\r\\n", "\n").replace("\\n", "\n").replace("\r", "\n");
let secret_key = SecretKey::from_sec1_pem(&pem)
.or_else(|_| SecretKey::from_pkcs8_pem(&pem))
.map_err(|e| anyhow!("invalid EC key: {e}"))?;
let signing_key = SigningKey::from(secret_key);
let mut rng = OsRng;
let mut nonce = [0u8; 16];
let _ = rng.try_fill_bytes(&mut nonce);
let header = serde_json::json!({
"typ": "JWT",
"alg": "ES256",
"kid": cred_name,
"nonce": hex::encode(nonce),
});
let header_b64 = URL_SAFE_NO_PAD.encode(header.to_string());
let now = Utc::now().timestamp();
let claims = serde_json::json!({
"sub": cred_name,
"iss": "cdp",
"nbf": now,
"exp": now + 60,
"uri": format!("{} {}{}", method, host, endpoint),
});
let claims_b64 = URL_SAFE_NO_PAD.encode(claims.to_string());
let signing_input = format!("{header_b64}.{claims_b64}");
let sig: p256::ecdsa::Signature = signing_key.sign(signing_input.as_bytes());
let sig_b64 = URL_SAFE_NO_PAD.encode(sig.to_bytes());
Ok(format!("{signing_input}.{sig_b64}"))
}

View file

@ -2,19 +2,47 @@ use reqwest::Url;
use tokio::net::lookup_host;
use crate::validation::SerializableCaptures;
pub fn process_captures(captures: &SerializableCaptures) -> Vec<(String, String, usize, usize)> {
let has_multiple_captures = captures.captures.len() > 1;
// pub fn process_captures(captures: &SerializableCaptures) -> Vec<(String, String, usize, usize)> {
// let has_multiple_captures = captures.captures.len() > 1;
// captures
// .captures
// .iter()
// .enumerate()
// .filter(|(idx, _)| !has_multiple_captures || *idx > 0)
// .map(|(_, capture)| {
// let name = capture.name.as_ref().map_or("TOKEN".to_string(), |n| n.to_uppercase());
// (name, capture.value.clone().into_owned(), capture.start, capture.end)
// })
// .collect()
// }
/// Return (NAME, value, start, end) for every capture we care about.
///
/// * If a capture has a name, use that (upper-cased)
/// * If its unnamed, fall back to `"TOKEN"`
/// * Skip the unnamed “whole-match” capture **only when** there are
/// additional captures to return.
pub fn process_captures(
captures: &SerializableCaptures,
) -> Vec<(String, String, usize, usize)> {
let multiple = captures.captures.len() > 1;
captures
.captures
.iter()
.enumerate()
.filter(|(idx, _)| !has_multiple_captures || *idx > 0)
.map(|(_, capture)| {
let name = capture.name.as_ref().map_or("TOKEN".to_string(), |n| n.to_uppercase());
(name, capture.value.clone().into_owned(), capture.start, capture.end)
.filter(|cap| multiple.then(|| cap.name.is_some()).unwrap_or(true))
.map(|cap| {
let name = cap
.name
.as_ref()
.map(|n| n.to_uppercase())
.unwrap_or_else(|| "TOKEN".to_string());
(name, cap.value.clone().into_owned(), cap.start, cap.end)
})
.collect()
}
pub fn find_closest_variable(
captures: &[(String, String, usize, usize)],
target_value: &String,
@ -47,6 +75,7 @@ pub fn find_closest_variable(
}
closest_value
}
pub async fn check_url_resolvable(url: &Url) -> Result<(), Box<dyn std::error::Error>> {
let host = url.host_str().ok_or("No host in URL")?;
let port = url.port().unwrap_or(if url.scheme() == "https" { 443 } else { 80 });