Skip to main content

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}