1use std::sync::LazyLock;
9
10use regex::Regex;
11use unicode_normalization::UnicodeNormalization;
12
13pub const DEFAULT_MAX_FULL_SCAN_BYTES: usize = 262_144;
14const FULL_SCAN_TAIL_BYTES: usize = 4096;
15const MAX_DECODE_ITERATIONS: usize = 16;
16const MAX_GUNZIP_ATTEMPTS_PER_PASS: u32 = 8;
17
18const LOOKALIKES: &[(char, &str)] = &[
20 ('\u{2044}', "/"),
21 ('\u{FF0F}', "/"),
22 ('\u{29F8}', "/"),
23 ('\u{0130}', "I"),
24 ('\u{0131}', "i"),
25 ('\u{200B}', ""),
26 ('\u{200C}', ""),
27 ('\u{200D}', ""),
28 ('\u{FEFF}', ""),
29 ('\u{00AD}', ""),
30 ('\u{034F}', ""),
31 ('\u{180E}', ""),
32 ('\u{2028}', "\n"),
33 ('\u{2029}', "\n"),
34 ('\u{E000}', ""),
35 ('\u{FFF0}', ""),
36 ('\u{01C0}', "|"),
37 ('\u{037E}', ";"),
38 ('\u{2215}', "/"),
39 ('\u{2216}', "\\"),
40 ('\u{FF1C}', "<"),
41 ('\u{FF1E}', ">"),
42 ('\u{FF1B}', ";"),
43 ('\u{FF5C}', "|"),
44 ('\u{FF06}', "&"),
45];
46
47static ATTACK_INDICATORS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
48 let patterns = [
49 r"<script",
50 r"javascript:",
51 r"on\w+=",
52 r"SELECT\s+.{0,50}?\s+FROM",
53 r"UNION\s+SELECT",
54 r"\.\./",
55 r"eval\s*\(",
56 r"exec\s*\(",
57 r"system\s*\(",
58 r"<\?php",
59 r"<%",
60 r"\{\{",
61 r"\{%",
62 r"<iframe",
63 r"<object",
64 r"<embed",
65 r"onerror\s*=",
66 r"onload\s*=",
67 r"\$\{",
68 r"\\x[0-9a-fA-F]{2}",
69 r"%[0-9a-fA-F]{2}",
70 r"`",
71 r"\$\(",
72 r"[;&|]",
73 r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b",
74 ];
75 patterns
76 .iter()
77 .map(|p| Regex::new(&format!("(?i){p}")).expect("static indicator must compile"))
78 .collect()
79});
80
81#[must_use]
84pub fn normalize_unicode(content: &str) -> String {
85 let normalized: String = content.nfkc().collect();
86 let mut result = normalized;
87
88 for &(ch, replacement) in LOOKALIKES {
89 if result.contains(ch) {
90 result = result.replace(ch, replacement);
91 }
92 }
93
94 result
95}
96
97const fn py_is_space(c: char) -> bool {
100 c.is_whitespace() || matches!(c, '\u{1c}'..='\u{1f}')
101}
102
103#[doc(hidden)]
104#[must_use]
105pub fn collapse_whitespace(content: &str) -> String {
106 let mut out = String::with_capacity(content.len());
107 let mut in_run = false;
108 let mut any = false;
109 for c in content.chars() {
110 if py_is_space(c) {
111 in_run = true;
112 continue;
113 }
114 if in_run && any {
115 out.push(' ');
116 }
117 in_run = false;
118 any = true;
119 out.push(c);
120 }
121 out
122}
123
124#[doc(hidden)]
125#[must_use]
126pub fn remove_null_bytes(content: &str) -> String {
127 content
128 .chars()
129 .filter(|&c| {
130 let code = c as u32;
131 code >= 32 || matches!(code, 9 | 10 | 13)
132 })
133 .collect()
134}
135
136fn decode_percent_u_escapes(s: &str) -> String {
141 static RE: LazyLock<Regex> =
142 LazyLock::new(|| Regex::new(r"(?i)%u([0-9a-fA-F]{4})").expect("static regex"));
143 replace_hex_group(&RE, s)
144}
145
146fn decode_hex_escapes(s: &str) -> String {
147 static RE: LazyLock<Regex> =
148 LazyLock::new(|| Regex::new(r"\\x([0-9a-fA-F]{2})").expect("static regex"));
149 replace_hex_group(&RE, s)
150}
151
152fn decode_ldap_hex_escapes(s: &str) -> String {
153 static RE: LazyLock<Regex> =
154 LazyLock::new(|| Regex::new(r"\\([0-9a-fA-F]{2})").expect("static regex"));
155 replace_hex_group(&RE, s)
156}
157
158fn decode_unicode_escapes(s: &str) -> String {
159 static RE: LazyLock<Regex> =
160 LazyLock::new(|| Regex::new(r"\\u([0-9a-fA-F]{4})").expect("static regex"));
161 replace_hex_group(&RE, s)
162}
163
164fn replace_hex_group(re: &Regex, s: &str) -> String {
165 re.replace_all(s, |caps: ®ex::Captures<'_>| {
166 let hex = caps.get(1).map_or("0", |m| m.as_str());
167 u32::from_str_radix(hex, 16)
168 .ok()
169 .and_then(char::from_u32)
170 .map_or_else(
171 || caps.get(0).map_or("", |m| m.as_str()).to_owned(),
172 String::from,
173 )
174 })
175 .into_owned()
176}
177
178const PERCENT_BYTE_RUN_RE: &str = r"(?:%[0-9a-fA-F]{2})+";
179
180#[must_use]
183pub fn decode_overlong_utf8_percent_runs(content: &str) -> String {
184 static RE: LazyLock<Regex> =
185 LazyLock::new(|| Regex::new(PERCENT_BYTE_RUN_RE).expect("static regex"));
186 RE.replace_all(content, |caps: ®ex::Captures<'_>| {
187 let run = caps.get(0).map_or("", |m| m.as_str());
188 let raw: Vec<u8> = run
189 .as_bytes()
190 .chunks(3)
191 .filter_map(|triple| {
192 if triple.len() == 3 {
193 u8::from_str_radix(std::str::from_utf8(&triple[1..3]).ok()?, 16).ok()
194 } else {
195 None
196 }
197 })
198 .collect();
199 if std::str::from_utf8(&raw).is_ok() {
200 return run.to_owned();
201 }
202 lenient_overlong_utf8_decode(&raw)
203 })
204 .into_owned()
205}
206
207const OVERLONG_LEAD_SPECS: [(u8, usize, u8, u8, u8); 4] = [
208 (0xC0, 2, 0x1F, 0x80, 0xBF),
210 (0xC1, 2, 0x1F, 0x80, 0xBF),
211 (0xE0, 3, 0x0F, 0x80, 0x9F),
212 (0xF0, 4, 0x07, 0x80, 0x8F),
213];
214
215fn decode_overlong_sequence_at(raw: &[u8], index: usize) -> Option<(char, usize)> {
216 let lead = *raw.get(index)?;
217 let spec = OVERLONG_LEAD_SPECS.iter().find(|spec| spec.0 == lead)?;
218 let sequence_length = spec.1;
219 let lead_mask = spec.2;
220 let first_min = spec.3;
221 let first_max = spec.4;
222 if index + sequence_length > raw.len() {
223 return None;
224 }
225 let continuations = &raw[index + 1..index + sequence_length];
226 if continuations[0] < first_min || continuations[0] > first_max {
227 return None;
228 }
229 if continuations[1..]
230 .iter()
231 .any(|byte| !(*byte >= 0x80 && *byte <= 0xBF))
232 {
233 return None;
234 }
235 let mut codepoint = u32::from(lead & lead_mask);
236 for byte in continuations {
237 codepoint = (codepoint << 6) | u32::from(byte & 0x3F);
238 }
239 char::from_u32(codepoint).map(|c| (c, sequence_length))
240}
241
242fn lenient_overlong_utf8_decode(raw: &[u8]) -> String {
243 let mut chars = String::new();
244 let mut index = 0;
245 while index < raw.len() {
246 if let Some((c, consumed)) = decode_overlong_sequence_at(raw, index) {
247 chars.push(c);
248 index += consumed;
249 } else if raw[index] < 0x80 {
250 chars.push(raw[index] as char);
251 index += 1;
252 } else {
253 index += 1;
254 }
255 }
256 chars
257}
258
259fn percent_unquote_ignore(s: &str) -> String {
262 if !s.contains('%') {
263 return s.to_owned();
264 }
265 let bytes = s.as_bytes();
266 let mut raw: Vec<u8> = Vec::with_capacity(bytes.len());
267 let mut i = 0;
268 while i < bytes.len() {
269 if bytes[i] == b'%'
270 && i + 2 < bytes.len()
271 && bytes[i + 1].is_ascii_hexdigit()
272 && bytes[i + 2].is_ascii_hexdigit()
273 {
274 let hi = (bytes[i + 1] as char).to_digit(16).unwrap_or(0) as u8;
275 let lo = (bytes[i + 2] as char).to_digit(16).unwrap_or(0) as u8;
276 raw.push((hi << 4) | lo);
277 i += 3;
278 } else {
279 raw.push(bytes[i]);
280 i += 1;
281 }
282 }
283 utf8_ignore(&raw)
284}
285
286fn utf8_ignore(bytes: &[u8]) -> String {
287 let mut out = String::with_capacity(bytes.len());
288 let mut i = 0;
289 while i < bytes.len() {
290 match std::str::from_utf8(&bytes[i..]) {
291 Ok(s) => {
292 out.push_str(s);
293 break;
294 }
295 Err(e) => {
296 let valid = e.valid_up_to();
297 out.push_str(std::str::from_utf8(&bytes[i..i + valid]).unwrap_or(""));
298 i += valid + e.error_len().unwrap_or(1);
299 }
300 }
301 }
302 out
303}
304
305const fn is_b64_data(c: char) -> bool {
310 c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '_' | '-')
311}
312
313fn is_b64_separator(c: char) -> bool {
316 let cp = c as u32;
317 if (0xDC80..=0xDCFF).contains(&cp) {
318 return true;
319 }
320 if cp < 0x80 {
321 return !is_b64_data(c);
322 }
323 false
324}
325
326const fn bounded_gunzip(_raw: &[u8]) -> Option<Vec<u8>> {
331 None
332}
333
334const GZIP_MAGIC: [u8; 2] = [0x1F, 0x8B];
335const PRINTABLE_RATIO_THRESHOLD: f64 = 0.5;
336const FALLBACK_PRINTABLE_RATIO_THRESHOLD: f64 = 0.95;
337const MAX_REPLACEMENT_CHAR_RATIO: f64 = 0.2;
338
339fn printable_ratio(text: &str) -> f64 {
340 if text.is_empty() {
341 return 0.0;
342 }
343 let printable = text.chars().filter(|c| py_is_printable(*c)).count();
344 f64::from(u32::try_from(printable).unwrap_or(u32::MAX))
345 / f64::from(u32::try_from(text.chars().count()).unwrap_or(u32::MAX))
346}
347
348fn py_is_printable(c: char) -> bool {
351 if c == ' ' {
352 return true;
353 }
354 if c.is_control() || c.is_whitespace() {
355 return false;
356 }
357 !matches!(c as u32,
359 0x00AD | 0x0600..=0x0605 | 0x061C | 0x06DD | 0x070F
360 | 0x180E | 0x200B..=0x200F | 0x202A..=0x202E | 0x2060..=0x2064
361 | 0x2066..=0x206F | 0xFEFF | 0xFFF9..=0xFFFB
362 | 0xE000..=0xF8FF | 0xF0000..=0xFFFFD | 0x0010_0000..=0x0010_FFFD
363 )
364}
365
366fn replacement_char_ratio(text: &str) -> f64 {
367 if text.is_empty() {
368 return 0.0;
369 }
370 let count = text.chars().filter(|c| *c == '\u{FFFD}').count();
371 f64::from(u32::try_from(count).unwrap_or(u32::MAX))
372 / f64::from(u32::try_from(text.chars().count()).unwrap_or(u32::MAX))
373}
374
375fn b64_decode_strict(cleaned: &str) -> Option<Vec<u8>> {
377 if cleaned.is_empty() || cleaned.len() % 4 == 1 {
378 return None;
379 }
380 let mut padded = cleaned.to_owned();
381 for _ in 0..(4 - cleaned.len() % 4) % 4 {
382 padded.push('=');
383 }
384 let bytes = padded.as_bytes();
385 let mut out = Vec::with_capacity(bytes.len() / 4 * 3);
386 let (groups, rest) = bytes.as_chunks::<4>();
388 debug_assert!(rest.is_empty());
389 for group in groups {
390 let v: [u8; 4] = group
391 .iter()
392 .map(|b| match b {
393 b'A'..=b'Z' => b - b'A',
394 b'a'..=b'z' => b - b'a' + 26,
395 b'0'..=b'9' => b - b'0' + 52,
396 b'+' => 62,
397 b'/' => 63,
398 b'=' => 254,
399 _ => 255,
400 })
401 .collect::<Vec<u8>>()
402 .try_into()
403 .ok()?;
404 if v.contains(&255) || v[1] == 254 {
405 return None;
406 }
407 out.push((v[0] << 2) | (v[1] >> 4));
408 if v[2] != 254 {
409 out.push((v[1] << 4) | (v[2] >> 2));
410 if v[3] != 254 {
411 out.push((v[2] << 6) | v[3]);
412 }
413 } else if v[3] != 254 {
414 return None;
415 }
416 }
417 Some(out)
418}
419
420fn decode_cleaned(
421 cleaned: &str,
422 min_printable_ratio: f64,
423 gunzip_left: &mut u32,
424) -> Option<String> {
425 let raw = b64_decode_strict(cleaned)?;
426 let raw = if raw.len() >= 2 && raw[..2] == GZIP_MAGIC && *gunzip_left > 0 {
427 *gunzip_left -= 1;
428 bounded_gunzip(&raw).unwrap_or(raw)
429 } else {
430 raw
431 };
432 let decoded = if let Ok(s) = std::str::from_utf8(&raw) {
433 s.to_owned()
434 } else {
435 let replaced = String::from_utf8_lossy(&raw).into_owned();
436 if replacement_char_ratio(&replaced) > MAX_REPLACEMENT_CHAR_RATIO {
437 return None;
438 }
439 replaced
440 };
441 if printable_ratio(&decoded) >= min_printable_ratio {
442 return Some(decoded);
443 }
444 None
445}
446
447fn is_hex_literal(token: &str) -> bool {
448 token.len() > 2
449 && (token.starts_with("0x") || token.starts_with("0X"))
450 && token[2..].bytes().all(|b| b.is_ascii_hexdigit())
451}
452
453fn token_has_widened_separator(token: &str) -> bool {
456 token.chars().any(|c| match c {
457 '\r' | '\n' | '=' => false,
458 '-' | '_' => true,
459 c if is_b64_separator(c) => true,
460 _ => false,
461 })
462}
463
464fn decode_token(token: &str, min_printable_ratio: f64, gunzip_left: &mut u32) -> Option<String> {
465 if is_hex_literal(token) {
466 return None;
467 }
468 let cleaned: String = token.chars().filter(|c| !is_b64_separator(*c)).collect();
470 let urlsafe: String = cleaned
471 .chars()
472 .map(|c| match c {
473 '-' => '+',
474 '_' => '/',
475 other => other,
476 })
477 .collect();
478 if let Some(decoded) = decode_cleaned(&urlsafe, min_printable_ratio, gunzip_left) {
479 return Some(decoded);
480 }
481 if cleaned.contains('-') || cleaned.contains('_') {
482 let stripped: String = cleaned.chars().filter(|c| *c != '-' && *c != '_').collect();
483 return decode_cleaned(&stripped, min_printable_ratio, gunzip_left);
484 }
485 None
486}
487
488fn base64_token_spans(content: &str) -> Vec<(usize, usize)> {
493 let chars: Vec<(usize, char)> = content.char_indices().collect();
494 let mut spans = Vec::new();
495 let mut i = 0;
496 while i < chars.len() {
497 let (start, c) = chars[i];
498 if !is_b64_data(c) || (i > 0 && is_b64_data(chars[i - 1].1)) {
499 i += 1;
500 continue;
501 }
502 let mut j = i;
504 let mut data_count = 0usize;
505 let mut last_data_idx: Option<usize> = None;
506 while j < chars.len() {
507 let (_, rc) = chars[j];
508 if is_b64_data(rc) {
509 data_count += 1;
510 last_data_idx = Some(j);
511 } else if !is_b64_separator(rc) {
512 break;
513 }
514 j += 1;
515 }
516 let run_end = j;
517 let trailing_from = last_data_idx.map_or(i, |idx| idx + 1);
518 let trailing: Vec<char> = chars[trailing_from..run_end]
519 .iter()
520 .map(|(_, c)| *c)
521 .collect();
522
523 let match_end_char: Option<usize> = if data_count >= 12 {
528 Some(run_end)
530 } else if data_count >= 11 {
531 alt_eq_end(&trailing, 1)
532 .or_else(|| alt_eq_end(&trailing, 2))
533 .map(|rel| trailing_from + rel)
534 } else if data_count == 10 {
535 alt_eq_end(&trailing, 2).map(|rel| trailing_from + rel)
536 } else {
537 None
538 };
539 let Some(end_char) = match_end_char else {
540 i += 1;
541 continue;
542 };
543 let end = chars.get(end_char).map_or(content.len(), |(idx, _)| *idx);
544 if end > start {
545 spans.push((start, end));
546 i = end_char;
548 continue;
549 }
550 i += 1;
551 }
552 spans
553}
554
555fn alt_eq_end(trailing: &[char], eq_count: usize) -> Option<usize> {
559 if eq_count == 1 {
560 return trailing
561 .iter()
562 .rposition(|c| *c == '=')
563 .filter(|p| trailing.get(p + 1).is_none_or(|c| *c != '='))
564 .map(|p| p + 1);
565 }
566 trailing
567 .iter()
568 .enumerate()
569 .rev()
570 .find(|(idx, c)| {
571 **c == '='
572 && trailing.get(idx + 1) == Some(&'=')
573 && trailing.get(idx + 2).is_none_or(|c| *c != '=')
574 })
575 .map(|(idx, _)| idx + 2)
576}
577
578#[must_use]
582pub fn decode_base64_candidates(content: &str, gunzip_attempts_left: &mut u32) -> String {
583 let spans = base64_token_spans(content);
584 if spans.is_empty() {
585 return content.to_owned();
586 }
587 let mut out = String::with_capacity(content.len());
588 let mut last = 0usize;
589 for (start, end) in spans {
590 if start < last {
591 continue;
592 }
593 out.push_str(&content[last..start]);
594 let token = &content[start..end];
595 let primary_threshold = if token_has_widened_separator(token) {
596 FALLBACK_PRINTABLE_RATIO_THRESHOLD
597 } else {
598 PRINTABLE_RATIO_THRESHOLD
599 };
600 let decoded = decode_token(token, primary_threshold, gunzip_attempts_left);
601 let base = decoded.unwrap_or_else(|| decode_runs(token, gunzip_attempts_left));
602 let reassembled = reassemble_sub_floor_runs(token, gunzip_attempts_left);
603 match reassembled {
604 Some(fragment) if !base.contains(&fragment) => {
605 out.push_str(&base);
606 out.push(' ');
607 out.push_str(&fragment);
608 }
609 _ => out.push_str(&base),
610 }
611 last = end;
612 }
613 out.push_str(&content[last..]);
614 out
615}
616
617fn decode_runs(token: &str, gunzip_attempts_left: &mut u32) -> String {
621 let chars: Vec<(usize, char)> = token.char_indices().collect();
622 let mut out = String::new();
623 let mut last = 0usize;
624 let mut idx = 0usize;
625 while idx < chars.len() {
626 let (start, c) = chars[idx];
627 if !is_b64_data(c) || (idx > 0 && is_b64_data(chars[idx - 1].1)) {
628 idx += 1;
629 continue;
630 }
631 let mut end_idx = idx;
632 while end_idx < chars.len() && is_b64_data(chars[end_idx].1) {
633 end_idx += 1;
634 }
635 let run_len = end_idx - idx;
636 let mut absorb = 0usize;
637 while absorb < 2
638 && chars
639 .get(end_idx + absorb)
640 .is_some_and(|(_, rc)| *rc == '=')
641 {
642 absorb += 1;
643 }
644 if run_len >= 12 {
645 let run: String = chars[idx..end_idx].iter().map(|(_, c)| c).collect();
646 out.push_str(&token[last..start]);
647 match decode_token(
648 &run,
649 FALLBACK_PRINTABLE_RATIO_THRESHOLD,
650 gunzip_attempts_left,
651 ) {
652 Some(decoded) => out.push_str(&decoded),
653 None => out.push_str(&token[start..start + run_len + absorb]),
654 }
655 last = start + run_len + absorb;
656 }
657 idx = end_idx.max(idx + 1);
658 }
659 out.push_str(&token[last..]);
660 out
661}
662
663fn reassemble_sub_floor_runs(token: &str, gunzip_attempts_left: &mut u32) -> Option<String> {
664 let chars: Vec<(usize, char)> = token.char_indices().collect();
665 let mut fragments = String::new();
666 let mut idx = 0usize;
667 while idx < chars.len() {
668 let (_, c) = chars[idx];
669 if is_b64_data(c) && (idx == 0 || !is_b64_data(chars[idx - 1].1)) {
670 let mut end_idx = idx;
671 while end_idx < chars.len() && is_b64_data(chars[end_idx].1) {
672 end_idx += 1;
673 }
674 let run_len = end_idx - idx;
675 if run_len <= 11 {
676 fragments.extend(chars[idx..end_idx].iter().map(|(_, c)| c));
677 }
678 idx = end_idx;
679 continue;
680 }
681 idx += 1;
682 }
683 if fragments.chars().count() < 12 {
684 return None;
685 }
686 decode_token(
687 &fragments,
688 FALLBACK_PRINTABLE_RATIO_THRESHOLD,
689 gunzip_attempts_left,
690 )
691}
692
693#[must_use]
700pub fn strip_sql_comments(content: &str) -> String {
701 let bytes = content.as_bytes();
702 let mut out = String::with_capacity(content.len());
703 let mut i = 0usize;
704 while i < bytes.len() {
705 if bytes[i] == b'/'
706 && i + 1 < bytes.len()
707 && bytes[i + 1] == b'*'
708 && let Some((body_start, close_end)) = block_comment_span(content, i)
709 {
710 out.push(' ');
711 out.push_str(&content[body_start..close_end - 2]);
712 out.push(' ');
713 i = close_end;
714 continue;
715 }
716 if bytes[i] == b'-' && i + 1 < bytes.len() && bytes[i + 1] == b'-' {
717 out.push(' ');
718 i += 2;
719 continue;
720 }
721 if bytes[i] == b'#' {
722 out.push(' ');
723 i += 1;
724 continue;
725 }
726 let ch = content[i..].chars().next().unwrap_or('/');
727 out.push(ch);
728 i += ch.len_utf8();
729 }
730 out
731}
732
733fn block_comment_span(content: &str, start: usize) -> Option<(usize, usize)> {
739 let bytes = content.as_bytes();
740 if bytes.get(start + 2) == Some(&b'!') {
741 return None;
742 }
743 let preceding_is_word =
744 start > 0 && char_before(content, start).is_some_and(|(_, c)| py_is_word(c));
745 let first_close = content[start + 2..]
746 .find("*/")
747 .map(|rel| (start + 2 + rel, start + 2 + rel + 2));
748 if !preceding_is_word {
749 return first_close.map(|(_, close_end)| (start + 2, close_end));
752 }
753 let mut cursor = start + 2;
755 while let Some(rel) = content[cursor..].find("*/") {
756 let close = cursor + rel;
757 let close_end = close + 2;
758 let after_is_word = content[close_end..].chars().next().is_some_and(py_is_word);
759 if !after_is_word {
760 return Some((start + 2, close_end));
761 }
762 cursor = close + 1;
763 }
764 None
765}
766
767fn char_before(s: &str, byte_idx: usize) -> Option<(usize, char)> {
768 s[..byte_idx].char_indices().next_back()
769}
770
771fn py_is_word(c: char) -> bool {
772 c.is_alphanumeric() || c == '_'
773}
774
775fn take_chars(content: &str, count: usize) -> String {
780 content.chars().take(count).collect()
781}
782
783fn char_len(content: &str) -> usize {
784 content.chars().count()
785}
786
787#[doc(hidden)]
790#[must_use]
791pub fn extract_attack_regions(content: &str, max_content_length: usize) -> Vec<(usize, usize)> {
792 let max_regions = usize::min(100, max_content_length / 100);
793 let mut regions: Vec<(usize, usize)> = Vec::new();
794
795 let cp_index = |byte_idx: usize| content[..byte_idx].chars().count();
797 let total_chars = char_len(content);
798
799 for indicator in ATTACK_INDICATORS.iter() {
800 let mut found: Vec<(usize, usize)> = Vec::new();
801 for m in indicator.find_iter(content) {
802 if found.len() >= max_regions {
803 break;
804 }
805 let start = cp_index(m.start()).saturating_sub(100);
806 let end = usize::min(cp_index(m.end()) + 100, total_chars);
807 found.push((start, end));
808 }
809 regions.extend(found);
810 if regions.len() >= max_regions {
811 break;
812 }
813 }
814
815 if regions.is_empty() {
816 return Vec::new();
817 }
818 regions.sort_unstable();
819 let mut merged: Vec<(usize, usize)> = Vec::with_capacity(regions.len());
820 for (start, end) in regions {
821 if let Some(last) = merged.last_mut()
822 && start <= last.1
823 {
824 last.1 = usize::max(last.1, end);
825 } else {
826 merged.push((start, end));
827 }
828 }
829 merged.truncate(max_regions);
830 merged
831}
832
833fn consume_gap(
834 content_chars: &[char],
835 last_end: usize,
836 start: usize,
837 gap_budget: usize,
838) -> (String, usize) {
839 let gap_len = start - last_end;
840 if gap_len <= gap_budget {
841 return (
842 content_chars[last_end..start].iter().collect(),
843 gap_budget - gap_len,
844 );
845 }
846 let chunk_len = gap_budget - 1;
847 let piece = if chunk_len > 0 {
848 content_chars[last_end..last_end + chunk_len]
849 .iter()
850 .collect::<String>()
851 } else {
852 String::new()
853 };
854 (format!("{piece} "), 0)
855}
856
857fn build_result_with_attack_regions_and_context(
858 content_chars: &[char],
859 attack_regions: &[(usize, usize)],
860 budget: usize,
861) -> String {
862 let attack_length: usize = attack_regions.iter().map(|(s, e)| e - s).sum();
863 let mut gap_budget = budget.saturating_sub(attack_length);
864 let mut result_parts: Vec<String> = Vec::new();
865 let mut last_end = 0usize;
866
867 for (start, end) in attack_regions {
868 if last_end < *start && gap_budget > 0 {
869 let (piece, remaining) = consume_gap(content_chars, last_end, *start, gap_budget);
870 gap_budget = remaining;
871 result_parts.push(piece);
872 }
873 result_parts.push(content_chars[*start..*end].iter().collect());
874 last_end = *end;
875 }
876
877 if last_end < content_chars.len() && gap_budget > 0 {
878 let tail_len = usize::min(content_chars.len() - last_end, gap_budget);
879 result_parts.push(
880 content_chars[last_end..last_end + tail_len]
881 .iter()
882 .collect(),
883 );
884 }
885
886 result_parts.concat()
887}
888
889fn cap_with_tail(content_chars: &[char], max_full_scan_bytes: usize) -> String {
890 let tail = usize::min(FULL_SCAN_TAIL_BYTES, max_full_scan_bytes);
891 let head_len = max_full_scan_bytes - tail;
892 let total = content_chars.len();
893 let mut out = String::new();
894 out.extend(content_chars[..head_len].iter());
895 out.extend(content_chars[total - tail..].iter());
896 out
897}
898
899#[doc(hidden)]
902#[must_use]
903pub fn truncate_safely(
904 content: &str,
905 max_full_scan_bytes: usize,
906 preserve_attacks: bool,
907 max_content_length: usize,
908) -> String {
909 let total = char_len(content);
910 if total <= max_full_scan_bytes {
911 return content.to_owned();
912 }
913 if !preserve_attacks {
914 return take_chars(content, max_full_scan_bytes);
915 }
916
917 let attack_regions = extract_attack_regions(content, max_content_length);
918 let content_chars: Vec<char> = content.chars().collect();
919
920 if attack_regions.is_empty() {
921 return cap_with_tail(&content_chars, max_full_scan_bytes);
922 }
923
924 let attack_length: usize = attack_regions.iter().map(|(s, e)| e - s).sum();
925 if attack_length >= max_full_scan_bytes {
926 let mut result = String::new();
927 let mut remaining = max_full_scan_bytes;
928 for (start, end) in attack_regions {
929 let chunk_len = usize::min(end - start, remaining);
930 result.extend(content_chars[start..start + chunk_len].iter());
931 remaining -= chunk_len;
932 if remaining == 0 {
933 break;
934 }
935 }
936 return result;
937 }
938
939 build_result_with_attack_regions_and_context(
940 &content_chars,
941 &attack_regions,
942 max_full_scan_bytes,
943 )
944}
945
946#[must_use]
953pub fn decode_common_encodings_with_budget(content: &str) -> (String, bool) {
954 let mut current = content.to_owned();
955 let mut gunzip_attempts_left = MAX_GUNZIP_ATTEMPTS_PER_PASS;
956 let mut iterations = 0usize;
957
958 while iterations < MAX_DECODE_ITERATIONS {
959 let original = current.clone();
960 current = decode_overlong_utf8_percent_runs(¤t);
961 current = percent_unquote_ignore(¤t);
962 current = html_escape::decode_html_entities(¤t).into_owned();
963 current = decode_percent_u_escapes(¤t);
964 current = decode_hex_escapes(¤t);
965 current = decode_ldap_hex_escapes(¤t);
966 current = decode_unicode_escapes(¤t);
967 current = normalize_unicode(¤t);
968 current = decode_base64_candidates(¤t, &mut gunzip_attempts_left);
969 if current == original {
970 break;
971 }
972 iterations += 1;
973 }
974 let exhausted = iterations == MAX_DECODE_ITERATIONS;
975 (strip_sql_comments(¤t), exhausted)
976}
977
978#[must_use]
980pub fn decode_common_encodings(content: &str) -> String {
981 decode_common_encodings_with_budget(content).0
982}
983
984#[must_use]
986pub fn preprocess_with_decoded(
987 content: &str,
988 max_full_scan_bytes: usize,
989 preserve_attacks: bool,
990 max_content_length: usize,
991) -> (String, String, bool) {
992 if content.is_empty() {
993 return (String::new(), String::new(), false);
994 }
995
996 let decoded = normalize_unicode(content);
997 let (decoded, exhausted) = decode_common_encodings_with_budget(&decoded);
998 let processed = remove_null_bytes(&decoded);
999 let processed = collapse_whitespace(&processed);
1000 let processed = truncate_safely(
1001 &processed,
1002 max_full_scan_bytes,
1003 preserve_attacks,
1004 max_content_length,
1005 );
1006
1007 (processed, decoded, exhausted)
1008}
1009
1010#[must_use]
1012pub fn preprocess_signal_preserving(
1013 content: &str,
1014 max_full_scan_bytes: usize,
1015 preserve_attacks: bool,
1016 max_content_length: usize,
1017) -> String {
1018 if content.is_empty() {
1019 return String::new();
1020 }
1021 let normalized = normalize_unicode(content);
1022 truncate_safely(
1023 &normalized,
1024 max_full_scan_bytes,
1025 preserve_attacks,
1026 max_content_length,
1027 )
1028}
1029
1030#[must_use]
1032pub fn preprocess(content: &str, max_full_scan_bytes: usize, preserve_attacks: bool) -> String {
1033 preprocess_with_decoded(
1034 content,
1035 max_full_scan_bytes,
1036 preserve_attacks,
1037 DEFAULT_MAX_CONTENT_LENGTH,
1038 )
1039 .0
1040}
1041
1042pub const DEFAULT_MAX_CONTENT_LENGTH: usize = 10_000;
1043
1044#[must_use]
1047pub fn short_base64_additive_view(
1048 content: &str,
1049 max_full_scan_bytes: usize,
1050 preserve_attacks: bool,
1051 max_content_length: usize,
1052) -> String {
1053 static TOKEN_RE: LazyLock<Regex> =
1054 LazyLock::new(|| Regex::new(r"[A-Za-z0-9+/]{4,}").expect("static regex"));
1055 static MARKERS: [char; 4] = ['$', '{', '}', '#'];
1056 const MAX_TOKEN_CHARS: usize = 11;
1057 const MAX_CANDIDATES: usize = 20_000;
1058 const RATIO: f64 = 0.95;
1059
1060 if content.is_empty() {
1061 return String::new();
1062 }
1063 let normalized = normalize_unicode(content);
1064 let truncated = truncate_safely(
1065 &normalized,
1066 max_full_scan_bytes,
1067 preserve_attacks,
1068 max_content_length,
1069 );
1070
1071 let mut fragments: Vec<String> = Vec::new();
1072 for (attempts, m) in TOKEN_RE.find_iter(&truncated).enumerate() {
1073 if attempts >= MAX_CANDIDATES {
1074 break;
1075 }
1076 let token = m.as_str();
1077 if token.chars().count() > MAX_TOKEN_CHARS {
1078 continue;
1079 }
1080 let Some(decoded) = short_token_decode(token) else {
1081 continue;
1082 };
1083 if printable_ratio(&decoded) < RATIO {
1084 continue;
1085 }
1086 if !decoded.chars().any(|c| MARKERS.contains(&c)) {
1087 continue;
1088 }
1089 fragments.push(decoded);
1090 }
1091 fragments.join("\n")
1092}
1093
1094fn short_token_decode(token: &str) -> Option<String> {
1095 let raw = b64_decode_strict(token)?;
1096 String::from_utf8(raw).ok()
1097}
1098
1099#[cfg(test)]
1100mod tests {
1101 use super::*;
1102
1103 #[test]
1104 fn unicode_normalization() {
1105 assert_eq!(normalize_unicode("\u{FF0F}"), "/");
1106 assert_eq!(normalize_unicode("\u{200B}test\u{200C}"), "test");
1107 assert_eq!(normalize_unicode("\u{FF1C}script\u{FF1E}"), "<script>");
1108 assert_eq!(normalize_unicode("\u{FF1B}\u{FF5C}\u{FF06}"), ";|&");
1109 }
1110
1111 #[test]
1112 fn whitespace_and_null_bytes() {
1113 assert_eq!(
1114 collapse_whitespace("test multiple spaces"),
1115 "test multiple spaces"
1116 );
1117 assert_eq!(
1118 collapse_whitespace(" leading trailing "),
1119 "leading trailing"
1120 );
1121 assert_eq!(remove_null_bytes("test\x00null\x00bytes"), "testnullbytes");
1122 }
1123
1124 #[test]
1125 fn decode_common_encodings_variants() {
1126 assert_eq!(decode_common_encodings("%3Cscript%3E"), "<script>");
1128 assert_eq!(decode_common_encodings("<script>"), "<script>");
1130 assert_eq!(decode_common_encodings("%253Cscript%253E"), "<script>");
1132 assert_eq!(decode_common_encodings(r"\x3Cscript\x3E"), "<script>");
1134 let r = decode_common_encodings("SEL/**/ECT * FR/**/OM users");
1136 assert!(r.contains("SEL/**/ECT"), "got: {r}");
1137 }
1138
1139 #[test]
1140 fn sql_comment_markers_become_spaces() {
1141 assert_eq!(strip_sql_comments("' OR 1=1-- rest"), "' OR 1=1 rest");
1142 assert_eq!(strip_sql_comments("# heading"), " heading");
1143 assert_eq!(strip_sql_comments("a -- b # c"), "a b c");
1144 assert_eq!(strip_sql_comments("x /* note */ y"), "x note y");
1146 assert_eq!(strip_sql_comments(" /*!5 */"), " /*!5 */");
1148 }
1149
1150 #[test]
1151 fn base64_candidates_decode() {
1152 let mut gunzip = 8;
1153 assert_eq!(
1154 decode_base64_candidates("PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==", &mut gunzip),
1155 "<script>alert(1)</script>"
1156 );
1157 assert_eq!(
1159 decode_base64_candidates("550e8400-e29b-41d4-a716-446655440000", &mut gunzip),
1160 "550e8400-e29b-41d4-a716-446655440000"
1161 );
1162 assert_eq!(
1164 decode_base64_candidates("aGVsbG8=", &mut gunzip),
1165 "aGVsbG8="
1166 );
1167 }
1168
1169 #[test]
1170 fn overlong_utf8_runs() {
1171 assert_eq!(decode_overlong_utf8_percent_runs("%C0%AF"), "/");
1173 assert_eq!(decode_overlong_utf8_percent_runs("%C3%A9"), "%C3%A9");
1175 }
1176
1177 #[test]
1178 fn ldap_hex_escapes() {
1179 assert_eq!(decode_ldap_hex_escapes(r"\41"), "A");
1180 assert_eq!(decode_hex_escapes(r"\x41"), "A");
1181 assert_eq!(decode_ldap_hex_escapes(r"\x41"), r"\x41");
1182 }
1183
1184 #[test]
1185 fn full_preprocess_pipeline() {
1186 let zwsp = '\u{200B}';
1187 let fullwidth_slash = '\u{FF0F}';
1188 let content = format!(
1189 "{zwsp}<script>{fullwidth_slash}alert(1)</script> multiple spaces %3Cimg%3E\x00null"
1190 );
1191 let result = preprocess(&content, 262_144, true);
1192
1193 assert!(!result.contains('\u{200B}'));
1194 assert!(!result.contains('\u{FF0F}'));
1195 assert!(!result.contains(" "));
1196 assert!(result.contains("<img>"));
1197 assert!(!result.contains('\x00'));
1198 }
1199
1200 #[test]
1201 fn preprocess_empty() {
1202 assert_eq!(preprocess("", 262_144, true), "");
1203 }
1204
1205 #[test]
1206 fn markdown_heading_survives_as_reference() {
1207 let processed = preprocess("# Title\n\n- item one\n- item two", 262_144, true);
1209 assert_eq!(processed, "Title - item one - item two");
1210 }
1211
1212 #[test]
1213 fn extract_attack_regions_padded() {
1214 let content = format!(
1215 "{}<script>alert(1)</script>{}",
1216 "a".repeat(150),
1217 "b".repeat(150)
1218 );
1219 let regions = extract_attack_regions(&content, 10_000);
1220 assert_eq!(regions.len(), 1);
1221 assert_eq!(regions[0], (50, 257));
1222 }
1223 #[test]
1224 fn b64_span_after_multibyte_no_underflow() {
1225 let content = "h\u{e9}llo ++++++++++++++++++++++++++++++++++++";
1229 let _ = decode_common_encodings(content);
1230 }
1231}