//! Logs audit events for login operations structurally, without including free-form messages from PAM modules that may contain sensitive information. use std::fmt; use crate::error::Error; /// Login operation that produced an audit event. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AuditAction { Authentication, PasswordChange, Session, } impl fmt::Display for AuditAction { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(match self { Self::Authentication => "authentication", Self::PasswordChange => "password_change", Self::Session => "session", }) } } /// Result recorded for a login operation. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AuditOutcome { Started, Succeeded, Failed, PasswordChangeRequired, } impl fmt::Display for AuditOutcome { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(match self { Self::Started => "started", Self::Succeeded => "succeeded", Self::Failed => "failed", Self::PasswordChangeRequired => "password_change_required", }) } } /// Stable category for a PAM status without its free-form message. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PamReason { AuthenticationRejected, AuthenticationUnavailable, AccountExpired, PasswordChangeRequired, TooManyAttempts, Conversation, System, Unknown, } impl fmt::Display for PamReason { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(match self { Self::AuthenticationRejected => "authentication_rejected", Self::AuthenticationUnavailable => "authentication_unavailable", Self::AccountExpired => "account_expired", Self::PasswordChangeRequired => "password_change_required", Self::TooManyAttempts => "too_many_attempts", Self::Conversation => "conversation_error", Self::System => "system_error", Self::Unknown => "unknown_pam_error", }) } } /// Event fields intended for the audit logger. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct AuditEvent<'a> { pub action: AuditAction, pub outcome: AuditOutcome, /// Account name supplied by the user, never a password or token. pub account: &'a str, pub pam_reason: Option, } impl<'a> AuditEvent<'a> { pub const fn started(action: AuditAction, account: &'a str) -> Self { Self { action, outcome: AuditOutcome::Started, account, pam_reason: None, } } pub const fn succeeded(action: AuditAction, account: &'a str) -> Self { Self { action, outcome: AuditOutcome::Succeeded, account, pam_reason: None, } } pub fn failed(action: AuditAction, account: &'a str, error: &Error) -> Self { Self { action, outcome: if error.requires_password_change() { AuditOutcome::PasswordChangeRequired } else { AuditOutcome::Failed }, account, pam_reason: error.pam_status().map(pam_reason), } } } /// Map Linux-PAM status codes to stable categories. pub const fn pam_reason(status: i32) -> PamReason { match status { 6 | 7 => PamReason::AuthenticationRejected, 9 | 10 => PamReason::AuthenticationUnavailable, 11 => PamReason::TooManyAttempts, 12 | 27 => PamReason::PasswordChangeRequired, 13 => PamReason::AccountExpired, 19 | 20 | 21 | 22 | 23 | 24 | 30 | 31 => PamReason::Conversation, 1 | 2 | 3 | 4 | 5 | 14 | 15 | 16 | 17 | 18 | 25 | 26 | 28 | 29 => PamReason::System, _ => PamReason::Unknown, } } #[cfg(test)] mod tests { use super::*; #[test] fn password_change_status_is_classified_without_raw_message() { let error = Error::Pam { operation: "pam_acct_mgmt", status: 12, message: "secret module detail".into(), }; let event = AuditEvent::failed(AuditAction::Authentication, "alice", &error); assert_eq!(event.outcome, AuditOutcome::PasswordChangeRequired); assert_eq!(event.pam_reason, Some(PamReason::PasswordChangeRequired)); assert!(!format!("{event:?}").contains("secret module detail")); } #[test] fn every_known_pam_family_has_a_stable_reason() { assert_eq!(pam_reason(7), PamReason::AuthenticationRejected); assert_eq!(pam_reason(9), PamReason::AuthenticationUnavailable); assert_eq!(pam_reason(13), PamReason::AccountExpired); assert_eq!(pam_reason(11), PamReason::TooManyAttempts); assert_eq!(pam_reason(999), PamReason::Unknown); } }