Move login state, authentication, discovery, input, rendering, and tests into focused TUI modules. Add detailed CLI error reports with recovery commands, align the build and host-check documentation, and keep the legacy Go tree outside normal builds. Checks: make check; make integration-test; go test ./... (legacy/go).
156 lines
4.8 KiB
Rust
156 lines
4.8 KiB
Rust
//! 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<PamReason>,
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|