use regex::Regex;
use std::sync::LazyLock;
use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
static STRONG_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
&[
r"(?:\.\./){2,}",
r"|(?:\.\.\\){2,}",
r"|(?i:\.\.%2[Ff])",
r"|(?i:\.\.%5[Cc])",
r"|(?i:%2[Ee]%2[Ee])",
r"|(?i:php://filter)",
r"|(?i:php://input)",
r"|(?i:data://)",
r"|(?i:expect://)",
r"|(?i:phar://)",
r"|(?i:zip://)",
r"|(?i:glob://)",
r"|%00",
r"|\x00",
]
.concat(),
)
.unwrap()
});
static WEAK_PATTERNS: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\.\./|\.\.\\").unwrap());
pub struct PathTraversalDetector;
impl Detector for PathTraversalDetector {
fn name(&self) -> &'static str {
"path_traversal"
}
fn detect(&self, input: &str) -> Option<DetectionResult> {
regex_detect(
std::slice::from_ref(&*STRONG_PATTERNS),
self.name(),
AttackCategory::File,
Severity::Critical,
"Path traversal attack detected",
input,
)
.or_else(|| {
regex_detect(
std::slice::from_ref(&*WEAK_PATTERNS),
self.name(),
AttackCategory::File,
Severity::Low,
"Single-level parent directory reference (weak signal)",
input,
)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn name_returns_attack_type() {
assert_eq!(PathTraversalDetector.name(), "path_traversal");
}
#[test]
fn detects_dotdot_traversal() {
for payload in [
"../../../etc/passwd",
"..\\..\\windows\\system32",
"a/../../b",
] {
let r = PathTraversalDetector
.detect(payload)
.unwrap_or_else(|| panic!("expected detection for {:?}", payload));
assert_eq!(r.attack_type, "path_traversal");
assert_eq!(r.category, AttackCategory::File);
assert_eq!(r.severity, Severity::Critical);
assert!(
!r.matched_pattern.is_empty(),
"matched_pattern empty for {:?}",
payload
);
assert!(
r.offset <= payload.len(),
"offset out of range for {:?}",
payload
);
}
}
#[test]
fn detects_encoded_traversal() {
for payload in [
"..%2f..%2fetc/passwd",
"..%2Fetc/passwd",
"%2e%2e%2fetc/passwd",
"%2E%2E/win.ini",
"..%5c..%5cwindows%5csystem32",
"..%5C..%5Cwindows%5Csystem32",
] {
let r = PathTraversalDetector
.detect(payload)
.unwrap_or_else(|| panic!("expected detection for {:?}", payload));
assert!(
!r.matched_pattern.is_empty(),
"matched_pattern empty for {:?}",
payload
);
assert!(
r.offset <= payload.len(),
"offset out of range for {:?}",
payload
);
}
}
#[test]
fn detects_wrappers_and_null_bytes() {
for payload in [
"php://filter/convert.base64-encode/resource=config.php",
"php://input",
"data://text/plain;base64,PD9waHA=",
"expect://id",
"phar://archive.phar",
"zip://archive.zip#a",
"glob://*.php",
"file%00.php",
] {
let r = PathTraversalDetector
.detect(payload)
.unwrap_or_else(|| panic!("expected detection for {:?}", payload));
assert!(
!r.matched_pattern.is_empty(),
"matched_pattern empty for {:?}",
payload
);
assert!(
r.offset <= payload.len(),
"offset out of range for {:?}",
payload
);
}
}
#[test]
fn multi_level_is_critical_single_level_is_low() {
for payload in [
"../../../../etc/passwd",
"../../../etc/passwd",
"a/../../b",
"..\\..\\windows\\system32",
] {
let r = PathTraversalDetector
.detect(payload)
.unwrap_or_else(|| panic!("expected detection for {:?}", payload));
assert_eq!(r.severity, Severity::Critical, "payload {:?}", payload);
}
for input in [
"../etc/passwd",
"include_str!(\"../docs/pet.svg\")",
"import { render } from '../lib/render.js'",
"[guide](../docs/api.md)",
r"C:\Users\erik\..\backup\report.docx",
] {
let r = PathTraversalDetector
.detect(input)
.unwrap_or_else(|| panic!("expected weak detection for {:?}", input));
assert_eq!(r.severity, Severity::Low, "input {:?}", input);
}
}
#[test]
fn ignores_benign_inputs() {
for input in [
"Hello, this is a normal text input.",
"etc/passwd",
"a.b/c",
"..",
"...",
"php:",
"zip:archive.zip",
"/etc/passwd",
r"C:\Users\erik\Documents\report.docx",
"https://example.com/docs/api/guide",
"path%5Csegment%5Cname",
"The file is .. hidden and ... is an ellipsis; see docs/readme.md (relative link).",
] {
assert!(
PathTraversalDetector.detect(input).is_none(),
"false positive: {:?}",
input
);
}
}
#[test]
fn edge_cases() {
assert!(PathTraversalDetector.detect("").is_none());
assert!(PathTraversalDetector.detect(" ").is_none());
assert!(PathTraversalDetector.detect("…/…/秘密のファイル").is_none()); }
}