agentsec_core/paste/obfuscation.rs
1//! Obfuscation-aware decode chain for paste detection.
2//!
3//! Attackers wrap injection payloads in percent / HTML / base64 / Unicode
4//! disguise so a naive substring scan misses them. [`decode_chain`] applies
5//! all four layers in sequence so the downstream detector
6//! ([`crate::paste::detector::scan`]) sees the normalized form.
7
8use unicode_normalization::UnicodeNormalization;
9
10/// Apply the full decode chain: percent → HTML → base64 → Unicode NFKC.
11///
12/// Each layer is best-effort:
13///
14/// - percent and HTML decoding pass non-matching bytes through unchanged
15/// - base64 decoding requires the *entire* input to be a valid base64
16/// token; otherwise the layer returns the input unchanged (no partial
17/// decode)
18/// - NFKC always succeeds
19///
20/// # Examples
21///
22/// ```
23/// use agentsec_core::paste::obfuscation::decode_chain;
24///
25/// // Plain text passes through.
26/// assert_eq!(decode_chain("hello"), "hello");
27///
28/// // Percent-encoded text is decoded.
29/// assert_eq!(decode_chain("hello%20world"), "hello world");
30/// ```
31pub fn decode_chain(input: &str) -> String {
32 let s1 = decode_percent(input);
33 let s2 = decode_html(&s1);
34 let s3 = decode_base64(&s2);
35 normalize_unicode(&s3)
36}
37
38fn decode_percent(input: &str) -> String {
39 percent_encoding::percent_decode_str(input)
40 .decode_utf8_lossy()
41 .into_owned()
42}
43
44fn decode_html(input: &str) -> String {
45 html_escape::decode_html_entities(input).into_owned()
46}
47
48fn decode_base64(input: &str) -> String {
49 // Best-effort: only decode if the whole token looks base64-like.
50 match data_encoding::BASE64.decode(input.as_bytes()) {
51 Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
52 Err(_) => input.to_string(),
53 }
54}
55
56fn normalize_unicode(input: &str) -> String {
57 input.nfkc().collect()
58}