From 1ee9e804b0e415625879a392dc0e2b7d0807bf98 Mon Sep 17 00:00:00 2001 From: Mick Grove Date: Sat, 8 Nov 2025 16:01:58 -0800 Subject: [PATCH] updated confluent rule with a checksum. Added zuplo rule with a checksum --- data/rules/confluent.yml | 37 ++++++ data/rules/zuplo.yml | 22 ++++ docs/RULES.md | 6 +- src/liquid_filters.rs | 242 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 306 insertions(+), 1 deletion(-) create mode 100644 data/rules/zuplo.yml diff --git a/data/rules/confluent.yml b/data/rules/confluent.yml index ce8b003..49e9b69 100644 --- a/data/rules/confluent.yml +++ b/data/rules/confluent.yml @@ -52,6 +52,43 @@ rules: - 200 type: StatusMatch url: https://api.confluent.cloud/iam/v2/api-keys/{{ CLIENTID }} + depends_on_rule: + - rule_id: "kingfisher.confluent.1" + variable: CLIENTID + - name: Confluent API Secret - Updated Format + id: kingfisher.confluent.3 + pattern: | + (?xi) + \b + ( + cflt(?P[A-Za-z0-9\+/]{54})(?P[A-Za-z0-9\+/]{6}) + ) + pattern_requirements: + checksum: + actual: + template: "{{ MATCH | suffix: 6 }}" + requires_capture: checksum + expected: "{{ BODY | crc32_le_b64: 6 }}" + skip_if_missing: true + min_entropy: 3.3 + confidence: medium + examples: + - confluent secret=cfltqPLd2lLPAtWtHGNhN32WlZxoEj30pcg8mzaPlPJ937JlMa7n9YCRLooqgifw + references: + - https://docs.confluent.io/cloud/current/api.html#tag/API-Keys-(iamv2)/operation/getIamV2ApiKey + validation: + type: Http + content: + request: + headers: + Authorization: 'Basic {{ CLIENTID | append: ":" | append: TOKEN | b64enc }}' + method: GET + response_matcher: + - report_response: true + - status: + - 200 + type: StatusMatch + url: https://api.confluent.cloud/iam/v2/api-keys/{{ CLIENTID }} depends_on_rule: - rule_id: "kingfisher.confluent.1" variable: CLIENTID \ No newline at end of file diff --git a/data/rules/zuplo.yml b/data/rules/zuplo.yml new file mode 100644 index 0000000..22ed4c1 --- /dev/null +++ b/data/rules/zuplo.yml @@ -0,0 +1,22 @@ +rules: + - name: Zuplo API Key + id: kingfisher.zuplo.1 + pattern: | + (?xi) + \b + ( + zpka_(?P[a-z0-9]{32})_(?P[0-9a-f]{8}) + ) + pattern_requirements: + checksum: + actual: + template: "{{ CHECKSUM | downcase }}" + requires_capture: checksum + expected: "{{ BODY | crc32_hex }}" + min_entropy: 3.3 + confidence: medium + examples: + - zpka_3e6c4f7d39954ca29353b7ab88589b64_de26cd55 + - zpka_b3f94d8d3d4d4a6ea5c5b20d0a5bb407_18eb262b + references: + - https://zuplo.com/blog/api-key-authentication diff --git a/docs/RULES.md b/docs/RULES.md index 262f66e..9e4d8e5 100644 --- a/docs/RULES.md +++ b/docs/RULES.md @@ -119,11 +119,15 @@ Below is the complete list of Liquid filters available in Kingfisher, along with | `b64url_enc` | – | URL-safe Base64 (no padding). Useful for JWT headers & payloads. | `{{ TOKEN \| b64url_enc }}` | | `b64dec` | – | Decodes a Base64 string. | `{{ "aGVsbG8=" \| b64dec }}` | | `sha256` | – | Computes the SHA-256 hex digest of the input. | `{{ TOKEN \| sha256 }}` | -| `crc32` | – | Computes the CRC32 checksum of the input and returns a decimal value. | `{{ TOKEN \| crc32 }}` | +| `crc32` | – | Computes the CRC32 checksum of the input and returns a decimal value. | `{{ TOKEN \| crc32 }}` | +| `crc32_dec` | `digits` (integer, optional) | Computes the CRC32 checksum and returns the last `digits` decimal characters (zero-padded). Defaults to the full value when omitted. | `{{ TOKEN \| crc32_dec: 6 }}` | +| `crc32_hex` | `digits` (integer, optional) | Computes the CRC32 checksum and returns the last `digits` hexadecimal characters (zero-padded). Defaults to the full value when omitted. | `{{ TOKEN \| crc32_hex: 8 }}` | +| `crc32_le_b64` | `len` (integer, optional) | Computes the CRC32 checksum, encodes the little-endian bytes using Base64, and optionally truncates to the first `len` characters. | `{{ TOKEN \| crc32_le_b64: 6 }}` | | `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" }}` | | `hmac_sha384` | `key` (string) | Computes HMAC-SHA384 over the input, returns Base64-encoded result. | `{{ TOKEN \| hmac_sha384: "secret-key" }}` | | `random_string` | `len` (integer, optional) | Generates a cryptographically-secure random alphanumeric string of the specified length (default: 32). | `{{ "" \| random_string: 16 }}` | +| `prefix` | `len` (integer, optional) | Returns the first `len` characters from the string (default: full). | `{{ TOKEN \| prefix: 6 }}` | | `suffix` | `len` (integer, optional) | Returns the last `len` characters from the string (default: full). | `{{ TOKEN \| suffix: 6 }}` | | `base62` | `width` (integer, optional) | Encodes the input number as Base62, left-padding with zeros as needed. | `{{ TOKEN \| crc32 \| base62: 6 }}` | | `url_encode` | – | Percent-encodes the input according to RFC 3986. | `{{ TOKEN \| url_encode }}` | diff --git a/src/liquid_filters.rs b/src/liquid_filters.rs index 5f02b9b..9112fb6 100644 --- a/src/liquid_filters.rs +++ b/src/liquid_filters.rs @@ -309,6 +309,49 @@ impl Filter for Suffix { } } +#[derive(Debug, FilterParameters)] +struct PrefixArgs { + #[parameter(description = "Number of leading characters to keep", arg_type = "integer")] + len: Option, +} + +#[derive(Clone, ParseFilter, FilterReflection, Default)] +#[filter( + name = "prefix", + description = "Return the prefix (first N characters) of the provided string.", + parameters(PrefixArgs), + parsed(Prefix) +)] +pub struct PrefixFilter; + +#[derive(Debug, FromFilterParameters, Display_filter)] +#[name = "prefix"] +struct Prefix { + #[parameters] + args: PrefixArgs, +} + +impl Filter for Prefix { + fn evaluate(&self, input: &dyn ValueView, runtime: &dyn Runtime) -> Result { + let args = self.args.evaluate(runtime)?; + let text = input.to_kstr(); + let requested = args + .len + .and_then(|value| { + let scalar = Value::scalar(value); + value_to_usize(&scalar) + }) + .unwrap_or_else(|| text.len()); + if requested == 0 { + return Ok(Value::scalar(String::new())); + } + + let mut chars: Vec = text.chars().collect(); + chars.truncate(requested.min(chars.len())); + Ok(Value::scalar(chars.into_iter().collect::())) + } +} + #[derive(Debug, Clone, Default, FilterReflection, ParseFilter)] #[filter( name = "b64enc", @@ -387,6 +430,175 @@ static_filter!( } ); +#[derive(Debug, FilterParameters)] +struct Crc32DecArgs { + #[parameter( + description = "Number of trailing decimal digits to return (zero padded)", + arg_type = "integer" + )] + digits: Option, +} + +#[derive(Clone, ParseFilter, FilterReflection, Default)] +#[filter( + name = "crc32_dec", + description = "Compute the CRC32 and optionally return the last N decimal digits.", + parameters(Crc32DecArgs), + parsed(Crc32Dec) +)] +pub struct Crc32DecFilter; + +#[derive(Debug, FromFilterParameters, Display_filter)] +#[name = "crc32_dec"] +struct Crc32Dec { + #[parameters] + args: Crc32DecArgs, +} + +impl Filter for Crc32Dec { + fn evaluate(&self, input: &dyn ValueView, runtime: &dyn Runtime) -> Result { + let args = self.args.evaluate(runtime)?; + let mut hasher = Hasher::new(); + hasher.update(input.to_kstr().as_bytes()); + let checksum = u128::from(hasher.finalize()); + + let digits = args + .digits + .and_then(|value| { + let scalar = Value::scalar(value); + value_to_usize(&scalar) + }) + .unwrap_or(0); + + if digits == 0 { + return Ok(Value::scalar(checksum.to_string())); + } + + let clamped_digits = digits.min(38); // 10^38 fits within u128 + let modulus = 10u128.pow(clamped_digits as u32); + let truncated = checksum % modulus; + let mut value = truncated.to_string(); + if clamped_digits > value.len() { + let mut padded = String::with_capacity(clamped_digits); + for _ in 0..(clamped_digits - value.len()) { + padded.push('0'); + } + padded.push_str(&value); + value = padded; + } + + Ok(Value::scalar(value)) + } +} + +#[derive(Debug, FilterParameters)] +struct Crc32HexArgs { + #[parameter( + description = "Number of trailing hexadecimal digits to return (zero padded)", + arg_type = "integer" + )] + digits: Option, +} + +#[derive(Clone, ParseFilter, FilterReflection, Default)] +#[filter( + name = "crc32_hex", + description = "Compute the CRC32 and optionally return the last N hexadecimal digits.", + parameters(Crc32HexArgs), + parsed(Crc32Hex) +)] +pub struct Crc32HexFilter; + +#[derive(Debug, FromFilterParameters, Display_filter)] +#[name = "crc32_hex"] +struct Crc32Hex { + #[parameters] + args: Crc32HexArgs, +} + +impl Filter for Crc32Hex { + fn evaluate(&self, input: &dyn ValueView, runtime: &dyn Runtime) -> Result { + let args = self.args.evaluate(runtime)?; + let mut hasher = Hasher::new(); + hasher.update(input.to_kstr().as_bytes()); + let checksum = hasher.finalize(); + let mut hex = format!("{checksum:08x}"); + + let digits = args + .digits + .and_then(|value| { + let scalar = Value::scalar(value); + value_to_usize(&scalar) + }) + .unwrap_or(0); + + if digits == 0 { + return Ok(Value::scalar(hex)); + } + + let clamped = digits.min(32); + if clamped > hex.len() { + let mut padded = String::with_capacity(clamped); + for _ in 0..(clamped - hex.len()) { + padded.push('0'); + } + padded.push_str(&hex); + hex = padded; + } else { + let start = hex.len() - clamped; + hex = hex[start..].to_string(); + } + + Ok(Value::scalar(hex)) + } +} + +#[derive(Debug, FilterParameters)] +struct Crc32LeB64Args { + #[parameter( + description = "Number of leading characters from the Base64 string to keep", + arg_type = "integer" + )] + len: Option, +} + +#[derive(Clone, ParseFilter, FilterReflection, Default)] +#[filter( + name = "crc32_le_b64", + description = "Compute the CRC32, encode little-endian bytes as Base64, optionally truncating.", + parameters(Crc32LeB64Args), + parsed(Crc32LeB64) +)] +pub struct Crc32LeB64Filter; + +#[derive(Debug, FromFilterParameters, Display_filter)] +#[name = "crc32_le_b64"] +struct Crc32LeB64 { + #[parameters] + args: Crc32LeB64Args, +} + +impl Filter for Crc32LeB64 { + fn evaluate(&self, input: &dyn ValueView, runtime: &dyn Runtime) -> Result { + let args = self.args.evaluate(runtime)?; + let mut hasher = Hasher::new(); + hasher.update(input.to_kstr().as_bytes()); + let checksum = hasher.finalize(); + let encoded = general_purpose::STANDARD.encode(checksum.to_le_bytes()); + + let output = if let Some(len) = args.len.and_then(|value| { + let scalar = Value::scalar(value); + value_to_usize(&scalar) + }) { + encoded.chars().take(len).collect::() + } else { + encoded + }; + + Ok(Value::scalar(output)) + } +} + #[derive(Debug, FilterParameters)] struct Base62Args { #[parameter( @@ -590,7 +802,11 @@ pub fn register_all(builder: liquid::ParserBuilder) -> liquid::ParserBuilder { .filter(B64DecFilter::default()) .filter(RandomStringFilter::default()) .filter(SuffixFilter::default()) + .filter(PrefixFilter::default()) .filter(Crc32Filter::default()) + .filter(Crc32DecFilter::default()) + .filter(Crc32HexFilter::default()) + .filter(Crc32LeB64Filter::default()) .filter(Base62Filter::default()) .filter(HmacSha256::default()) .filter(HmacSha1::default()) @@ -645,6 +861,13 @@ mod tests { assert_eq!(render(r#"{{ "value" | suffix: 0 }}"#), ""); } + #[test] + fn prefix_filter() { + assert_eq!(render(r#"{{ "abcdef" | prefix: 3 }}"#), "abc"); + assert_eq!(render(r#"{{ "short" | prefix: 10 }}"#), "short"); + assert_eq!(render(r#"{{ "value" | prefix: 0 }}"#), ""); + } + #[test] fn crc32_and_base62_filters() { assert_eq!(render(r#"{{ "hello" | crc32 }}"#), "907060870"); @@ -652,6 +875,25 @@ mod tests { assert_eq!(render(r#"{{ "hello" | crc32 | base62: 6 }}"#), "0zNvy2"); } + #[test] + fn crc32_dec_filter() { + assert_eq!(render(r#"{{ "hello" | crc32_dec }}"#), "907060870"); + assert_eq!(render(r#"{{ "hello" | crc32_dec: 6 }}"#), "060870"); + } + + #[test] + fn crc32_hex_filter() { + assert_eq!(render(r#"{{ "hello" | crc32_hex }}"#), "3610a686"); + assert_eq!(render(r#"{{ "hello" | crc32_hex: 4 }}"#), "a686"); + assert_eq!(render(r#"{{ "hello" | crc32_hex: 10 }}"#), "003610a686"); + } + + #[test] + fn crc32_le_b64_filter() { + assert_eq!(render(r#"{{ "hello" | crc32_le_b64 }}"#), "hqYQNg=="); + assert_eq!(render(r#"{{ "hello" | crc32_le_b64: 6 }}"#), "hqYQNg"); + } + #[test] fn hmac_sha1_filter() { let key = b"key1";