Skip to main content

rusthound_ce/modules/gpo/
types.rs

1use std::fmt;
2
3/// Custom error types for GPO operations and parsing.
4#[derive(Debug)]
5pub enum GpoError {
6    /// The input bytes could not be decoded with a supported character encoding.
7    InvalidEncoding(String),
8    /// The content of the policy file is malformed.
9    MalformedContent(String),
10    /// An underlying I/O error occurred.
11    Io(std::io::Error),
12}
13
14impl fmt::Display for GpoError {
15    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
16        match self {
17            Self::InvalidEncoding(msg) => write!(f, "Invalid policy encoding: {msg}"),
18            Self::MalformedContent(msg) => write!(f, "Malformed policy content: {msg}"),
19            Self::Io(err) => write!(f, "Policy I/O error: {err}"),
20        }
21    }
22}
23
24impl std::error::Error for GpoError {
25    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
26        match self {
27            Self::Io(err) => Some(err),
28            _ => None,
29        }
30    }
31}
32
33impl From<std::io::Error> for GpoError {
34    fn from(err: std::io::Error) -> Self {
35        Self::Io(err)
36    }
37}
38
39/// Represents a single privilege right assignment from GptTmpl.inf.
40#[derive(Debug, Clone, PartialEq, Eq, Default)]
41pub struct PrivilegeAssignment {
42    privilege: String,
43    principals: Vec<String>,
44}
45
46impl PrivilegeAssignment {
47    /// Creates a new `PrivilegeAssignment`.
48    pub fn new(privilege: impl Into<String>, principals: Vec<String>) -> Self {
49        Self {
50            privilege: privilege.into(),
51            principals,
52        }
53    }
54
55    /// Returns the privilege name (e.g., `SeDebugPrivilege`).
56    pub fn privilege(&self) -> &str {
57        &self.privilege
58    }
59
60    /// Returns the raw assigned principals as stored in the policy (preserving any leading `*`).
61    pub fn principals(&self) -> &[String] {
62        &self.principals
63    }
64
65    /// Returns an iterator over normalized principal names/SIDs, stripping any leading `*` prefix.
66    pub fn normalized_principals(&self) -> impl Iterator<Item = &str> {
67        self.principals
68            .iter()
69            .map(|p| p.strip_prefix('*').unwrap_or(p))
70    }
71
72    /// Returns normalized principals that look like Windows SID candidates
73    /// based on the `S-1-` prefix, with any leading `*` stripped.
74    pub fn sid_candidates(&self) -> impl Iterator<Item = &str> {
75        self.normalized_principals()
76            .filter(|p| p.starts_with("S-1-") || p.starts_with("s-1-"))
77    }
78}
79
80/// Represents a parsed GptTmpl.inf security policy.
81#[derive(Debug, Clone, PartialEq, Eq, Default)]
82pub struct GptTmplPolicy {
83    privilege_rights: Vec<PrivilegeAssignment>,
84    restricted_groups: Vec<RestrictedGroupDirective>,
85}
86
87impl GptTmplPolicy {
88    /// Creates a new, empty `GptTmplPolicy`.
89    pub fn new() -> Self {
90        Self::default()
91    }
92
93    /// Creates a new `GptTmplPolicy` with the given privilege rights.
94    pub fn with_privilege_rights(privilege_rights: Vec<PrivilegeAssignment>) -> Self {
95        Self {
96            privilege_rights,
97            restricted_groups: Vec::new(),
98        }
99    }
100
101    /// Creates a policy containing both privilege assignments and Restricted Groups directives.
102    pub fn with_entries(
103        privilege_rights: Vec<PrivilegeAssignment>,
104        restricted_groups: Vec<RestrictedGroupDirective>,
105    ) -> Self {
106        Self {
107            privilege_rights,
108            restricted_groups,
109        }
110    }
111
112    /// Returns a slice of all privilege assignments.
113    pub fn privilege_rights(&self) -> &[PrivilegeAssignment] {
114        &self.privilege_rights
115    }
116
117    /// Looks up a privilege assignment by name (case-insensitive).
118    pub fn get_privilege(&self, privilege_name: &str) -> Option<&PrivilegeAssignment> {
119        self.privilege_rights
120            .iter()
121            .find(|p| p.privilege.eq_ignore_ascii_case(privilege_name))
122    }
123
124    /// Returns the Restricted Groups directives in source order.
125    pub fn restricted_groups(&self) -> &[RestrictedGroupDirective] {
126        &self.restricted_groups
127    }
128}
129
130/// Describes how a Restricted Groups entry changes local group membership.
131///
132/// The parser preserves policy intent and deliberately does not mutate graph edges.
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum RestrictedGroupOperation {
135    /// `__Members`: the listed principals are the complete membership configured by the policy.
136    ReplaceMembers,
137    /// `__Memberof`: the target group is added to each listed parent group.
138    AddToParentGroups,
139}
140
141/// A single entry from the `[Group Membership]` section of `GptTmpl.inf`.
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct RestrictedGroupDirective {
144    target: String,
145    operation: RestrictedGroupOperation,
146    principals: Vec<String>,
147}
148
149impl RestrictedGroupDirective {
150    pub fn new(
151        target: impl Into<String>,
152        operation: RestrictedGroupOperation,
153        principals: Vec<String>,
154    ) -> Self {
155        Self {
156            target: target.into(),
157            operation,
158            principals,
159        }
160    }
161
162    pub fn target(&self) -> &str {
163        &self.target
164    }
165
166    pub fn operation(&self) -> RestrictedGroupOperation {
167        self.operation
168    }
169
170    pub fn principals(&self) -> &[String] {
171        &self.principals
172    }
173}
174
175/// Action requested by a Group Policy Preferences local-group item.
176#[derive(Debug, Clone, Copy, PartialEq, Eq)]
177pub enum GppGroupAction {
178    Create,
179    Delete,
180    Replace,
181    Update,
182}
183
184/// Action requested for a member inside a GPP local-group item.
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186pub enum GppMemberAction {
187    Add,
188    Remove,
189}
190
191fn nonempty_identity(value: Option<String>) -> Option<String> {
192    value.filter(|value| !value.trim().is_empty())
193}
194
195#[derive(Debug, Clone, PartialEq, Eq)]
196pub struct GppGroupMember {
197    sid: Option<String>,
198    name: Option<String>,
199    action: GppMemberAction,
200}
201
202impl GppGroupMember {
203    pub fn new(sid: Option<String>, name: Option<String>, action: GppMemberAction) -> Self {
204        Self {
205            sid: nonempty_identity(sid),
206            name: nonempty_identity(name),
207            action,
208        }
209    }
210
211    /// Returns the nonempty SID when present, otherwise the nonempty name.
212    pub fn principal(&self) -> Option<&str> {
213        self.sid.as_deref().or(self.name.as_deref())
214    }
215
216    pub fn sid(&self) -> Option<&str> {
217        self.sid.as_deref()
218    }
219
220    pub fn name(&self) -> Option<&str> {
221        self.name.as_deref()
222    }
223
224    pub fn action(&self) -> GppMemberAction {
225        self.action
226    }
227}
228
229#[derive(Debug, Clone, PartialEq, Eq)]
230pub struct GppLocalGroup {
231    sid: Option<String>,
232    name: Option<String>,
233    action: GppGroupAction,
234    delete_all_users: bool,
235    delete_all_groups: bool,
236    has_item_level_targeting: bool,
237    members: Vec<GppGroupMember>,
238}
239
240impl GppLocalGroup {
241    #[allow(clippy::too_many_arguments)]
242    pub fn new(
243        sid: Option<String>,
244        name: Option<String>,
245        action: GppGroupAction,
246        delete_all_users: bool,
247        delete_all_groups: bool,
248        has_item_level_targeting: bool,
249        members: Vec<GppGroupMember>,
250    ) -> Self {
251        Self {
252            sid: nonempty_identity(sid),
253            name: nonempty_identity(name),
254            action,
255            delete_all_users,
256            delete_all_groups,
257            has_item_level_targeting,
258            members,
259        }
260    }
261
262    /// Returns the nonempty group SID when present, otherwise the nonempty group name.
263    pub fn target(&self) -> Option<&str> {
264        self.sid.as_deref().or(self.name.as_deref())
265    }
266
267    pub fn sid(&self) -> Option<&str> {
268        self.sid.as_deref()
269    }
270
271    pub fn name(&self) -> Option<&str> {
272        self.name.as_deref()
273    }
274
275    pub fn action(&self) -> GppGroupAction {
276        self.action
277    }
278
279    pub fn delete_all_users(&self) -> bool {
280        self.delete_all_users
281    }
282
283    pub fn delete_all_groups(&self) -> bool {
284        self.delete_all_groups
285    }
286
287    /// If true, a future applicability layer must evaluate targeting before applying
288    /// this directive; membership must never be applied globally without evaluation.
289    pub fn has_item_level_targeting(&self) -> bool {
290        self.has_item_level_targeting
291    }
292
293    pub fn members(&self) -> &[GppGroupMember] {
294        &self.members
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    #[test]
303    fn privilege_assignment_normalized_principals_and_sid_candidates() {
304        let assignment = PrivilegeAssignment::new(
305            "SeRemoteInteractiveLogonRight",
306            vec![
307                "*S-1-5-32-544".to_string(),
308                "S-1-5-32-545".to_string(),
309                "*DOMAIN\\Administrators".to_string(),
310                "LocalUser".to_string(),
311            ],
312        );
313
314        assert_eq!(assignment.privilege(), "SeRemoteInteractiveLogonRight");
315        assert_eq!(assignment.principals().len(), 4);
316
317        let normalized: Vec<&str> = assignment.normalized_principals().collect();
318        assert_eq!(
319            normalized,
320            vec![
321                "S-1-5-32-544",
322                "S-1-5-32-545",
323                "DOMAIN\\Administrators",
324                "LocalUser"
325            ]
326        );
327
328        let sids: Vec<&str> = assignment.sid_candidates().collect();
329        assert_eq!(sids, vec!["S-1-5-32-544", "S-1-5-32-545"]);
330    }
331
332    #[test]
333    fn gpttmpl_policy_lookup_is_case_insensitive() {
334        let policy = GptTmplPolicy::with_privilege_rights(vec![
335            PrivilegeAssignment::new("SeDebugPrivilege", vec!["*S-1-5-32-544".to_string()]),
336            PrivilegeAssignment::new(
337                "SeRemoteInteractiveLogonRight",
338                vec!["*S-1-5-32-555".to_string()],
339            ),
340        ]);
341
342        assert!(policy.get_privilege("sedebugprivilege").is_some());
343        assert!(policy.get_privilege("SEDEBUGPRIVILEGE").is_some());
344        assert!(policy.get_privilege("SeDebugPrivilege").is_some());
345        assert!(policy.get_privilege("SeNonExistentPrivilege").is_none());
346    }
347
348    #[test]
349    fn gpo_error_display_formatting() {
350        let err = GpoError::MalformedContent("invalid syntax at line 5".to_string());
351        assert_eq!(
352            err.to_string(),
353            "Malformed policy content: invalid syntax at line 5"
354        );
355
356        let err2 = GpoError::InvalidEncoding("unsupported encoding".to_string());
357        assert_eq!(
358            err2.to_string(),
359            "Invalid policy encoding: unsupported encoding"
360        );
361    }
362}