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/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 420247a..6330988 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.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 2c37117..9d48d6b 100644
--- a/crates/kingfisher-rules/data/rules/github.yml
+++ b/crates/kingfisher-rules/data/rules/github.yml
@@ -2,15 +2,22 @@ rules:
- name: GitHub Personal Access Token - fine-grained permissions
id: kingfisher.github.1
pattern: |
- (?xi)
+ (?x)
(
- github_pat_
- [A-Z0-9_+]{82,84}
+ (?P
github_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
pattern_requirements:
min_digits: 2
min_lowercase: 2
+ checksum:
+ actual:
+ template: "{{ checksum }}"
+ requires_capture: checksum
+ expected: "{{ body | sha256_b32: 8 }}"
+ skip_if_missing: true
min_entropy: 3.5
examples:
- "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"
@@ -40,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:
@@ -50,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
@@ -96,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:
@@ -106,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
@@ -141,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: 2026-03-10
+ 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:
@@ -171,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: 2026-03-10
+ 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",'
@@ -220,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:
@@ -244,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: 2026-03-10
+ 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..5594435 100644
--- a/crates/kingfisher-rules/src/liquid_filters.rs
+++ b/crates/kingfisher-rules/src/liquid_filters.rs
@@ -540,6 +540,57 @@ static_filter!(
}
);
+// {{ value | sha256_b32 }} -- base32-encoded SHA-256 digest, optional length
+#[derive(Debug, FilterParameters)]
+struct Sha256B32Args {
+ #[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 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)
+)]
+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 +1064,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 +1135,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");