Skip to main content

rusthound_ce/modules/gpo/
groups_xml.rs

1use quick_xml::events::{BytesStart, Event};
2use quick_xml::Reader;
3
4use crate::modules::gpo::types::{
5    GpoError, GppGroupAction, GppGroupMember, GppLocalGroup, GppMemberAction,
6};
7
8#[derive(Default)]
9struct PendingGroup {
10    sid: Option<String>,
11    name: Option<String>,
12    action: Option<GppGroupAction>,
13    disabled: bool,
14    delete_all_users: bool,
15    delete_all_groups: bool,
16    has_item_level_targeting: bool,
17    members: Vec<GppGroupMember>,
18}
19
20#[derive(Clone, Copy, PartialEq, Eq)]
21enum Element {
22    Groups,
23    Group,
24    Properties,
25    Members,
26    Member,
27    Filters,
28    Other,
29}
30
31impl Element {
32    fn from_name(name: &[u8]) -> Self {
33        match name {
34            b"Groups" => Self::Groups,
35            b"Group" => Self::Group,
36            b"Properties" => Self::Properties,
37            b"Members" => Self::Members,
38            b"Member" => Self::Member,
39            b"Filters" => Self::Filters,
40            _ => Self::Other,
41        }
42    }
43}
44
45/// Parses UTF-8 Group Policy Preferences `Groups.xml` into local-group directives.
46/// Empty input represents no directives; malformed documents return an error.
47///
48/// MS-GPPREF sections 2.2.1.1 and 2.2.1.11.3 define `disabled` on the outer
49/// `Groups` and on `Group/Properties`. `Group disabled` is additionally tolerated
50/// for compatibility with input accepted by the initial parser. Disabled items
51/// are omitted, including when their unused membership attributes are incomplete.
52///
53/// Item-Level Targeting is detected, not evaluated. A future applicability layer
54/// must evaluate it before applying membership; targeted items cannot be applied
55/// globally. Unknown subtrees (including `User` and filter contents) are ignored.
56pub fn parse_groups_xml(content: &[u8]) -> Result<Vec<GppLocalGroup>, GpoError> {
57    use Element::*;
58
59    let mut reader = Reader::from_reader(content);
60    reader.config_mut().trim_text(true);
61    // Share the same structural checks for <Element/> and <Element></Element>.
62    reader.config_mut().expand_empty_elements = true;
63    let mut path = Vec::new();
64    let mut root_seen = false;
65    let mut root_disabled = false;
66    let mut groups = Vec::new();
67    let mut pending: Option<PendingGroup> = None;
68
69    loop {
70        match reader.read_event() {
71            Ok(Event::Start(event)) => {
72                let element = Element::from_name(event.local_name().as_ref());
73                match (path.as_slice(), element) {
74                    ([], Groups) if !root_seen => {
75                        root_seen = true;
76                        root_disabled = bool_attribute(&event, b"disabled")?;
77                    }
78                    ([], _) => {
79                        return Err(malformed("expected a single Groups root element"));
80                    }
81                    ([Groups], Group) => {
82                        pending = Some(PendingGroup {
83                            disabled: root_disabled || bool_attribute(&event, b"disabled")?,
84                            ..PendingGroup::default()
85                        });
86                    }
87                    ([Groups, Group], Properties) => {
88                        if let Some(group) = pending.as_mut() {
89                            group.disabled |= bool_attribute(&event, b"disabled")?;
90                            if !group.disabled {
91                                if group.action.is_some() {
92                                    return Err(malformed("duplicate Group Properties element"));
93                                }
94                                group.sid = attribute(&event, b"groupSid")?;
95                                group.name = attribute(&event, b"groupName")?;
96                                group.action = Some(parse_group_action(
97                                    attribute(&event, b"action")?.as_deref().unwrap_or("U"),
98                                )?);
99                                group.delete_all_users = bool_attribute(&event, b"deleteAllUsers")?;
100                                group.delete_all_groups =
101                                    bool_attribute(&event, b"deleteAllGroups")?;
102                            }
103                        }
104                    }
105                    ([Groups, Group, Properties, Members], Member) => {
106                        if let Some(group) = pending.as_mut().filter(|group| !group.disabled) {
107                            group.members.push(parse_member(&event)?);
108                        }
109                    }
110                    ([Groups, Group], Filters) => {
111                        if let Some(group) = pending.as_mut() {
112                            group.has_item_level_targeting = true;
113                        }
114                    }
115                    _ => {}
116                }
117                path.push(element);
118            }
119            Ok(Event::End(_)) => {
120                let element = path.pop();
121                if element == Some(Group) && path == [Groups] {
122                    let group = pending
123                        .take()
124                        .ok_or_else(|| malformed("unexpected Group end"))?;
125                    if group.disabled {
126                        continue;
127                    }
128                    let action = group
129                        .action
130                        .ok_or_else(|| malformed("GPP group is missing Properties"))?;
131                    let group = GppLocalGroup::new(
132                        group.sid,
133                        group.name,
134                        action,
135                        group.delete_all_users,
136                        group.delete_all_groups,
137                        group.has_item_level_targeting,
138                        group.members,
139                    );
140                    if group.target().is_none() {
141                        return Err(malformed("GPP group is missing groupSid and groupName"));
142                    }
143                    groups.push(group);
144                }
145            }
146            Ok(Event::Text(text)) if !text.is_empty() => {
147                return Err(malformed("unexpected text in Groups.xml"));
148            }
149            Ok(Event::DocType(_) | Event::CData(_)) => {
150                return Err(malformed("unsupported DTD or CDATA in Groups.xml"));
151            }
152            Ok(Event::Eof) => break,
153            Ok(_) => {}
154            Err(error) => {
155                return Err(malformed(format!(
156                    "invalid Groups.xml near byte {}: {error}",
157                    reader.error_position()
158                )));
159            }
160        }
161    }
162
163    if !path.is_empty() {
164        return Err(malformed("unterminated Groups.xml element"));
165    }
166    Ok(groups)
167}
168
169fn malformed(message: impl Into<String>) -> GpoError {
170    GpoError::MalformedContent(message.into())
171}
172
173fn attribute(event: &BytesStart<'_>, name: &[u8]) -> Result<Option<String>, GpoError> {
174    let mut value = None;
175    for attribute in event.attributes() {
176        let attribute =
177            attribute.map_err(|error| malformed(format!("invalid XML attribute: {error}")))?;
178        if attribute.key.local_name().as_ref() == name {
179            if value.is_some() {
180                return Err(malformed("duplicate XML attribute"));
181            }
182            value = Some(
183                attribute
184                    .unescape_value()
185                    .map_err(|error| malformed(format!("invalid XML attribute value: {error}")))?
186                    .into_owned(),
187            );
188        }
189    }
190    Ok(value)
191}
192
193fn bool_attribute(event: &BytesStart<'_>, name: &[u8]) -> Result<bool, GpoError> {
194    match attribute(event, name)?.as_deref().map(str::trim) {
195        None | Some("0") => Ok(false),
196        Some("1") => Ok(true),
197        Some(value) if value.eq_ignore_ascii_case("true") => Ok(true),
198        Some(value) if value.eq_ignore_ascii_case("false") => Ok(false),
199        _ => Err(malformed("invalid boolean XML attribute")),
200    }
201}
202
203fn parse_group_action(value: &str) -> Result<GppGroupAction, GpoError> {
204    match value {
205        value if value.eq_ignore_ascii_case("C") => Ok(GppGroupAction::Create),
206        value if value.eq_ignore_ascii_case("D") => Ok(GppGroupAction::Delete),
207        value if value.eq_ignore_ascii_case("R") => Ok(GppGroupAction::Replace),
208        value if value.eq_ignore_ascii_case("U") => Ok(GppGroupAction::Update),
209        _ => Err(malformed(format!("unsupported GPP group action '{value}'"))),
210    }
211}
212
213fn parse_member(event: &BytesStart<'_>) -> Result<GppGroupMember, GpoError> {
214    let action =
215        attribute(event, b"action")?.ok_or_else(|| malformed("GPP member is missing action"))?;
216    let action = match action.as_str() {
217        value if value.eq_ignore_ascii_case("ADD") => GppMemberAction::Add,
218        value if value.eq_ignore_ascii_case("REMOVE") => GppMemberAction::Remove,
219        value => {
220            return Err(malformed(format!(
221                "unsupported GPP member action '{value}'"
222            )))
223        }
224    };
225    let member = GppGroupMember::new(
226        attribute(event, b"sid")?,
227        attribute(event, b"name")?,
228        action,
229    );
230    if member.principal().is_none() {
231        return Err(malformed("GPP member is missing sid and name"));
232    }
233    Ok(member)
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    fn parse_item(properties: &str, members: &str) -> Result<Vec<GppLocalGroup>, GpoError> {
241        parse_groups_xml(format!(
242            "<Groups><Group><Properties {properties}><Members>{members}</Members></Properties></Group></Groups>"
243        ).as_bytes())
244    }
245
246    #[test]
247    fn preserves_sid_precedence_actions_and_targeting() {
248        let xml = br#"<?xml version="1.0" encoding="utf-8"?>
249<Groups><Group><Properties action="R" groupName="Administrators" groupSid="S-1-5-32-544" deleteAllUsers="1" deleteAllGroups="0"><Members><Member name="DOMAIN\alice" sid="S-1-5-21-1-2-3-1001" action="ADD"/><Member name="DOMAIN\old" action="REMOVE"/></Members></Properties><Filters><FilterOs bool="AND"/></Filters></Group></Groups>"#;
250        let groups = parse_groups_xml(xml).unwrap();
251        assert_eq!(groups.len(), 1);
252        let group = &groups[0];
253        assert_eq!(group.target(), Some("S-1-5-32-544"));
254        assert_eq!(group.name(), Some("Administrators"));
255        assert_eq!(group.action(), GppGroupAction::Replace);
256        assert!(group.delete_all_users());
257        assert!(!group.delete_all_groups());
258        assert!(group.has_item_level_targeting());
259        assert_eq!(group.members().len(), 2);
260        assert_eq!(group.members()[0].principal(), Some("S-1-5-21-1-2-3-1001"));
261        assert_eq!(group.members()[0].action(), GppMemberAction::Add);
262        assert_eq!(group.members()[1].action(), GppMemberAction::Remove);
263    }
264
265    #[test]
266    fn empty_member_sid_falls_back_to_name_without_trimming() {
267        for sid in ["", " ", "&#x9;&#xA;&#xD;"] {
268            for name in [r"DOMAIN\alice", r" DOMAIN\Help  Desk "] {
269                let groups = parse_item(
270                    r#"groupName="Administrators""#,
271                    &format!(r#"<Member sid="{sid}" name="{name}" action="ADD"/>"#),
272                )
273                .unwrap();
274                let member = &groups[0].members()[0];
275                assert_eq!(member.sid(), None);
276                assert_eq!(member.principal(), Some(name));
277            }
278        }
279    }
280
281    #[test]
282    fn empty_group_sid_falls_back_to_name_without_trimming() {
283        for sid in ["", " ", "&#x9;&#xA;&#xD;"] {
284            for name in ["Administrators", " Local  Operators "] {
285                let groups =
286                    parse_item(&format!(r#"groupSid="{sid}" groupName="{name}""#), "").unwrap();
287                assert_eq!(groups[0].sid(), None);
288                assert_eq!(groups[0].target(), Some(name));
289            }
290        }
291    }
292
293    #[test]
294    fn empty_names_are_absent_when_sids_are_present() {
295        let groups = parse_item(
296            r#"groupSid="S-1-5-32-544" groupName=" ""#,
297            r#"<Member sid="S-1-5-21-1-2-3-1001" name="" action="ADD"/>"#,
298        )
299        .unwrap();
300        assert_eq!(groups[0].name(), None);
301        assert_eq!(groups[0].members()[0].name(), None);
302        assert_eq!(
303            groups[0].members()[0].principal(),
304            Some("S-1-5-21-1-2-3-1001")
305        );
306    }
307
308    #[test]
309    fn rejects_missing_or_blank_identities() {
310        for attributes in ["", r#"sid="" name=" ""#, r#"name="&#x9;""#] {
311            assert!(matches!(
312                parse_item(
313                    r#"groupName="Administrators""#,
314                    &format!(r#"<Member {attributes} action="ADD"/>"#)
315                ),
316                Err(GpoError::MalformedContent(_))
317            ));
318        }
319        for attributes in ["", r#"groupSid="" groupName=" ""#, r#"groupName="&#x9;""#] {
320            assert!(matches!(
321                parse_item(attributes, ""),
322                Err(GpoError::MalformedContent(_))
323            ));
324        }
325    }
326
327    #[test]
328    fn root_disabled_suppresses_every_item() {
329        for disabled in ["1", "true", "TRUE"] {
330            let xml = format!(
331                r#"<Groups disabled="{disabled}"><Group><Properties groupName="Administrators"/></Group><Group><Properties groupName="Remote Desktop Users"/></Group></Groups>"#
332            );
333            assert!(parse_groups_xml(xml.as_bytes()).unwrap().is_empty());
334        }
335    }
336
337    #[test]
338    fn root_enabled_preserves_items() {
339        for disabled in ["0", "false", "FALSE"] {
340            let xml = format!(
341                r#"<Groups disabled="{disabled}"><Group><Properties groupName="Administrators"/></Group></Groups>"#
342            );
343            assert_eq!(parse_groups_xml(xml.as_bytes()).unwrap().len(), 1);
344        }
345    }
346
347    #[test]
348    fn properties_disabled_suppresses_only_that_item() {
349        let xml = br#"<Groups><Group><Properties disabled="1" groupName="Skip"/></Group><Group><Properties disabled="0" groupName="Keep"/></Group></Groups>"#;
350        let groups = parse_groups_xml(xml).unwrap();
351        assert_eq!(groups.len(), 1);
352        assert_eq!(groups[0].target(), Some("Keep"));
353    }
354
355    #[test]
356    fn tolerates_group_disabled_without_overriding_properties() {
357        let xml = br#"<Groups><Group disabled="1"><Properties disabled="0" groupName="Skip"/></Group><Group disabled="0"><Properties disabled="1" groupName="Skip too"/></Group><Group disabled="0"><Properties groupName="Keep"/></Group></Groups>"#;
358        let groups = parse_groups_xml(xml).unwrap();
359        assert_eq!(groups.len(), 1);
360        assert_eq!(groups[0].target(), Some("Keep"));
361    }
362
363    #[test]
364    fn disabled_items_do_not_require_unused_membership_attributes() {
365        for xml in [
366            r#"<Groups disabled="1"><Group/></Groups>"#,
367            r#"<Groups><Group disabled="1"/></Groups>"#,
368            r#"<Groups><Group><Properties disabled="1"><Members><Member/></Members></Properties></Group></Groups>"#,
369        ] {
370            assert!(parse_groups_xml(xml.as_bytes()).unwrap().is_empty());
371        }
372    }
373
374    #[test]
375    fn rejects_invalid_disabled_and_delete_flags() {
376        for xml in [
377            r#"<Groups disabled="unknown"/>"#,
378            r#"<Groups><Group disabled="unknown"/></Groups>"#,
379            r#"<Groups><Group><Properties disabled="unknown" groupName="Admins"/></Group></Groups>"#,
380            r#"<Groups><Group><Properties groupName="Admins" deleteAllUsers="unknown"/></Group></Groups>"#,
381            r#"<Groups><Group><Properties groupName="Admins" deleteAllGroups="unknown"/></Group></Groups>"#,
382        ] {
383            assert!(matches!(
384                parse_groups_xml(xml.as_bytes()),
385                Err(GpoError::MalformedContent(_))
386            ));
387        }
388    }
389
390    #[test]
391    fn missing_member_action_is_an_error() {
392        let result = parse_item(
393            r#"groupName="Administrators""#,
394            r#"<Member name="DOMAIN\alice"/>"#,
395        );
396        assert!(
397            matches!(result, Err(GpoError::MalformedContent(message)) if message.contains("missing action"))
398        );
399    }
400
401    #[test]
402    fn rejects_unsupported_member_actions() {
403        for action in ["", " ", "DELETE", "DEL", "UNKNOWN"] {
404            assert!(matches!(
405                parse_item(
406                    r#"groupName="Administrators""#,
407                    &format!(r#"<Member name="DOMAIN\alice" action="{action}"/>"#)
408                ),
409                Err(GpoError::MalformedContent(_))
410            ));
411        }
412    }
413
414    #[test]
415    fn preserves_group_actions_and_defaults_only_missing_action_to_update() {
416        for (action, expected) in [
417            ("C", GppGroupAction::Create),
418            ("D", GppGroupAction::Delete),
419            ("R", GppGroupAction::Replace),
420            ("U", GppGroupAction::Update),
421        ] {
422            let groups =
423                parse_item(&format!(r#"groupName="Admins" action="{action}""#), "").unwrap();
424            assert_eq!(groups[0].action(), expected);
425        }
426        let groups = parse_item(r#"groupName="Admins""#, "").unwrap();
427        assert_eq!(groups[0].action(), GppGroupAction::Update);
428        for action in ["", "X"] {
429            assert!(matches!(
430                parse_item(&format!(r#"groupName="Admins" action="{action}""#), ""),
431                Err(GpoError::MalformedContent(_))
432            ));
433        }
434    }
435
436    #[test]
437    fn preserves_delete_flags_and_false_defaults() {
438        for (attributes, expected) in [
439            ("", (false, false)),
440            (r#"deleteAllUsers="1""#, (true, false)),
441            (r#"deleteAllGroups="1""#, (false, true)),
442            (r#"deleteAllUsers="1" deleteAllGroups="1""#, (true, true)),
443            (r#"deleteAllUsers="0" deleteAllGroups="0""#, (false, false)),
444        ] {
445            let groups = parse_item(&format!(r#"groupName="Admins" {attributes}"#), "").unwrap();
446            assert_eq!(
447                (groups[0].delete_all_users(), groups[0].delete_all_groups()),
448                expected
449            );
450        }
451    }
452
453    #[test]
454    fn accepts_empty_input_and_empty_groups() {
455        for xml in ["", " \r\n", "<Groups/>", "<Groups></Groups>"] {
456            assert!(parse_groups_xml(xml.as_bytes()).unwrap().is_empty());
457        }
458    }
459
460    #[test]
461    fn rejects_malformed_truncated_and_multiple_root_documents() {
462        for xml in [
463            "<",
464            "<Groups>",
465            "<Groups><Group>",
466            r#"<Groups><Group><Properties groupName="Admins"/>"#,
467            r#"<Groups><Group><Properties groupName="Admins"/></Group>"#,
468            "<Groups><Group></Groups>",
469            "<Groups/><Groups/>",
470            "<Other/>",
471            "not xml",
472            "<Groups/>trailing",
473            "<Groups><!",
474            "<Groups></Groups></Groups>",
475        ] {
476            assert!(
477                matches!(
478                    parse_groups_xml(xml.as_bytes()),
479                    Err(GpoError::MalformedContent(_))
480                ),
481                "{xml}"
482            );
483        }
484    }
485
486    #[test]
487    fn rejects_missing_or_duplicate_properties() {
488        for xml in [
489            "<Groups><Group/></Groups>",
490            "<Groups><Group></Group></Groups>",
491            r#"<Groups><Group><Properties groupName="First"/><Properties groupName="Second"/></Group></Groups>"#,
492        ] {
493            assert!(matches!(
494                parse_groups_xml(xml.as_bytes()),
495                Err(GpoError::MalformedContent(_))
496            ));
497        }
498    }
499
500    #[test]
501    fn scopes_members_and_targeting_to_the_correct_item() {
502        let xml = br#"<Groups><User><Properties userName="Ignored"/></User><Group><Properties groupName="First"><Members><Member name="DOMAIN\alice" action="ADD"></Member></Members></Properties><Filters><Group><Properties groupName="Not a directive"><Members><Member name="DOMAIN\other" action="ADD"/></Members></Properties></Group></Filters></Group><Group><Properties groupName="Second"/><Filters/></Group><Group><Properties groupName="Third"/></Group></Groups>"#;
503        let groups = parse_groups_xml(xml).unwrap();
504        assert_eq!(
505            groups.iter().map(GppLocalGroup::target).collect::<Vec<_>>(),
506            vec![Some("First"), Some("Second"), Some("Third")]
507        );
508        assert_eq!(groups[0].members().len(), 1);
509        assert_eq!(groups[0].members()[0].principal(), Some(r"DOMAIN\alice"));
510        assert!(groups[0].has_item_level_targeting());
511        assert!(groups[1].has_item_level_targeting());
512        assert!(!groups[2].has_item_level_targeting());
513        assert!(groups[2].members().is_empty());
514    }
515
516    #[test]
517    fn accepts_namespace_prefixes_and_unescapes_names() {
518        let xml = br#"<g:Groups xmlns:g="urn:synthetic:gpp"><g:Group><g:Properties groupName="Local &amp; Operators"><g:Members><g:Member name="DOMAIN\a&amp;b" action="ADD"/></g:Members></g:Properties></g:Group></g:Groups>"#;
519        let groups = parse_groups_xml(xml).unwrap();
520        assert_eq!(groups[0].target(), Some("Local & Operators"));
521        assert_eq!(groups[0].members()[0].principal(), Some(r"DOMAIN\a&b"));
522    }
523
524    #[test]
525    fn rejects_duplicate_attributes_and_unknown_entities() {
526        for properties in [
527            r#"groupName="Admins" groupName="Other""#,
528            r#"groupName="&unknown;""#,
529            r#"groupName="Admins" broken"#,
530        ] {
531            assert!(matches!(
532                parse_item(properties, ""),
533                Err(GpoError::MalformedContent(_))
534            ));
535        }
536    }
537}