Skip to main content

rusthound_ce/modules/gpo/
local_group.rs

1//! GPO local group and privilege resolution.
2//!
3//! Turns the SYSVOL directives collected per GPO into the two BloodHound
4//! outputs:
5//!
6//!   [Group Membership] / Groups.xml -> GPOChanges on OU and Domain objects
7//!       Administrators (544)        -> LocalAdmins        -> AdminTo
8//!       Remote Desktop Users (555)  -> RemoteDesktopUsers -> CanRDP
9//!       Distributed COM Users (562) -> DcomUsers          -> ExecuteDCOM
10//!       Remote Management Users(580)-> PSRemoteUsers      -> CanPSRemote
11//!
12//!   [Privilege Rights] (SeXxx)      -> UserRights on the affected Computers
13//!
14//! Mirrors SharpHound GPOLocalGroupProcessor for the merge semantics. It reuses
15//! the checker's work: AffectedComputers is already set (add_affected_computers*)
16//! and links already carry the GPO SID (replace_guid_gplink), so this layer only
17//! fills the four local-group vecs and the computers' UserRights.
18
19use std::collections::HashMap;
20
21use crate::objects::common::{GPOChange, LdapObject, Link, Member, UserRight};
22use crate::objects::computer::Computer;
23use crate::objects::domain::Domain;
24use crate::objects::group::Group;
25use crate::objects::ou::Ou;
26use crate::objects::user::User;
27
28use super::sysvol::SysvolGpo;
29use super::types::{
30    GppGroupAction, GppMemberAction, RestrictedGroupDirective,
31    RestrictedGroupOperation,
32};
33
34// ---- group identity ----------------------------------------------------------
35
36/// One of the four built-in local groups tracked for lateral movement.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
38pub enum TargetGroup {
39    LocalAdmins,
40    RemoteDesktopUsers,
41    DcomUsers,
42    PsRemote,
43}
44
45impl TargetGroup {
46    fn from_rid(rid: u32) -> Option<Self> {
47        match rid {
48            544 => Some(Self::LocalAdmins),
49            555 => Some(Self::RemoteDesktopUsers),
50            562 => Some(Self::DcomUsers),
51            580 => Some(Self::PsRemote),
52            _ => None,
53        }
54    }
55
56    fn from_group_name(name: &str) -> Option<Self> {
57        match name.trim().to_ascii_lowercase().as_str() {
58            "administrators" => Some(Self::LocalAdmins),
59            "remote desktop users" => Some(Self::RemoteDesktopUsers),
60            "distributed com users" => Some(Self::DcomUsers),
61            "remote management users" => Some(Self::PsRemote),
62            _ => None,
63        }
64    }
65}
66
67/// A resolved principal: SID plus BloodHound object type.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct TypedPrincipal {
70    pub sid: String,
71    pub object_type: String,
72}
73
74// ---- resolution --------------------------------------------------------------
75
76/// Name/SID -> (SID, type) resolution, backed by the collected LDAP objects.
77pub trait Resolver {
78    fn resolve_name(&self, name: &str) -> Option<(String, String)>;
79    fn type_of_sid(&self, sid: &str) -> Option<String>;
80}
81
82/// Resolver built from the users / groups / computers collected over LDAP.
83pub struct ObjectResolver {
84    by_name: HashMap<String, (String, String)>, // UPPER bare SAM -> (sid, type)
85    by_sid: HashMap<String, String>,            // UPPER sid -> type
86}
87
88fn index_object(
89    sid: &str,
90    display: &str,
91    ty: &str,
92    by_name: &mut HashMap<String, (String, String)>,
93    by_sid: &mut HashMap<String, String>,
94) {
95    if sid.is_empty() {
96        return;
97    }
98    by_sid.insert(sid.to_uppercase(), ty.to_string());
99    let bare = display
100        .rsplit(['\\', '/'])
101        .next()
102        .unwrap_or(display)
103        .split('@')
104        .next()
105        .unwrap_or(display)
106        .trim()
107        .to_uppercase();
108    if !bare.is_empty() {
109        by_name
110            .entry(bare)
111            .or_insert_with(|| (sid.to_string(), ty.to_string()));
112    }
113}
114
115impl ObjectResolver {
116    pub fn build(users: &[User], groups: &[Group], computers: &[Computer]) -> Self {
117        let mut by_name = HashMap::new();
118        let mut by_sid = HashMap::new();
119
120        for u in users {
121            index_object(u.object_identifier(), u.properties().name(), "User", &mut by_name, &mut by_sid);
122        }
123        for g in groups {
124            index_object(g.object_identifier(), g.properties().name(), "Group", &mut by_name, &mut by_sid);
125        }
126        for c in computers {
127            let sid = c.object_identifier();
128            let name = c.properties().name();
129            index_object(sid, name, "Computer", &mut by_name, &mut by_sid);
130            let short = name.split('.').next().unwrap_or(name).to_uppercase();
131            if !short.is_empty() {
132                by_name
133                    .entry(format!("{short}$"))
134                    .or_insert_with(|| (sid.to_string(), "Computer".to_string()));
135            }
136        }
137
138        ObjectResolver { by_name, by_sid }
139    }
140}
141
142impl Resolver for ObjectResolver {
143    fn resolve_name(&self, name: &str) -> Option<(String, String)> {
144        self.by_name.get(&name.to_uppercase()).cloned()
145    }
146    fn type_of_sid(&self, sid: &str) -> Option<String> {
147        self.by_sid.get(&sid.to_uppercase()).cloned()
148    }
149}
150
151/// Extract the RID from an "S-1-5-32-XXX" built-in SID (any leading '*' ok).
152fn builtin_rid(s: &str) -> Option<u32> {
153    let up = s.trim().trim_start_matches('*').to_uppercase();
154    let rest = up.strip_prefix("S-1-5-32-")?;
155    let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
156    digits.parse().ok()
157}
158
159/// Resolve a raw principal string (SID or name) to a typed principal.
160fn resolve_principal(raw: &str, resolver: &impl Resolver, domain: &str) -> Option<TypedPrincipal> {
161    let p = raw.trim().trim_start_matches('*').trim();
162    if p.is_empty() {
163        return None;
164    }
165    if p.len() >= 4 && p[..4].eq_ignore_ascii_case("S-1-") {
166        let sid = p.to_uppercase();
167        // Well-known SIDs (S-1-5-32-*, S-1-5-<10..20>, S-1-1-0, S-1-3-*, S-1-0-*)
168        // are domain-scoped in BloodHound: prefix with the domain, uppercased,
169        // matching GetWellKnownPrincipalObjectIdentifier. A real domain SID
170        // (S-1-5-21-...) is already unique and left as-is.
171        let scoped = domain_scope_wellknown(&sid, domain);
172        let object_type = resolver.type_of_sid(&sid).unwrap_or_else(|| "Base".to_string());
173        return Some(TypedPrincipal { sid: scoped, object_type });
174    }
175    let bare = p.rsplit(['\\', '/']).next().unwrap_or(p);
176    resolver
177        .resolve_name(bare)
178        .map(|(sid, object_type)| TypedPrincipal { sid, object_type })
179}
180
181/// A well-known SID becomes `<DOMAIN>-<SID>` (uppercased); a domain SID stays.
182fn domain_scope_wellknown(sid: &str, domain: &str) -> String {
183    let is_wellknown = sid.starts_with("S-1-5-32-")
184        || (sid.starts_with("S-1-5-") && !sid.starts_with("S-1-5-21-"))
185        || sid.starts_with("S-1-1-")
186        || sid.starts_with("S-1-3-")
187        || sid.starts_with("S-1-0-");
188    if is_wellknown {
189        format!("{}-{}", domain.to_uppercase(), sid)
190    } else {
191        sid.to_string()
192    }
193}
194
195// ---- action model (internal), mirrors SharpHound GroupAction -----------------
196
197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198enum Op {
199    Add,
200    Delete,
201    DeleteUsers,
202    DeleteGroups,
203}
204
205#[derive(Debug, Clone, Copy, PartialEq, Eq)]
206enum Kind {
207    RestrictedMember,
208    RestrictedMemberOf,
209    LocalGroup,
210}
211
212#[derive(Debug, Clone)]
213struct Action {
214    group: TargetGroup,
215    kind: Kind,
216    op: Op,
217    principal: Option<TypedPrincipal>,
218}
219
220/// Build the ordered action list for a single GPO (GPP first, then GptTmpl).
221fn actions_for_gpo(gpo: &SysvolGpo, resolver: &impl Resolver, domain: &str) -> Vec<Action> {
222    let mut out = Vec::new();
223
224    for grp in &gpo.gpp_local_groups {
225        if grp.action() != GppGroupAction::Update {
226            continue;
227        }
228        let target = grp
229            .sid()
230            .and_then(builtin_rid)
231            .and_then(TargetGroup::from_rid)
232            .or_else(|| grp.name().and_then(TargetGroup::from_group_name));
233        let Some(group) = target else { continue };
234
235        if grp.delete_all_users() {
236            out.push(Action { group, kind: Kind::LocalGroup, op: Op::DeleteUsers, principal: None });
237        }
238        if grp.delete_all_groups() {
239            out.push(Action { group, kind: Kind::LocalGroup, op: Op::DeleteGroups, principal: None });
240        }
241        for m in grp.members() {
242            let op = match m.action() {
243                GppMemberAction::Add => Op::Add,
244                GppMemberAction::Remove => Op::Delete,
245            };
246            let principal = m
247                .sid()
248                .and_then(|s| resolve_principal(s, resolver, domain))
249                .or_else(|| m.name().and_then(|n| resolve_principal(n, resolver, domain)));
250            if let Some(p) = principal {
251                out.push(Action { group, kind: Kind::LocalGroup, op, principal: Some(p) });
252            }
253        }
254    }
255
256    for dir in &gpo.restricted_groups {
257        push_restricted(dir, resolver, domain, &mut out);
258    }
259
260    out
261}
262
263fn push_restricted(dir: &RestrictedGroupDirective, resolver: &impl Resolver, domain: &str, out: &mut Vec<Action>) {
264    match dir.operation() {
265        RestrictedGroupOperation::ReplaceMembers => {
266            let Some(group) = builtin_rid(dir.target()).and_then(TargetGroup::from_rid) else {
267                return;
268            };
269            for raw in dir.principals() {
270                if let Some(p) = resolve_principal(raw, resolver, domain) {
271                    out.push(Action { group, kind: Kind::RestrictedMember, op: Op::Add, principal: Some(p) });
272                }
273            }
274        }
275        RestrictedGroupOperation::AddToParentGroups => {
276            let Some(member) = resolve_principal(dir.target(), resolver, domain) else {
277                return;
278            };
279            for raw in dir.principals() {
280                if let Some(group) = builtin_rid(raw).and_then(TargetGroup::from_rid) {
281                    out.push(Action {
282                        group,
283                        kind: Kind::RestrictedMemberOf,
284                        op: Op::Add,
285                        principal: Some(member.clone()),
286                    });
287                }
288            }
289        }
290    }
291}
292
293// ---- merge -------------------------------------------------------------------
294
295/// The four resulting member sets after merging all linked GPOs.
296#[derive(Debug, Default)]
297pub struct MergedGroups {
298    pub local_admins: Vec<TypedPrincipal>,
299    pub remote_desktop_users: Vec<TypedPrincipal>,
300    pub dcom_users: Vec<TypedPrincipal>,
301    pub psremote_users: Vec<TypedPrincipal>,
302}
303
304fn dedup_by_sid(v: &mut Vec<TypedPrincipal>) {
305    let mut seen = std::collections::HashSet::new();
306    v.retain(|p| seen.insert(p.sid.clone()));
307}
308
309/// final = RestrictedMemberOf + (RestrictedMember if any, else LocalGroups), distinct.
310fn merge(actions: &[Action]) -> MergedGroups {
311    let mut merged = MergedGroups::default();
312    for group in [
313        TargetGroup::LocalAdmins,
314        TargetGroup::RemoteDesktopUsers,
315        TargetGroup::DcomUsers,
316        TargetGroup::PsRemote,
317    ] {
318        let mut restricted_member: Vec<TypedPrincipal> = Vec::new();
319        let mut restricted_memberof: Vec<TypedPrincipal> = Vec::new();
320        let mut local_groups: Vec<TypedPrincipal> = Vec::new();
321
322        for a in actions.iter().filter(|a| a.group == group) {
323            match (a.kind, a.op) {
324                (Kind::RestrictedMember, _) => {
325                    if let Some(p) = &a.principal { restricted_member.push(p.clone()); }
326                }
327                (Kind::RestrictedMemberOf, _) => {
328                    if let Some(p) = &a.principal { restricted_memberof.push(p.clone()); }
329                }
330                (Kind::LocalGroup, Op::Add) => {
331                    if let Some(p) = &a.principal { local_groups.push(p.clone()); }
332                }
333                (Kind::LocalGroup, Op::Delete) => {
334                    if let Some(p) = &a.principal { local_groups.retain(|x| x.sid != p.sid); }
335                }
336                (Kind::LocalGroup, Op::DeleteUsers) => {
337                    local_groups.retain(|x| x.object_type != "User");
338                }
339                (Kind::LocalGroup, Op::DeleteGroups) => {
340                    local_groups.retain(|x| x.object_type != "Group");
341                }
342            }
343        }
344
345        let mut final_set = restricted_memberof;
346        if restricted_member.is_empty() {
347            final_set.extend(local_groups);
348        } else {
349            final_set.extend(restricted_member);
350        }
351        dedup_by_sid(&mut final_set);
352
353        match group {
354            TargetGroup::LocalAdmins => merged.local_admins = final_set,
355            TargetGroup::RemoteDesktopUsers => merged.remote_desktop_users = final_set,
356            TargetGroup::DcomUsers => merged.dcom_users = final_set,
357            TargetGroup::PsRemote => merged.psremote_users = final_set,
358        }
359    }
360    merged
361}
362
363/// Compute the merged local groups for a container from its linked GPOs
364/// (already ordered: unenforced first, then enforced).
365pub fn compute_merged(ordered_gpos: &[&SysvolGpo], resolver: &impl Resolver, domain: &str) -> MergedGroups {
366    let mut actions = Vec::new();
367    for gpo in ordered_gpos {
368        actions.extend(actions_for_gpo(gpo, resolver, domain));
369    }
370    merge(&actions)
371}
372
373/// Merge privilege assignments across linked GPOs (union per privilege, dedup by SID).
374pub fn resolve_privileges(
375    ordered_gpos: &[&SysvolGpo],
376    resolver: &impl Resolver,
377    domain: &str,
378) -> Vec<(String, Vec<TypedPrincipal>)> {
379    let mut order: Vec<String> = Vec::new();
380    let mut map: HashMap<String, Vec<TypedPrincipal>> = HashMap::new();
381
382    for gpo in ordered_gpos {
383        for pa in &gpo.privileges {
384            let key = pa.privilege().to_string();
385            if !map.contains_key(&key) {
386                order.push(key.clone());
387                map.insert(key.clone(), Vec::new());
388            }
389            let entry = map.get_mut(&key).unwrap();
390            for raw in pa.principals() {
391                if let Some(p) = resolve_principal(raw, resolver, domain) {
392                    if !entry.iter().any(|x| x.sid == p.sid) {
393                        entry.push(p);
394                    }
395                }
396            }
397        }
398    }
399    order.into_iter().map(|k| { let v = map.remove(&k).unwrap(); (k, v) }).collect()
400}
401
402// ---- output helpers ----------------------------------------------------------
403
404fn to_member(p: &TypedPrincipal) -> Member {
405    let mut m = Member::new();
406    *m.object_identifier_mut() = p.sid.clone();
407    *m.object_type_mut() = p.object_type.clone();
408    m
409}
410
411fn to_members(list: &[TypedPrincipal]) -> Vec<Member> {
412    list.iter().map(to_member).collect()
413}
414
415// ---- driver (reuses the checker: no affected-computers recompute, no GUID
416//      extraction; links already carry the GPO SID) -----------------------------
417
418/// Fill GPOChanges (local groups) on OU/Domain and UserRights (privileges) on
419/// the affected computers already resolved by the checker.
420///
421/// `dn_sid` (ad.mappings.dn_sid) bridges each GPO SYSVOL folder GUID to its SID,
422/// so links (whose GUID was replaced with the GPO SID by replace_guid_gplink)
423/// can be matched back to the collected directives.
424pub fn apply_gpo(
425    ous: &mut [Ou],
426    domains: &mut [Domain],
427    users: &[User],
428    groups: &[Group],
429    computers: &mut Vec<Computer>,
430    sysvol: &[SysvolGpo],
431    dn_sid: &HashMap<String, String>,
432    domain: &str,
433) {
434    let resolver = ObjectResolver::build(users, groups, computers);
435
436    // folder GUID -> GPO SID, the same way replace_guid_gplink matched links.
437    let mut by_sid: HashMap<String, &SysvolGpo> = HashMap::new();
438    for g in sysvol {
439        let needle = g.guid.to_uppercase();
440        if let Some((_, sid)) = dn_sid.iter().find(|(dn, _)| dn.to_uppercase().contains(&needle)) {
441            by_sid.insert(sid.to_uppercase(), g);
442        }
443    }
444
445    log::debug!("[gpo] {} SYSVOL GPO(s) bridged to a SID via dn_sid", by_sid.len());
446    for (sid, g) in &by_sid {
447        log::debug!("[gpo]   {} -> {}", g.guid, sid);
448    }
449
450    // computer SID -> (privilege -> members), accumulated across containers.
451    let mut priv_acc: HashMap<String, HashMap<String, Vec<TypedPrincipal>>> = HashMap::new();
452
453    for ou in ous.iter_mut() {
454        let label = ou.properties().distinguishedname().clone();
455        let links: Vec<Link> = ou.get_links().to_vec();
456        fill_container("OU", &label, &links, ou.gpo_changes_mut(), &by_sid, &resolver, domain, &mut priv_acc);
457    }
458    for dom in domains.iter_mut() {
459        let label = dom.properties().distinguishedname().clone();
460        let links: Vec<Link> = dom.get_links().to_vec();
461        fill_container("Domain", &label, &links, dom.gpo_changes_mut(), &by_sid, &resolver, domain, &mut priv_acc);
462    }
463
464    for c in computers.iter_mut() {
465        let Some(per) = priv_acc.get(c.object_identifier()) else { continue };
466        let ur = c.users_rights_mut();
467        for (privilege, members) in per {
468            let mut right = UserRight::new();
469            *right.privilege_mut() = privilege.clone();
470            *right.results_mut() = to_members(members);
471            *right.collected_mut() = true;
472            ur.push(right);
473        }
474        log::trace!("[gpo] Computer {}: {} UserRight(s) set", c.object_identifier(), per.len());
475    }
476    
477}
478
479fn fill_container(
480    kind: &str,
481    label: &str,
482    links_src: &[Link],
483    changes: &mut GPOChange,
484    by_sid: &HashMap<String, &SysvolGpo>,
485    resolver: &impl Resolver,
486    domain: &str,
487    priv_acc: &mut HashMap<String, HashMap<String, Vec<TypedPrincipal>>>,
488) {
489    // Links now hold (is_enforced, GPO SID). Order unenforced first, then enforced.
490    let mut links: Vec<(bool, String)> = links_src
491        .iter()
492        .map(|l| (*l.is_enforced(), l.guid().clone()))
493        .collect();
494    if links.is_empty() {
495        return;
496    }
497    links.sort_by_key(|(enforced, _)| *enforced);
498
499    let ordered: Vec<&SysvolGpo> = links
500        .iter()
501        .filter_map(|(_, sid)| by_sid.get(&sid.to_uppercase()).copied())
502        .collect();
503    if ordered.is_empty() {
504        return;
505    }
506
507    // Local groups -> this container's GPOChanges (AffectedComputers preserved).
508    let merged = compute_merged(&ordered, resolver, domain);
509    *changes.local_admins_mut() = to_members(&merged.local_admins);
510    *changes.remote_desktop_users_mut() = to_members(&merged.remote_desktop_users);
511    *changes.dcom_users_mut() = to_members(&merged.dcom_users);
512    *changes.psremote_users_mut() = to_members(&merged.psremote_users);
513
514    log::trace!(
515        "[gpo] {kind} {label}: GPOChanges set from {} linked GPO(s) -> {} local admin(s), {} RDP, {} DCOM, {} PSRemote",
516        ordered.len(),
517        merged.local_admins.len(),
518        merged.remote_desktop_users.len(),
519        merged.dcom_users.len(),
520        merged.psremote_users.len(),
521    );
522
523
524    // Privileges -> accumulate onto the computers the checker already resolved.
525    let privs = resolve_privileges(&ordered, resolver, domain);
526    if privs.is_empty() {
527        return;
528    }
529    log::debug!(
530        "[gpo] {kind} {label}: {} privilege(s) resolved, {} affected computer(s)",
531        privs.len(), changes.affected_computers().len()
532    );
533    for m in changes.affected_computers() {
534        let per = priv_acc.entry(m.object_identifier().clone()).or_default();
535        for (privilege, members) in &privs {
536            let e = per.entry(privilege.clone()).or_default();
537            for p in members {
538                if !e.iter().any(|x| x.sid == p.sid) {
539                    e.push(p.clone());
540                }
541            }
542        }
543    }
544}
545
546// ---- tests -------------------------------------------------------------------
547
548#[cfg(test)]
549mod tests {
550    use super::*;
551
552    struct FakeResolver;
553    impl Resolver for FakeResolver {
554        fn resolve_name(&self, name: &str) -> Option<(String, String)> {
555            match name.to_uppercase().as_str() {
556                "ALICE" => Some(("S-1-5-21-1-1-1105".into(), "User".into())),
557                "HELPDESK" => Some(("S-1-5-21-1-1-1200".into(), "Group".into())),
558                _ => None,
559            }
560        }
561        fn type_of_sid(&self, sid: &str) -> Option<String> {
562            match sid {
563                "S-1-5-21-1-1-512" => Some("Group".into()),
564                _ => None,
565            }
566        }
567    }
568
569    fn gpo_with_restricted(target: &str, op: RestrictedGroupOperation, principals: &[&str]) -> SysvolGpo {
570        let mut g = SysvolGpo::default();
571        g.guid = "{G}".into();
572        g.restricted_groups = vec![RestrictedGroupDirective::new(
573            target,
574            op,
575            principals.iter().map(|s| s.to_string()).collect(),
576        )];
577        g
578    }
579
580    #[test]
581    fn replace_members_fills_local_admins() {
582        let g = gpo_with_restricted(
583            "S-1-5-32-544",
584            RestrictedGroupOperation::ReplaceMembers,
585            &["*S-1-5-21-1-1-512", "*ALICE"],
586        );
587        let domain= "ESSOS.LOCAL";
588        let merged = compute_merged(&[&g], &FakeResolver, domain);
589        let sids: Vec<&str> = merged.local_admins.iter().map(|p| p.sid.as_str()).collect();
590        assert_eq!(sids, vec!["S-1-5-21-1-1-512", "S-1-5-21-1-1-1105"]);
591        assert_eq!(merged.local_admins[0].object_type, "Group");
592        assert_eq!(merged.local_admins[1].object_type, "User");
593        assert!(merged.remote_desktop_users.is_empty());
594    }
595
596    #[test]
597    fn memberof_targets_the_parent_group_rid() {
598        let g = gpo_with_restricted(
599            "HELPDESK",
600            RestrictedGroupOperation::AddToParentGroups,
601            &["S-1-5-32-555"],
602        );
603        let domain= "ESSOS.LOCAL";
604        let merged = compute_merged(&[&g], &FakeResolver, domain);
605        assert_eq!(merged.remote_desktop_users.len(), 1);
606        assert_eq!(merged.remote_desktop_users[0].sid, "S-1-5-21-1-1-1200");
607        assert!(merged.local_admins.is_empty());
608    }
609
610    #[test]
611    fn restricted_member_overrides_local_groups() {
612        let g = gpo_with_restricted(
613            "S-1-5-32-544",
614            RestrictedGroupOperation::ReplaceMembers,
615            &["*ALICE"],
616        );
617        let domain= "ESSOS.LOCAL";
618        let merged = compute_merged(&[&g], &FakeResolver, domain);
619        assert_eq!(merged.local_admins.len(), 1);
620        assert_eq!(merged.local_admins[0].sid, "S-1-5-21-1-1-1105");
621    }
622
623    // #[test]
624    // fn privileges_resolve_and_union_by_sid() {
625    //     let mut g = SysvolGpo::default();
626    //     g.guid = "{G}".into();
627    //     g.privileges = vec![PrivilegeAssignment::new(
628    //         "SeRemoteInteractiveLogonRight",
629    //         vec!["*S-1-5-21-1-1-512".into(), "*ALICE".into(), "*S-1-5-21-1-1-512".into()],
630    //     )];
631    //     let out = resolve_privileges(&[&g], &FakeResolver);
632    //     assert_eq!(out.len(), 1);
633    //     assert_eq!(out[0].0, "SeRemoteInteractiveLogonRight");
634    //     let sids: Vec<&str> = out[0].1.iter().map(|p| p.sid.as_str()).collect();
635    //     assert_eq!(sids, vec!["S-1-5-21-1-1-512", "S-1-5-21-1-1-1105"]);
636    // }
637}