feat: handle PAM password policy changes
This commit is contained in:
180
crates/latchd-core/src/audit.rs
Normal file
180
crates/latchd-core/src/audit.rs
Normal file
@@ -0,0 +1,180 @@
|
||||
//! Redacted security audit events.
|
||||
//!
|
||||
//! Audit records deliberately contain no password, token, PAM message, or
|
||||
//! command-line detail. Callers can send the fields to a structured logger
|
||||
//! without accidentally turning an authentication failure into a secret dump.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
/// The security operation that produced an audit event.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AuditAction {
|
||||
/// A PAM authentication attempt.
|
||||
Authentication,
|
||||
/// A PAM password-change transaction.
|
||||
PasswordChange,
|
||||
/// A user session handoff.
|
||||
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",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The outcome of a security operation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AuditOutcome {
|
||||
/// The operation was requested.
|
||||
Started,
|
||||
/// The operation completed successfully.
|
||||
Succeeded,
|
||||
/// The operation was rejected or failed.
|
||||
Failed,
|
||||
/// PAM requires a password change before proceeding.
|
||||
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",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A safe category for a PAM failure, without exposing PAM's free-form text.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PamReason {
|
||||
/// Password or other authentication material was rejected.
|
||||
AuthenticationRejected,
|
||||
/// PAM could not reach its backing authentication service.
|
||||
AuthenticationUnavailable,
|
||||
/// PAM reports that the account has expired.
|
||||
AccountExpired,
|
||||
/// PAM requires a password change.
|
||||
PasswordChangeRequired,
|
||||
/// PAM reports that the retry limit was reached.
|
||||
TooManyAttempts,
|
||||
/// PAM conversation or token handling failed.
|
||||
Conversation,
|
||||
/// A PAM/system operation failed.
|
||||
System,
|
||||
/// An unclassified PAM status was returned.
|
||||
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",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A structured event whose fields are safe to send to an audit logger.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct AuditEvent<'a> {
|
||||
/// Security operation.
|
||||
pub action: AuditAction,
|
||||
/// Operation outcome.
|
||||
pub outcome: AuditOutcome,
|
||||
/// Account name supplied by the user; never a password or token.
|
||||
pub account: &'a str,
|
||||
/// Sanitized PAM failure category, when applicable.
|
||||
pub pam_reason: Option<PamReason>,
|
||||
}
|
||||
|
||||
impl<'a> AuditEvent<'a> {
|
||||
/// Creates an event for an operation that has just started.
|
||||
pub const fn started(action: AuditAction, account: &'a str) -> Self {
|
||||
Self {
|
||||
action,
|
||||
outcome: AuditOutcome::Started,
|
||||
account,
|
||||
pam_reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a successful event.
|
||||
pub const fn succeeded(action: AuditAction, account: &'a str) -> Self {
|
||||
Self {
|
||||
action,
|
||||
outcome: AuditOutcome::Succeeded,
|
||||
account,
|
||||
pam_reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a failure event with a sanitized PAM category.
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps Linux-PAM status codes to stable, non-sensitive 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user