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";