use std::collections::HashSet;
use crate::redact::{SensitiveNames, redact_headers, redact_url_for_display};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LogLevel {
Info,
Debug,
Warning,
Error,
Critical,
}
impl LogLevel {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Info => "INFO",
Self::Debug => "DEBUG",
Self::Warning => "WARNING",
Self::Error => "ERROR",
Self::Critical => "CRITICAL",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LogType {
Request,
Suspicious,
Other(String),
}
impl LogType {
#[must_use]
pub fn as_str(&self) -> &str {
match self {
Self::Request => "request",
Self::Suspicious => "suspicious",
Self::Other(value) => value,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct LogContext {
pub client_ip: Option<String>,
pub method: Option<String>,
pub url: Option<String>,
pub headers: Vec<(String, String)>,
}
#[must_use]
#[allow(clippy::too_many_arguments)]
#[allow(clippy::needless_pass_by_value)]
#[allow(clippy::implicit_hasher)]
pub fn log_activity(
log_type: LogType,
level: Option<LogLevel>,
reason: &str,
client_ip: Option<&str>,
method: Option<&str>,
url: Option<&str>,
headers: Option<&[(&str, &str)]>,
passive_mode: bool,
trigger_info: &str,
check_name: Option<&str>,
muted_check_logs: Option<&HashSet<String>>,
sensitive: &SensitiveNames,
) -> Option<String> {
if let Some(name) = check_name
&& muted_check_logs.is_some_and(|muted| muted.contains(name))
{
return None;
}
let level = level?;
let ip = client_ip.unwrap_or("unknown");
let redacted_url = url.map(|value| redact_url_for_display(value, sensitive));
let redacted_headers = headers.map(|values| redact_headers(values, sensitive));
let details = match log_type {
LogType::Request => format!(
"Request from {ip}: {} {}",
method.unwrap_or_default(),
redacted_url.as_deref().unwrap_or_default()
),
LogType::Suspicious => {
if passive_mode {
format!(
"[PASSIVE MODE] Penetration attempt detected from {ip}: {} {}",
method.unwrap_or_default(),
redacted_url.as_deref().unwrap_or_default()
)
} else {
format!(
"Suspicious activity detected from {ip}: {} {}",
method.unwrap_or_default(),
redacted_url.as_deref().unwrap_or_default()
)
}
}
LogType::Other(ref kind) => format!(
"{} from {ip}: {} {}",
capitalize(kind),
method.unwrap_or_default(),
redacted_url.as_deref().unwrap_or_default()
),
};
let reason_message = match log_type {
LogType::Request => {
format!("Headers: {}", display_headers(redacted_headers.as_ref()))
}
LogType::Suspicious => {
if passive_mode {
let mut message = String::new();
if !trigger_info.is_empty() {
message.push_str("Trigger: ");
message.push_str(trigger_info);
message.push_str(" - ");
}
message.push_str("Headers: ");
message.push_str(&display_headers(redacted_headers.as_ref()));
message
} else {
format!(
"Reason: {reason} - Headers: {}",
display_headers(redacted_headers.as_ref())
)
}
}
LogType::Other(_) => format!(
"Details: {reason} - Headers: {}",
display_headers(redacted_headers.as_ref())
),
};
let _ = level; Some(format!("{details} - {reason_message}"))
}
fn display_headers(headers: Option<&Vec<(String, String)>>) -> String {
headers.map_or_else(
|| "{}".to_owned(),
|pairs| {
let inner: Vec<String> = pairs
.iter()
.map(|(key, value)| format!("'{key}': '{value}'"))
.collect();
format!("{{{}}}", inner.join(", "))
},
)
}
fn capitalize(text: &str) -> String {
let mut chars = text.chars();
chars.next().map_or_else(String::new, |first| {
first.to_uppercase().collect::<String>() + chars.as_str()
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::redact::SensitiveNames;
fn names() -> SensitiveNames {
SensitiveNames::default()
}
#[test]
fn request_shape_matches_the_reference() {
let line = log_activity(
LogType::Request,
Some(LogLevel::Info),
"",
Some("192.0.2.1"),
Some("GET"),
Some("/login?token=abc"),
Some(&[("Authorization", "Bearer x")]),
false,
"",
None,
None,
&names(),
)
.expect("logged");
assert_eq!(
line,
"Request from 192.0.2.1: GET /login?token=[REDACTED] - Headers: \
{'Authorization': '[REDACTED]'}"
);
}
#[test]
fn suspicious_active_shape_matches_the_reference() {
let line = log_activity(
LogType::Suspicious,
Some(LogLevel::Warning),
"Suspicious activity detected for IP: 192.0.2.1 - sqli",
Some("192.0.2.1"),
Some("POST"),
Some("/q"),
None,
false,
"",
None,
None,
&names(),
)
.expect("logged");
assert_eq!(
line,
"Suspicious activity detected from 192.0.2.1: POST /q - Reason: \
Suspicious activity detected for IP: 192.0.2.1 - sqli - Headers: {}"
);
}
#[test]
fn suspicious_passive_shape_matches_the_reference() {
let line = log_activity(
LogType::Suspicious,
Some(LogLevel::Warning),
"ignored in passive mode",
Some("192.0.2.1"),
Some("GET"),
Some("/q"),
None,
true,
"sqli in query",
None,
None,
&names(),
)
.expect("logged");
assert_eq!(
line,
"[PASSIVE MODE] Penetration attempt detected from 192.0.2.1: GET /q - \
Trigger: sqli in query - Headers: {}"
);
}
#[test]
fn passive_without_trigger_skips_the_trigger_segment() {
let line = log_activity(
LogType::Suspicious,
Some(LogLevel::Warning),
"",
Some("192.0.2.1"),
Some("GET"),
Some("/q"),
None,
true,
"",
None,
None,
&names(),
)
.expect("logged");
assert!(line.ends_with("/q - Headers: {}"));
}
#[test]
fn generic_shape_capitalizes_the_log_type() {
let line = log_activity(
LogType::Other("blocked".to_owned()),
Some(LogLevel::Error),
"cloud provider",
Some("192.0.2.1"),
Some("GET"),
Some("/"),
None,
false,
"",
None,
None,
&names(),
)
.expect("logged");
assert!(line.starts_with("Blocked from 192.0.2.1: GET / - Details: cloud provider"));
}
#[test]
fn muted_check_logs_suppress_the_line() {
let muted = HashSet::from(["rate_limit".to_owned()]);
let line = log_activity(
LogType::Suspicious,
Some(LogLevel::Warning),
"rate limit",
Some("192.0.2.1"),
Some("GET"),
Some("/"),
None,
false,
"",
Some("rate_limit"),
Some(&muted),
&names(),
);
assert!(line.is_none());
}
#[test]
fn a_none_level_logs_nothing() {
let line = log_activity(
LogType::Request,
None,
"",
Some("192.0.2.1"),
Some("GET"),
Some("/"),
None,
false,
"",
None,
None,
&names(),
);
assert!(line.is_none());
}
#[test]
fn unknown_ip_falls_back_like_the_reference() {
let line = log_activity(
LogType::Request,
Some(LogLevel::Debug),
"",
None,
Some("GET"),
Some("/"),
None,
false,
"",
None,
None,
&names(),
)
.expect("logged");
assert!(line.starts_with("Request from unknown:"));
}
}