rusthound-ce 2.5.7

Active Directory data collector for Bloodhound Community Edition written in rust.
Documentation
use std::fmt;

/// Custom error types for GPO operations and parsing.
#[derive(Debug)]
pub enum GpoError {
    /// The input bytes could not be decoded with a supported character encoding.
    InvalidEncoding(String),
    /// The content of the policy file is malformed.
    MalformedContent(String),
    /// An underlying I/O error occurred.
    Io(std::io::Error),
}

impl fmt::Display for GpoError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::InvalidEncoding(msg) => write!(f, "Invalid policy encoding: {msg}"),
            Self::MalformedContent(msg) => write!(f, "Malformed policy content: {msg}"),
            Self::Io(err) => write!(f, "Policy I/O error: {err}"),
        }
    }
}

impl std::error::Error for GpoError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            Self::Io(err) => Some(err),
            _ => None,
        }
    }
}

impl From<std::io::Error> for GpoError {
    fn from(err: std::io::Error) -> Self {
        Self::Io(err)
    }
}

/// Represents a single privilege right assignment from GptTmpl.inf.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PrivilegeAssignment {
    privilege: String,
    principals: Vec<String>,
}

impl PrivilegeAssignment {
    /// Creates a new `PrivilegeAssignment`.
    pub fn new(privilege: impl Into<String>, principals: Vec<String>) -> Self {
        Self {
            privilege: privilege.into(),
            principals,
        }
    }

    /// Returns the privilege name (e.g., `SeDebugPrivilege`).
    pub fn privilege(&self) -> &str {
        &self.privilege
    }

    /// Returns the raw assigned principals as stored in the policy (preserving any leading `*`).
    pub fn principals(&self) -> &[String] {
        &self.principals
    }

    /// Returns an iterator over normalized principal names/SIDs, stripping any leading `*` prefix.
    pub fn normalized_principals(&self) -> impl Iterator<Item = &str> {
        self.principals
            .iter()
            .map(|p| p.strip_prefix('*').unwrap_or(p))
    }

    /// Returns normalized principals that look like Windows SID candidates
    /// based on the `S-1-` prefix, with any leading `*` stripped.
    pub fn sid_candidates(&self) -> impl Iterator<Item = &str> {
        self.normalized_principals()
            .filter(|p| p.starts_with("S-1-") || p.starts_with("s-1-"))
    }
}

/// Represents a parsed GptTmpl.inf security policy.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct GptTmplPolicy {
    privilege_rights: Vec<PrivilegeAssignment>,
}

impl GptTmplPolicy {
    /// Creates a new, empty `GptTmplPolicy`.
    pub fn new() -> Self {
        Self::default()
    }

    /// Creates a new `GptTmplPolicy` with the given privilege rights.
    pub fn with_privilege_rights(privilege_rights: Vec<PrivilegeAssignment>) -> Self {
        Self { privilege_rights }
    }

    /// Returns a slice of all privilege assignments.
    pub fn privilege_rights(&self) -> &[PrivilegeAssignment] {
        &self.privilege_rights
    }

    /// Looks up a privilege assignment by name (case-insensitive).
    pub fn get_privilege(&self, privilege_name: &str) -> Option<&PrivilegeAssignment> {
        self.privilege_rights
            .iter()
            .find(|p| p.privilege.eq_ignore_ascii_case(privilege_name))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn privilege_assignment_normalized_principals_and_sid_candidates() {
        let assignment = PrivilegeAssignment::new(
            "SeRemoteInteractiveLogonRight",
            vec![
                "*S-1-5-32-544".to_string(),
                "S-1-5-32-545".to_string(),
                "*DOMAIN\\Administrators".to_string(),
                "LocalUser".to_string(),
            ],
        );

        assert_eq!(assignment.privilege(), "SeRemoteInteractiveLogonRight");
        assert_eq!(assignment.principals().len(), 4);

        let normalized: Vec<&str> = assignment.normalized_principals().collect();
        assert_eq!(
            normalized,
            vec![
                "S-1-5-32-544",
                "S-1-5-32-545",
                "DOMAIN\\Administrators",
                "LocalUser"
            ]
        );

        let sids: Vec<&str> = assignment.sid_candidates().collect();
        assert_eq!(sids, vec!["S-1-5-32-544", "S-1-5-32-545"]);
    }

    #[test]
    fn gpttmpl_policy_lookup_is_case_insensitive() {
        let policy = GptTmplPolicy::with_privilege_rights(vec![
            PrivilegeAssignment::new("SeDebugPrivilege", vec!["*S-1-5-32-544".to_string()]),
            PrivilegeAssignment::new(
                "SeRemoteInteractiveLogonRight",
                vec!["*S-1-5-32-555".to_string()],
            ),
        ]);

        assert!(policy.get_privilege("sedebugprivilege").is_some());
        assert!(policy.get_privilege("SEDEBUGPRIVILEGE").is_some());
        assert!(policy.get_privilege("SeDebugPrivilege").is_some());
        assert!(policy.get_privilege("SeNonExistentPrivilege").is_none());
    }

    #[test]
    fn gpo_error_display_formatting() {
        let err = GpoError::MalformedContent("invalid syntax at line 5".to_string());
        assert_eq!(
            err.to_string(),
            "Malformed policy content: invalid syntax at line 5"
        );

        let err2 = GpoError::InvalidEncoding("unsupported encoding".to_string());
        assert_eq!(
            err2.to_string(),
            "Invalid policy encoding: unsupported encoding"
        );
    }
}