From 9cf22e27fc900a2abf1c63174f29ce721dd9c153 Mon Sep 17 00:00:00 2001 From: Luke Young Date: Fri, 17 Apr 2026 22:10:03 -0700 Subject: [PATCH 1/3] fix(kingfisher.github.1): add checksum validation for GitHub fine-grained PATs Updated GitHub PAT rule to include checksum validation. Signed-off-by: Luke Young --- crates/kingfisher-rules/data/rules/github.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/kingfisher-rules/data/rules/github.yml b/crates/kingfisher-rules/data/rules/github.yml index 2c37117..484da40 100644 --- a/crates/kingfisher-rules/data/rules/github.yml +++ b/crates/kingfisher-rules/data/rules/github.yml @@ -2,15 +2,23 @@ rules: - name: GitHub Personal Access Token - fine-grained permissions id: kingfisher.github.1 pattern: | - (?xi) + (?x) ( github_pat_ - [A-Z0-9_+]{82,84} + (?P[0-9][A-Za-z0-9]{21}_[A-Za-z0-9]{43}) + (?P[A-Za-z0-9]{8}) + [A-Za-z0-9]{8} ) \b pattern_requirements: min_digits: 2 min_lowercase: 2 + checksum: + actual: + template: "{{ checksum }}" + requires_capture: checksum + expected: "{{ body | crc32 | base62: 8 }}" + skip_if_missing: true min_entropy: 3.5 examples: - "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" From 6048462041da5ecc684433f6a8d4eed74b2502fd Mon Sep 17 00:00:00 2001 From: Luke Young Date: Fri, 17 Apr 2026 23:25:02 -0700 Subject: [PATCH 2/3] working --- Cargo.lock | 1 + crates/kingfisher-rules/Cargo.toml | 1 + crates/kingfisher-rules/data/rules/github.yml | 95 +++++++++++++++---- crates/kingfisher-rules/src/liquid_filters.rs | 63 ++++++++++++ 4 files changed, 140 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b5ccddf..f0886bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5273,6 +5273,7 @@ name = "kingfisher-rules" version = "0.1.0" dependencies = [ "anyhow", + "base32", "base64 0.22.1", "crc32fast", "hmac 0.12.1", diff --git a/crates/kingfisher-rules/Cargo.toml b/crates/kingfisher-rules/Cargo.toml index 420247a..4e4772a 100644 --- a/crates/kingfisher-rules/Cargo.toml +++ b/crates/kingfisher-rules/Cargo.toml @@ -36,6 +36,7 @@ liquid = "0.26" liquid-core = "0.26" # Crypto for liquid filters +base32 = "0.5" base64.workspace = true crc32fast = "1.5" hmac.workspace = true diff --git a/crates/kingfisher-rules/data/rules/github.yml b/crates/kingfisher-rules/data/rules/github.yml index 484da40..fa2e3cd 100644 --- a/crates/kingfisher-rules/data/rules/github.yml +++ b/crates/kingfisher-rules/data/rules/github.yml @@ -4,9 +4,8 @@ rules: pattern: | (?x) ( - github_pat_ - (?P[0-9][A-Za-z0-9]{21}_[A-Za-z0-9]{43}) - (?P[A-Za-z0-9]{8}) + (?Pgithub_pat_[0-9][A-Za-z0-9]{21}_[A-Za-z0-9]{43}) + (?P[A-Za-z2-7]{8}) [A-Za-z0-9]{8} ) \b @@ -17,7 +16,7 @@ rules: actual: template: "{{ checksum }}" requires_capture: checksum - expected: "{{ body | crc32 | base62: 8 }}" + expected: "{{ body | sha256_b32: 8 }}" skip_if_missing: true min_entropy: 3.5 examples: @@ -58,9 +57,9 @@ rules: - name: GitHub Personal Access Token id: kingfisher.github.2 pattern: | - (?xi) + (?x) ( - ghp_(?P[A-Z0-9]{30})(?P[A-Z0-9]{6}) + ghp_(?P[A-Za-z0-9]{30})(?P[A-Za-z0-9]{6}) ) pattern_requirements: min_digits: 2 @@ -114,9 +113,9 @@ rules: - name: GitHub OAuth Access Token id: kingfisher.github.3 pattern: | - (?xi) + (?x) ( - gho_(?P[A-Z0-9]{30})(?P[A-Z0-9]{6}) + gho_(?P[A-Za-z0-9]{30})(?P[A-Za-z0-9]{6}) ) pattern_requirements: min_digits: 2 @@ -149,18 +148,40 @@ rules: words: - '"login"' - '"id"' + revocation: + type: Http + content: + request: + method: POST + url: https://api.github.com/credentials/revoke + headers: + Accept: application/vnd.github+json + X-GitHub-Api-Version: 2022-11-28 + Content-Type: application/json + body: '{"credentials":["{{ TOKEN }}"]}' + response_matcher: + - report_response: true + - type: StatusMatch + status: [202] - name: GitHub App User-to-Server Token id: kingfisher.github.4 pattern: | - (?xi) + (?x) ( - ghu_(?P[A-Z0-9]{30})(?P[A-Z0-9]{6}) + ghu_(?P[A-Za-z0-9]{30})(?P[A-Za-z0-9]{6}) ) + pattern_requirements: + checksum: + actual: + template: "{{ checksum }}" + requires_capture: checksum + expected: "{{ body | crc32 | base62: 6 }}" + skip_if_missing: true examples: - - ' "token": "ghu_16C7e42F292c69C2E7C10c838347Ae178B4a",' + - ' "token": "ghu_TIOHHEVefAwRonSMALCFfWMYK0un1R1dj2rn",' - | Example usage: - git clone http://ghu_RguXIkihJjwHAP6eXEYxaPNvywurTr5IOAbg@github.com/username/repo.git + git clone http://ghu_imqBAXUtRirzzcJPwAiqImhkzsvzYZ1eDtPf@github.com/username/repo.git references: - https://docs.github.com/en/rest/users?apiVersion=2022-11-28 validation: @@ -179,15 +200,27 @@ rules: words: - '"login"' - '"id"' - # Revocation not supported: ghu_ tokens require the GitHub App's client_id - # and client_secret (DELETE /applications/{client_id}/token with Basic auth). - # Users can revoke via GitHub Settings > Applications > Authorized GitHub Apps. + revocation: + type: Http + content: + request: + method: POST + url: https://api.github.com/credentials/revoke + headers: + Accept: application/vnd.github+json + X-GitHub-Api-Version: 2022-11-28 + Content-Type: application/json + body: '{"credentials":["{{ TOKEN }}"]}' + response_matcher: + - report_response: true + - type: StatusMatch + status: [202] - name: GitHub App Server-to-Server Token id: kingfisher.github.5 pattern: | - (?xi) + (?x) ( - ghs_(?P[A-Z0-9]{30})(?P[A-Z0-9]{6}) + ghs_(?P[A-Za-z0-9]{30})(?P[A-Za-z0-9]{6}) ) examples: - ' "token": "ghs_16C7e42F292c69C2E7C10c838347Ae178B4a",' @@ -228,12 +261,19 @@ rules: - name: GitHub Refresh Token id: kingfisher.github.6 pattern: | - (?xi) + (?x) ( - ghr_(?P[A-Z0-9]{30})(?P[A-Z0-9]{6}) + ghr_(?P[A-Za-z0-9]{70})(?P[A-Za-z0-9]{6}) ) + pattern_requirements: + checksum: + actual: + template: "{{ checksum }}" + requires_capture: checksum + expected: "{{ body | crc32 | base62: 6 }}" + skip_if_missing: true examples: - - ' "refresh_token": "ghr_1B4a2e77838347a7E420ce178F2E7c6912E169246c3CE1ccbF66C46812d16D5B1A9Dc86A1498",' + - ' "refresh_token": "ghr_xgrrGzSbbGRL34Wp39JU9nxtN27Pr1v1He8FjE7x7wbExGGs7nfJszJDAmZuoKasxZ0KxJ1HSzgc",' references: - https://docs.github.com/en/rest/users?apiVersion=2022-11-28 validation: @@ -252,6 +292,21 @@ rules: words: - '"login"' - '"id"' + revocation: + type: Http + content: + request: + method: POST + url: https://api.github.com/credentials/revoke + headers: + Accept: application/vnd.github+json + X-GitHub-Api-Version: 2022-11-28 + Content-Type: application/json + body: '{"credentials":["{{ TOKEN }}"]}' + response_matcher: + - report_response: true + - type: StatusMatch + status: [202] - name: GitHub Client ID id: kingfisher.github.7 diff --git a/crates/kingfisher-rules/src/liquid_filters.rs b/crates/kingfisher-rules/src/liquid_filters.rs index cf6b73e..19322cd 100644 --- a/crates/kingfisher-rules/src/liquid_filters.rs +++ b/crates/kingfisher-rules/src/liquid_filters.rs @@ -540,6 +540,56 @@ static_filter!( } ); +// {{ value | sha256_b32 }} -- base32-encoded SHA-256 digest, optional length +#[derive(Debug, FilterParameters)] +struct Sha256B32Args { + #[parameter(description = "Exact output length: truncates if longer, pads with '=' if shorter", arg_type = "integer")] + len: Option, +} + +#[derive(Clone, ParseFilter, FilterReflection, Default)] +#[filter( + name = "sha256_b32", + description = "SHA-256 digest encoded as Base32 (RFC 4648), optionally truncating or padding with '=' to an exact length.", + parameters(Sha256B32Args), + parsed(Sha256B32) +)] +pub struct Sha256B32Filter; + +#[derive(Debug, FromFilterParameters, Display_filter)] +#[name = "sha256_b32"] +struct Sha256B32 { + #[parameters] + args: Sha256B32Args, +} + +impl Filter for Sha256B32 { + fn evaluate(&self, input: &dyn ValueView, runtime: &dyn Runtime) -> Result { + let args = self.args.evaluate(runtime)?; + let mut h = Sha256::new(); + h.update(input.to_kstr().as_bytes()); + let mut encoded = base32::encode( + base32::Alphabet::Rfc4648 { padding: true }, + &h.finalize()[..], + ); + if let Some(len) = args.len.and_then(|value| { + let scalar = Value::scalar(value); + value_to_usize(&scalar) + }) { + match encoded.len().cmp(&len) { + std::cmp::Ordering::Greater => encoded.truncate(len), + std::cmp::Ordering::Less => { + for _ in 0..(len - encoded.len()) { + encoded.push('='); + } + } + std::cmp::Ordering::Equal => {} + } + } + Ok(Value::scalar(encoded)) + } +} + static_filter!( /// Compute the CRC32 of the input and return it as a decimal number. Crc32Filter, @@ -1013,6 +1063,7 @@ pub fn register_all(builder: liquid::ParserBuilder) -> liquid::ParserBuilder { .filter(Replace::default()) .filter(B64UrlEncFilter::default()) .filter(Sha256Filter::default()) + .filter(Sha256B32Filter::default()) .filter(UrlEncodeFilter::default()) .filter(JsonEscapeFilter::default()) .filter(UnixTimestampFilter::default()) @@ -1083,6 +1134,18 @@ mod tests { assert_eq!(render(r#"{{ "hello" | sha256 }}"#), expect); } + #[test] + fn sha256_b32_filter() { + let expect = "FTZE3OS7WCRQ4JXIHMVMLOPCTYNRMHS4D6TUEXTTAQZWFE4LTASA===="; + assert_eq!(render(r#"{{ "hello" | sha256_b32 }}"#), expect); + // truncate + assert_eq!(render(r#"{{ "hello" | sha256_b32: 4 }}"#), &expect[..4]); + // pad + let padded = render(r#"{{ "hello" | sha256_b32: 60 }}"#); + assert!(padded.ends_with("====")); + assert_eq!(padded.len(), 60); + } + #[test] fn suffix_filter() { assert_eq!(render(r#"{{ "abcdef" | suffix: 3 }}"#), "def"); From f22b7768e9a8433d08093aa9811f24e415814be7 Mon Sep 17 00:00:00 2001 From: Mick Grove Date: Mon, 20 Apr 2026 08:44:41 -0700 Subject: [PATCH 3/3] fix(github): address PR review feedback - Update X-GitHub-Api-Version to 2026-03-10 for /credentials/revoke endpoint (the endpoint is only documented under this API version). - Clarify sha256_b32 filter description: note that the optional `len` parameter may produce output that is not valid RFC 4648 Base32. - Move base32 to [workspace.dependencies] and reference it via .workspace = true from both the root crate and kingfisher-rules to avoid version skew. --- Cargo.toml | 3 ++- crates/kingfisher-rules/Cargo.toml | 2 +- crates/kingfisher-rules/data/rules/github.yml | 10 +++++----- crates/kingfisher-rules/src/liquid_filters.rs | 13 +++++++------ 4 files changed, 15 insertions(+), 13 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index a9cbb83..d453de6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ include_dir = "0.7" sha1 = "0.10" sha2 = "0.10" hmac = "0.12" +base32 = "0.5.1" base64 = "0.22" percent-encoding = "2.3" time = "0.3" @@ -150,7 +151,7 @@ liquid = "0.26.11" liquid-core = "0.26.11" flate2 = "1.1" thousands = "0.2.0" -base32 = "0.5.1" +base32.workspace = true crossbeam-skiplist = "0.1.3" tokio-postgres = { version = "0.7", default-features = false, features = ["runtime"] } mongodb = { version = "3.4", default-features = false, features = ["rustls-tls", "aws-auth", "compat-3-0-0", "dns-resolver"] } diff --git a/crates/kingfisher-rules/Cargo.toml b/crates/kingfisher-rules/Cargo.toml index 4e4772a..6330988 100644 --- a/crates/kingfisher-rules/Cargo.toml +++ b/crates/kingfisher-rules/Cargo.toml @@ -36,7 +36,7 @@ liquid = "0.26" liquid-core = "0.26" # Crypto for liquid filters -base32 = "0.5" +base32.workspace = true base64.workspace = true crc32fast = "1.5" hmac.workspace = true diff --git a/crates/kingfisher-rules/data/rules/github.yml b/crates/kingfisher-rules/data/rules/github.yml index fa2e3cd..9d48d6b 100644 --- a/crates/kingfisher-rules/data/rules/github.yml +++ b/crates/kingfisher-rules/data/rules/github.yml @@ -47,7 +47,7 @@ rules: url: https://api.github.com/credentials/revoke headers: Accept: application/vnd.github+json - X-GitHub-Api-Version: 2022-11-28 + X-GitHub-Api-Version: 2026-03-10 Content-Type: application/json body: '{"credentials":["{{ TOKEN }}"]}' response_matcher: @@ -103,7 +103,7 @@ rules: url: https://api.github.com/credentials/revoke headers: Accept: application/vnd.github+json - X-GitHub-Api-Version: 2022-11-28 + X-GitHub-Api-Version: 2026-03-10 Content-Type: application/json body: '{"credentials":["{{ TOKEN }}"]}' response_matcher: @@ -156,7 +156,7 @@ rules: url: https://api.github.com/credentials/revoke headers: Accept: application/vnd.github+json - X-GitHub-Api-Version: 2022-11-28 + X-GitHub-Api-Version: 2026-03-10 Content-Type: application/json body: '{"credentials":["{{ TOKEN }}"]}' response_matcher: @@ -208,7 +208,7 @@ rules: url: https://api.github.com/credentials/revoke headers: Accept: application/vnd.github+json - X-GitHub-Api-Version: 2022-11-28 + X-GitHub-Api-Version: 2026-03-10 Content-Type: application/json body: '{"credentials":["{{ TOKEN }}"]}' response_matcher: @@ -300,7 +300,7 @@ rules: url: https://api.github.com/credentials/revoke headers: Accept: application/vnd.github+json - X-GitHub-Api-Version: 2022-11-28 + X-GitHub-Api-Version: 2026-03-10 Content-Type: application/json body: '{"credentials":["{{ TOKEN }}"]}' response_matcher: diff --git a/crates/kingfisher-rules/src/liquid_filters.rs b/crates/kingfisher-rules/src/liquid_filters.rs index 19322cd..5594435 100644 --- a/crates/kingfisher-rules/src/liquid_filters.rs +++ b/crates/kingfisher-rules/src/liquid_filters.rs @@ -543,14 +543,17 @@ static_filter!( // {{ value | sha256_b32 }} -- base32-encoded SHA-256 digest, optional length #[derive(Debug, FilterParameters)] struct Sha256B32Args { - #[parameter(description = "Exact output length: truncates if longer, pads with '=' if shorter", arg_type = "integer")] + #[parameter( + description = "Exact output length: truncates the Base32 text if longer, pads with '=' if shorter (output may not be valid RFC 4648 Base32)", + arg_type = "integer" + )] len: Option, } #[derive(Clone, ParseFilter, FilterReflection, Default)] #[filter( name = "sha256_b32", - description = "SHA-256 digest encoded as Base32 (RFC 4648), optionally truncating or padding with '=' to an exact length.", + description = "SHA-256 digest encoded as RFC 4648 Base32 by default; with `len`, returns a Base32-alphabet checksum substring of the requested length (truncated or '='-padded), which may not be valid RFC 4648 Base32.", parameters(Sha256B32Args), parsed(Sha256B32) )] @@ -568,10 +571,8 @@ impl Filter for Sha256B32 { let args = self.args.evaluate(runtime)?; let mut h = Sha256::new(); h.update(input.to_kstr().as_bytes()); - let mut encoded = base32::encode( - base32::Alphabet::Rfc4648 { padding: true }, - &h.finalize()[..], - ); + let mut encoded = + base32::encode(base32::Alphabet::Rfc4648 { padding: true }, &h.finalize()[..]); if let Some(len) = args.len.and_then(|value| { let scalar = Value::scalar(value); value_to_usize(&scalar)