diff --git a/CHANGELOG.md b/CHANGELOG.md index a4c8196..3e68beb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,9 @@ All notable changes to this project will be documented in this file. ## [1.20.0] - Removed confirmation prompt when user provides --self-update flag - Added support for HTTP request bodies in rule validation +- Added new liquid-rs filters: HmacSha1, IsoTimestampNoFracFilter, Replace - Added rules for mistral, perplexity +- Added validation for Alibaba rule ## [1.19.0] - JSON output was missing committer name and email diff --git a/data/rules/alibaba.yml b/data/rules/alibaba.yml index 3c2c4aa..b4807ec 100644 --- a/data/rules/alibaba.yml +++ b/data/rules/alibaba.yml @@ -10,6 +10,7 @@ rules: \b min_entropy: 4.0 confidence: medium + visible: false examples: - LTAI8x2NiGqfyJGx7eLDhp12 - LTAI5GqyJGhp12ad31L5hpix @@ -36,22 +37,22 @@ rules: request: method: GET url: > - {% assign nonce = "" | uuid | url_encode -%} - {% assign ts = "" | iso_timestamp | url_encode -%} - {% capture qs -%} - AccessKeyId={{ AKID }}& - Action=GetCallerIdentity& - Format=JSON& - SignatureMethod=HMAC-SHA1& - SignatureNonce={{ nonce }}& - SignatureVersion=1.0& - Timestamp={{ ts }}& - Version=2015-04-01 - {%- endcapture %} - {% capture sts -%}GET&%2F&{{ qs | url_encode }}{%- endcapture %} - {% assign key = TOKEN | append: '&' -%} - {% assign sig = sts | hmac_sha256: key | b64enc | url_encode -%} - https://sts.aliyuncs.com/?{{ qs }}&Signature={{ sig }} + {%- assign nonce = "" | uuid | upcase -%} + {%- assign raw_timestamp = "" | iso_timestamp_no_frac -%} + {%- assign timestamp = raw_timestamp | replace: ":", "%3A" -%} + + {%- capture params -%} + AccessKeyId={{ AKID | url_encode }}&Action=GetCallerIdentity&Format=JSON&SignatureMethod=HMAC-SHA1&SignatureNonce={{ nonce }}&SignatureVersion=1.0&Timestamp={{ timestamp }}&Version=2015-04-01 + {%- endcapture -%} + {%- assign encoded_params = params | replace: "+", "%20" | replace: "*", "%2A" | replace: "%7E", "~" -%} + {%- assign query_string = encoded_params | url_encode | replace: "%2D", "-" | replace: "%2E", "." -%} + + {%- assign signature_base_string = "GET&%2F&" | append: query_string -%} + {%- assign token_amp = TOKEN | append: "&" -%} + + {%- assign hmacsignature = signature_base_string | hmac_sha1: token_amp | url_encode -%} + + https://sts.aliyuncs.com/?{{ params }}&Signature={{ hmacsignature }} headers: Accept: application/json response_matcher: @@ -60,7 +61,6 @@ rules: status: [200] - type: WordMatch words: ['"Arn"'] - match_all_words: true depends_on_rule: - rule_id: kingfisher.alibabacloud.1 variable: AKID \ No newline at end of file diff --git a/docs/RULES.md b/docs/RULES.md index 7b0650d..4f89e20 100644 --- a/docs/RULES.md +++ b/docs/RULES.md @@ -84,7 +84,51 @@ rules: | **XmlValid** | – | Pass only if body parses as well-formed XML. Use when response is expected as XML data | | **ReportResponse** | `report_response` (bool) | Include raw payload in finding for debugging. | +## 2. Templating with Liquid +Kingfisher leverages the Liquid template engine for dynamic parts of HTTP request bodies, headers, query parameters, and multipart payloads. The engine supports both built-in and custom filters to manipulate the captured secret (TOKEN) or other named captures ({{ NAME }}). +### Using Liquid Filters in Validation +- **Capture Injection**: The unnamed capture from your regex becomes {{ TOKEN }}. Named captures are made available as uppercase variables (e.g. {{ RDMVAL }}). +- **Filter Pipeline**: You can chain filters using the pipe (|) syntax: + +```liquid +{{ TOKEN | b64enc | url_encode }} +``` +Arguments: Some filters accept parameters, provided after a colon: + +```liquid +{{ TOKEN | hmac_sha256: "my-secret-key" }} +``` + +### 3. Built-in & Custom Liquid Filters + +Below is the complete list of Liquid filters available in Kingfisher, along with their usage patterns and examples. +| Filter | Parameters | Description | Example | +| --------------------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | +| `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 }}` | +| `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" }}` | +| `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 }}` | +| `url_encode` | – | Percent-encodes the input according to RFC 3986. | `{{ TOKEN \| url_encode }}` | +| `json_escape` | – | Escapes special characters so a string can be safely injected into JSON contexts. | `{{ TOKEN \| json_escape }}` | +| `unix_timestamp` | – | Returns the current Unix epoch time in seconds (UTC). | `{{ "" \| unix_timestamp }}` | +| `iso_timestamp` | – | Returns the current UTC timestamp in full ISO-8601 format (may include fractional seconds). | `{{ "" \| iso_timestamp }}` | +| `iso_timestamp_no_frac` | – | Current ISO-8601 timestamp (UTC) **without** fractional seconds. | `{{ "" \| iso_timestamp_no_frac }}` | +| `uuid` | – | Generates a random UUIDv4 string. | `{{ "" \| uuid }}` | +| `jwt_header` | – | Builds a minimal JWT header JSON (`{"typ":"JWT","alg":…}`) and Base64URL-encodes it. | `{{ "HS256" \| jwt_header }}` | +| `replace` | `from` (string), `to` (string) | Replaces every occurrence of `from` with `to` in the input string. | `{{ "hello world" \| replace: "world", "mars" }}` | + + +**Chaining & Composition:** Filters can be stacked; e.g.: + +```liquid +Authorization: Basic {{ "api:" | append: TOKEN | b64enc }} +``` + +**Runtime Values:** Filters like unix_timestamp and uuid are evaluated at runtime, enabling nonces, timestamps, and unique IDs in your requests. ### How depends_on_rule Works - **Dependency Declaration:** diff --git a/src/liquid_filters.rs b/src/liquid_filters.rs index f546ad7..301ba57 100644 --- a/src/liquid_filters.rs +++ b/src/liquid_filters.rs @@ -9,6 +9,7 @@ use liquid_core::{ use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; use rand::{distr::Alphanumeric, Rng}; use sha2::{Digest, Sha256, Sha384}; +use sha1::Sha1; use time::{format_description::well_known::Iso8601, OffsetDateTime}; use uuid::Uuid; @@ -72,6 +73,40 @@ macro_rules! static_filter { }; } +#[derive(Debug, FilterParameters)] +struct ReplaceArgs { + #[parameter(description = "The substring to search for.", arg_type = "str")] + from: Expression, + #[parameter(description = "The string to replace it with.", arg_type = "str")] + to: Expression, +} + +#[derive(Clone, ParseFilter, FilterReflection, Default)] +#[filter( + name = "replace", + description = "Replaces every occurrence of a substring with another.", + parameters(ReplaceArgs), + parsed(ReplaceFilter) +)] +pub struct Replace; + +#[derive(Debug, FromFilterParameters, Display_filter)] +#[name = "replace"] +struct ReplaceFilter { + #[parameters] + args: ReplaceArgs, +} + +impl Filter for ReplaceFilter { + fn evaluate(&self, input: &dyn ValueView, runtime: &dyn Runtime) -> Result { + let args = self.args.evaluate(runtime)?; + let from = args.from.to_kstr(); + let to = args.to.to_kstr(); + let input_str = input.to_kstr(); + Ok(Value::scalar(input_str.replace(from.as_str(), to.as_str()))) + } +} + // ── HMAC args ───────────────────────────────────── #[derive(Debug, FilterParameters)] struct HmacArgs { @@ -110,6 +145,45 @@ impl Filter for HmacSha256Filter { } } + +// ── HMAC-SHA1 ───────────────────────────────────────────── +#[derive(Debug, FilterParameters)] +struct Hmac1Args { + #[parameter(description = "HMAC key", arg_type = "str")] + key: Expression, +} + +#[derive(Clone, ParseFilter, FilterReflection, Default)] +#[filter( + name = "hmac_sha1", + description = "HMAC-SHA1 – returns Base64.", + parameters(Hmac1Args), + parsed(HmacSha1Filter) +)] +pub struct HmacSha1; + +#[derive(Debug, FromFilterParameters, Display_filter)] +#[name = "hmac_sha1"] +struct HmacSha1Filter { + #[parameters] + args: Hmac1Args, +} + +impl Filter for HmacSha1Filter { + fn evaluate(&self, input: &dyn ValueView, runtime: &dyn Runtime) -> Result { + // Evaluate the arguments first… + let args = self.args.evaluate(runtime)?; + let key = args.key.to_kstr(); + + // …then do the cryptography. + let mut mac = Hmac::::new_from_slice(key.as_bytes()).unwrap(); + mac.update(input.to_kstr().as_bytes()); + Ok(Value::scalar( + base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes()), + )) + } +} + // ── HMAC-SHA384 ───────────────────────────────────────────── #[derive(Debug, FilterParameters)] struct Hmac384Args { @@ -260,6 +334,26 @@ static_filter!( } ); + +// {{ "" | iso_timestamp_no_frac }} +static_filter!( + /// Current ISO-8601 timestamp (UTC) with no fractional seconds. + IsoTimestampNoFracFilter, "iso_timestamp_no_frac", + |_input: &dyn ValueView| -> String { + let full = OffsetDateTime::now_utc() + .format(&Iso8601::DEFAULT) + .unwrap_or_else(|_| "1970-01-01T00:00:00Z".into()); + + // If there’s a fractional-second part, remove it but keep the trailing ‘Z’. + match full.split_once('.') { + Some((prefix, _)) => { + format!("{prefix}Z") + } + None => full, + } + } +); + // {{ "" | iso_timestamp }} static_filter!( /// Current ISO-8601 timestamp (UTC). @@ -285,17 +379,20 @@ static_filter!( pub fn register_all(builder: liquid::ParserBuilder) -> liquid::ParserBuilder { builder // zero-arg helpers + .filter(Replace::default()) .filter(B64UrlEncFilter::default()) .filter(Sha256Filter::default()) .filter(UrlEncodeFilter::default()) .filter(JsonEscapeFilter::default()) .filter(UnixTimestampFilter::default()) .filter(IsoTimestampFilter::default()) + .filter(IsoTimestampNoFracFilter::default()) .filter(UuidFilter::default()) .filter(JwtHeaderFilter::default()) .filter(B64EncFilter::default()) .filter(RandomStringFilter::default()) .filter(HmacSha256::default()) + .filter(HmacSha1::default()) .filter(HmacSha384::default()) } @@ -308,6 +405,7 @@ mod tests { use regex::Regex; use sha2::{Digest, Sha256, Sha384}; use time::OffsetDateTime; + use sha1::Sha1; use super::*; @@ -334,6 +432,17 @@ mod tests { assert_eq!(render(r#"{{ "hello" | sha256 }}"#), expect); } + #[test] + fn hmac_sha1_filter() { + let key = b"key1"; + let data = b"data"; + let mut mac = Hmac::::new_from_slice(key).unwrap(); + mac.update(data); + let expect = general_purpose::STANDARD.encode(mac.finalize().into_bytes()); + + assert_eq!(render(r#"{{ "data" | hmac_sha1: "key1" }}"#), expect); + } + #[test] fn b64url_enc_filter() { assert_eq!( @@ -416,6 +525,16 @@ mod tests { assert!((now - tmpl_val).abs() < 5, "timestamp differs by >5 s"); } + #[test] + fn rfc3986_ts_filter_format() { + let ts = render(r#"{{ "" | rfc3986_ts }}"#); + + // RFC-3986 form: 2025-07-09T03%3A36%3A40Z + let re = Regex::new(r"^\d{4}-\d{2}-\d{2}T\d{2}%3A\d{2}%3A\d{2}Z$").unwrap(); + assert!(re.is_match(&ts), "timestamp not RFC3986-encoded: {ts}"); + assert!(!ts.contains('.'), "sub-seconds should be removed: {ts}"); + } + #[test] fn iso_timestamp_filter_parses() { let out = render(r#"{{ "" | iso_timestamp }}"#); @@ -434,4 +553,5 @@ mod tests { let v = render(r#"{{ "" | uuid }}"#); assert!(uuid_re.is_match(&v)); } + } diff --git a/src/validation/httpvalidation.rs b/src/validation/httpvalidation.rs index 2633ff1..eed7019 100644 --- a/src/validation/httpvalidation.rs +++ b/src/validation/httpvalidation.rs @@ -527,4 +527,5 @@ mod tests { // 4️⃣ It *should* be valid (true) because all matcher conditions hold assert!(ok, "Slack webhook response should be considered ACTIVE"); } + }