Skip to main content

rusthound_ce/objects/
gpo.rs

1use ldap3::SearchEntry;
2use log::{debug, trace, warn};
3use serde::{Deserialize, Serialize};
4use serde_json::value::Value;
5use std::collections::HashMap;
6use std::error::Error;
7
8use crate::enums::acl::parse_ntsecuritydescriptor;
9use crate::enums::decode_guid_le;
10use crate::objects::common::{AceTemplate, LdapObject, Link, Member, SPNTarget};
11use crate::utils::date::string_to_epoch;
12
13/// Gpo structure
14#[derive(Debug, Clone, Deserialize, Serialize, Default)]
15pub struct Gpo {
16    #[serde(rename = "Properties")]
17    properties: GpoProperties,
18    #[serde(rename = "Aces")]
19    aces: Vec<AceTemplate>,
20    #[serde(rename = "ObjectIdentifier")]
21    object_identifier: String,
22    #[serde(rename = "IsDeleted")]
23    is_deleted: bool,
24    #[serde(rename = "IsACLProtected")]
25    is_acl_protected: bool,
26    #[serde(rename = "ContainedBy")]
27    contained_by: Option<Member>,
28    #[serde(rename = "Links")]
29    links: Vec<Link>,
30}
31
32impl Gpo {
33    // New gpo.
34    pub fn new() -> Self {
35        Self {
36            ..Default::default()
37        }
38    }
39
40    /// Whether the GPO's computer configuration is applicable.
41    pub fn computer_configuration_enabled(&self) -> bool {
42        if self.properties.gpostatus.is_empty() {
43            warn!(
44                "GPO {} has no flags value; treating computer configuration as enabled for SharpHound compatibility",
45                self.properties.distinguishedname
46            );
47            return true;
48        }
49
50        match self.properties.gpostatus.parse::<u32>() {
51            Ok(flags) => flags & 0x2 == 0,
52            Err(_) => {
53                warn!(
54                    "GPO {} has invalid flags value {:?}; skipping computer configuration",
55                    self.properties.distinguishedname, self.properties.gpostatus
56                );
57                false
58            }
59        }
60    }
61
62    pub(crate) fn sysvol_guid(&self) -> Option<String> {
63        self.properties
64            .gpcpath
65            .rsplit(['\\', '/'])
66            .find(|part| !part.is_empty())
67            .map(str::to_uppercase)
68    }
69
70    /// Function to parse and replace value for GPO object.
71    /// <https://bloodhound.readthedocs.io/en/latest/further-reading/json.html#gpos>
72    pub fn parse(
73        &mut self,
74        result: SearchEntry,
75        domain: &str,
76        dn_sid: &mut HashMap<String, String>,
77        sid_type: &mut HashMap<String, String>,
78        domain_sid: &str,
79        schema_guid_map: &HashMap<String, String>,
80    ) -> Result<(), Box<dyn Error>> {
81        let result_dn: String = result.dn.to_uppercase();
82        let result_attrs: HashMap<String, Vec<String>> = result.attrs;
83        let result_bin: HashMap<String, Vec<Vec<u8>>> = result.bin_attrs;
84
85        // Debug for current object
86        debug!("Parse gpo: {result_dn}");
87
88        // Trace all result attributes
89        for (key, value) in &result_attrs {
90            trace!("  {key:?}:{value:?}");
91        }
92        // Trace all bin result attributes
93        for (key, value) in &result_bin {
94            trace!("  {key:?}:{value:?}");
95        }
96
97        // Change all values...
98        self.properties.domain = domain.to_uppercase();
99        self.properties.distinguishedname = result_dn;
100        self.properties.domainsid = domain_sid.to_string();
101
102        // Check and replace value
103        for (key, value) in &result_attrs {
104            match key.as_str() {
105                "displayName" => {
106                    let name = &value[0];
107                    let email = format!("{}@{}", name.to_owned(), domain);
108                    self.properties.name = email.to_uppercase();
109                }
110                "description" => {
111                    self.properties.description = value.first().cloned();
112                }
113                "whenCreated" => {
114                    let epoch = string_to_epoch(&value[0])?;
115                    if epoch.is_positive() {
116                        self.properties.whencreated = epoch;
117                    }
118                }
119                "gPCFileSysPath" => {
120                    self.properties.gpcpath = value[0].to_owned();
121                }
122                "flags" => {
123                    self.properties.gpostatus = value.first().cloned().unwrap_or_default();
124                }
125                "isDeleted" => {
126                    self.is_deleted = true;
127                }
128                _ => {}
129            }
130        }
131
132        // For all, bins attributes
133        for (key, value) in &result_bin {
134            match key.as_str() {
135                "objectGUID" => {
136                    // objectGUID raw to string
137                    self.object_identifier = decode_guid_le(&value[0]).to_owned();
138                }
139                "nTSecurityDescriptor" => {
140                    // nTSecurityDescriptor raw to string
141                    let relations_ace = parse_ntsecuritydescriptor(
142                        self,
143                        &value[0],
144                        "Gpo",
145                        &result_attrs,
146                        &result_bin,
147                        domain,
148                        schema_guid_map,
149                    );
150                    self.aces = relations_ace;
151                }
152                _ => {}
153            }
154        }
155
156        // Push DN and SID in HashMap
157        dn_sid.insert(
158            self.properties.distinguishedname.to_string(),
159            self.object_identifier.to_string(),
160        );
161        // Push DN and Type
162        sid_type.insert(self.object_identifier.to_string(), "Gpo".to_string());
163
164        // Trace and return Gpo struct
165        // trace!("JSON OUTPUT: {:?}",serde_json::to_string(&self).unwrap());
166        Ok(())
167    }
168}
169
170impl LdapObject for Gpo {
171    // To JSON
172    fn to_json(&self) -> Value {
173        serde_json::to_value(self).unwrap()
174    }
175
176    // Get values
177    fn get_object_identifier(&self) -> &String {
178        &self.object_identifier
179    }
180    fn get_is_acl_protected(&self) -> &bool {
181        &self.is_acl_protected
182    }
183    fn get_aces(&self) -> &Vec<AceTemplate> {
184        &self.aces
185    }
186    fn get_spntargets(&self) -> &Vec<SPNTarget> {
187        panic!("Not used by current object.");
188    }
189    fn get_allowed_to_delegate(&self) -> &Vec<Member> {
190        panic!("Not used by current object.");
191    }
192    fn get_links(&self) -> &Vec<Link> {
193        panic!("Not used by current object.");
194    }
195    fn get_contained_by(&self) -> &Option<Member> {
196        &self.contained_by
197    }
198    fn get_child_objects(&self) -> &Vec<Member> {
199        panic!("Not used by current object.");
200    }
201    fn get_haslaps(&self) -> &bool {
202        &false
203    }
204
205    // Get mutable values
206    fn get_aces_mut(&mut self) -> &mut Vec<AceTemplate> {
207        &mut self.aces
208    }
209    fn get_spntargets_mut(&mut self) -> &mut Vec<SPNTarget> {
210        panic!("Not used by current object.");
211    }
212    fn get_allowed_to_delegate_mut(&mut self) -> &mut Vec<Member> {
213        panic!("Not used by current object.");
214    }
215
216    // Edit values
217    fn set_is_acl_protected(&mut self, is_acl_protected: bool) {
218        self.is_acl_protected = is_acl_protected;
219        self.properties.isaclprotected = is_acl_protected;
220    }
221    fn set_aces(&mut self, aces: Vec<AceTemplate>) {
222        self.aces = aces;
223    }
224    fn set_spntargets(&mut self, _spn_targets: Vec<SPNTarget>) {
225        // Not used by current object.
226    }
227    fn set_allowed_to_delegate(&mut self, _allowed_to_delegate: Vec<Member>) {
228        // Not used by current object.
229    }
230    fn set_links(&mut self, links: Vec<Link>) {
231        self.links = links;
232    }
233    fn set_contained_by(&mut self, contained_by: Option<Member>) {
234        self.contained_by = contained_by;
235    }
236    fn set_child_objects(&mut self, _child_objects: Vec<Member>) {
237        // Not used by current object.
238    }
239}
240
241// Gpo properties structure
242#[derive(Debug, Clone, Deserialize, Serialize, Default)]
243pub struct GpoProperties {
244    domain: String,
245    name: String,
246    distinguishedname: String,
247    domainsid: String,
248    isaclprotected: bool,
249    highvalue: bool,
250    description: Option<String>,
251    whencreated: i64,
252    gpcpath: String,
253    gpostatus: String,
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    fn parse_gpo_with_flags(flags: Option<&str>) -> Gpo {
261        let mut attrs = HashMap::from([
262            ("displayName".to_string(), vec!["Test GPO".to_string()]),
263            (
264                "gPCFileSysPath".to_string(),
265                vec![r"\\example.local\SYSVOL\example.local\Policies\{00000000-0000-0000-0000-000000000000}".to_string()],
266            ),
267        ]);
268        if let Some(flags) = flags {
269            attrs.insert("flags".to_string(), vec![flags.to_string()]);
270        }
271        let result = SearchEntry {
272            dn: "CN={00000000-0000-0000-0000-000000000000},CN=Policies,CN=System,DC=example,DC=local".to_string(),
273            attrs,
274            bin_attrs: HashMap::new(),
275        };
276        let mut gpo = Gpo::new();
277        let mut dn_sid = HashMap::new();
278        let mut sid_type = HashMap::new();
279
280        gpo.parse(
281            result,
282            "example.local",
283            &mut dn_sid,
284            &mut sid_type,
285            "S-1-5-21-111111111-222222222-333333333",
286            &HashMap::new(),
287        )
288        .unwrap();
289
290        gpo
291    }
292
293    #[test]
294    fn parse_preserves_gpo_status_for_all_defined_flag_values() {
295        for flags in ["0", "1", "2", "3"] {
296            let gpo = parse_gpo_with_flags(Some(flags));
297            assert_eq!(
298                gpo.to_json()["Properties"]["gpostatus"],
299                flags,
300                "flags={flags} should be retained as gpostatus",
301            );
302        }
303    }
304
305    #[test]
306    fn computer_configuration_applicability_follows_flags_bit_one() {
307        let cases = [
308            (Some("0"), true),
309            (Some("1"), true),
310            (Some("2"), false),
311            (Some("3"), false),
312            (Some("4"), true),
313            (Some("6"), false),
314            (None, true),
315            (Some(""), true),
316            (Some("not-a-number"), false),
317            (Some("4294967296"), false),
318        ];
319
320        for (flags, expected) in cases {
321            let gpo = parse_gpo_with_flags(flags);
322            assert_eq!(
323                gpo.computer_configuration_enabled(),
324                expected,
325                "unexpected computer applicability for flags={flags:?}",
326            );
327        }
328    }
329}