sdl_parser/
entity.rs

1use std::collections::HashMap;
2
3use anyhow::{anyhow, Result};
4use serde::{Deserialize, Serialize};
5
6use crate::{
7    event::Event, helpers::Connection, training_learning_objective::TrainingLearningObjective,
8    vulnerability::Vulnerability,
9};
10
11#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
12pub enum ExerciseRole {
13    White,
14    Green,
15    Red,
16    Blue,
17}
18
19#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
20pub struct Entity {
21    #[serde(alias = "Name", alias = "NAME")]
22    pub name: Option<String>,
23    #[serde(alias = "Description", alias = "DESCRIPTION")]
24    pub description: Option<String>,
25    #[serde(alias = "Role", alias = "ROLE")]
26    pub role: Option<ExerciseRole>,
27    #[serde(alias = "Mission", alias = "MISSION")]
28    pub mission: Option<String>,
29    #[serde(alias = "Categories", alias = "CATEGORIES")]
30    pub categories: Option<Vec<String>>,
31    #[serde(alias = "Vulnerabilities", alias = "VULNERABILITIES")]
32    pub vulnerabilities: Option<Vec<String>>,
33    #[serde(alias = "TLOs", alias = "TLOS")]
34    pub tlos: Option<Vec<String>>,
35    #[serde(alias = "Events", alias = "EVENTS")]
36    pub events: Option<Vec<String>>,
37    #[serde(alias = "Entities", alias = "ENTITIES")]
38    pub entities: Option<Entities>,
39}
40
41impl Connection<TrainingLearningObjective> for (&String, &Entity) {
42    fn validate_connections(&self, potential_tlo_names: &Option<Vec<String>>) -> Result<()> {
43        let tlos = &self.1.tlos;
44
45        if let Some(tlos) = tlos {
46            if let Some(tlo_names) = potential_tlo_names {
47                for tlo_name in tlos {
48                    if !tlo_names.contains(tlo_name) {
49                        return Err(anyhow!(
50                            "Entity \"{entity_name}\" TLO \"{tlo_name}\" not found under Scenario TLOs",
51                            entity_name = self.0
52                        ));
53                    }
54                }
55            } else {
56                return Err(anyhow!(
57                    "Entity \"{entity_name}\" has TLOs but none found under Scenario",
58                    entity_name = self.0
59                ));
60            }
61        }
62
63        Ok(())
64    }
65}
66
67impl Connection<Vulnerability> for (&String, &Entity) {
68    fn validate_connections(
69        &self,
70        potential_vulnerability_names: &Option<Vec<String>>,
71    ) -> Result<()> {
72        let vulnerabilities = &self.1.vulnerabilities;
73
74        if let Some(vulnerabilities) = vulnerabilities {
75            if let Some(vulnerability_names) = potential_vulnerability_names {
76                for vulnerability_name in vulnerabilities {
77                    if !vulnerability_names.contains(vulnerability_name) {
78                        return Err(anyhow!(
79                            "Entity \"{entity_name}\" Vulnerability \"{vulnerability_name}\" not found under Scenario Vulnerabilities",
80                            entity_name = self.0
81                        ));
82                    }
83                }
84            } else {
85                return Err(anyhow!(
86                    "Entity \"{entity_name}\" has Vulnerabilities but none found under Scenario",
87                    entity_name = self.0
88                ));
89            }
90        }
91
92        Ok(())
93    }
94}
95
96impl Connection<Event> for (&String, &Entity) {
97    fn validate_connections(&self, potential_event_names: &Option<Vec<String>>) -> Result<()> {
98        let entity_events = &self.1.events;
99
100        if let Some(entity_events) = entity_events {
101            if let Some(sdl_event_names) = potential_event_names {
102                for entity_event_name in entity_events {
103                    if !sdl_event_names.contains(entity_event_name) {
104                        return Err(anyhow!(
105                            "Entity \"{entity_name}\" Event \"{entity_event_name}\" not found under Scenario Events",
106                            entity_name = self.0
107                        ));
108                    }
109                }
110            } else {
111                return Err(anyhow!(
112                    "Entity \"{entity_name}\" has Events but none found under Scenario",
113                    entity_name = self.0
114                ));
115            }
116        }
117
118        Ok(())
119    }
120}
121
122pub type Entities = HashMap<String, Entity>;
123pub trait Flatten {
124    fn flatten(&self) -> Self;
125}
126
127impl Flatten for Entities {
128    fn flatten(&self) -> Self {
129        let mut result = self.clone();
130
131        self.iter().for_each(|(key, entity)| {
132            if let Some(child_entities) = &entity.entities {
133                Self::flatten(child_entities)
134                    .into_iter()
135                    .for_each(|(child_key, child_entity)| {
136                        result.insert(format!("{key}.{child_key}"), child_entity);
137                    })
138            }
139        });
140
141        result
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use crate::parse_sdl;
149
150    #[test]
151    fn parses_sdl_with_entities() {
152        let sdl = r#"
153          name: test-scenario
154          description: some-description
155          stories:
156            story-1:
157                speed: 1
158                scripts:
159                    - script-1
160          scripts:
161            script-1:
162                start-time: 0
163                end-time: 3 hour 30 min
164                speed: 1
165                events:
166                    earthquake: 1 hour
167          events:
168            earthquake:
169                description: "Here comes another earthquake"
170                source: earthquake-package
171          conditions:
172            condition-1:
173                command: executable/path.sh
174                interval: 30
175          metrics:
176              metric-1:
177                  type: MANUAL
178                  artifact: true
179                  max-score: 10
180              metric-2:
181                  type: CONDITIONAL
182                  max-score: 10
183                  condition: condition-1
184          vulnerabilities:
185              vulnerability-1:
186                  name: Some other vulnerability
187                  description: some-description
188                  technical: false
189                  class: CWE-1343
190              vulnerability-2:
191                  name: Some vulnerability
192                  description: some-description
193                  technical: false
194                  class: CWE-1341
195          evaluations:
196              evaluation-1:
197                  description: some description
198                  metrics:
199                      - metric-1
200                      - metric-2
201                  min-score: 50
202          tlos:
203              tlo-1:
204                  description: some description
205                  evaluation: evaluation-1
206          goals:
207              goal-1:
208                  description: "new goal"
209                  tlos:
210                    - tlo-1
211          entities:
212              my-organization:
213                  name: "My Organization"
214                  description: "This is my organization"
215                  role: White
216                  mission: "defend"
217                  events:
218                    - earthquake
219                  categories:
220                    - Foundation
221                    - Organization
222                  vulnerabilities:
223                    - vulnerability-2
224                  tlos:
225                    - tlo-1
226                  entities:
227                    fish:
228                        name: "Shark"
229                        description: "This is my organization"
230                        mission: "swim around"
231                        categories:
232                            - Animal
233      "#;
234        let entities = parse_sdl(sdl).unwrap().entities;
235        insta::with_settings!({sort_maps => true}, {
236                insta::assert_yaml_snapshot!(entities);
237        });
238    }
239
240    #[test]
241    fn parses_single_entity() {
242        let entity_yml = r#"
243          name: "My Organization"
244          description: "This is my organization"
245          role: White
246          mission: "defend"
247          categories:
248            - Foundation
249            - Organization
250          vulnerabilities:
251            - vulnerability-2
252          tlos:
253            - tlo-1
254            - tlo-2
255        "#;
256        serde_yaml::from_str::<Entity>(entity_yml).unwrap();
257    }
258
259    #[test]
260    fn parses_nested_entity() {
261        let entity_yml = r#"
262          name: "My Organization"
263          description: "This is my organization"
264          role: White
265          mission: "defend"
266          categories:
267            - Foundation
268            - Organization
269          vulnerabilities:
270            - vulnerability-2
271          tlos:
272            - tlo-1
273            - tlo-2
274          entities:
275            fish:
276              name: "Shark"
277              description: "This is my organization"
278              mission: "swim around"
279              categories:
280                - Animal
281        "#;
282        serde_yaml::from_str::<Entity>(entity_yml).unwrap();
283    }
284
285    #[test]
286    #[should_panic(
287        expected = "Entity \"my-organization\" TLO \"tlo-2\" not found under Scenario TLOs"
288    )]
289    fn fails_parsing_entity_with_nonexisting_tlo() {
290        let sdl = r#"
291
292          name: test-scenario
293          description: some-description
294          conditions:
295            condition-1:
296                command: executable/path.sh
297                interval: 30
298          metrics:
299              metric-1:
300                  type: MANUAL
301                  artifact: true
302                  max-score: 10
303              metric-2:
304                  type: CONDITIONAL
305                  max-score: 10
306                  condition: condition-1
307          vulnerabilities:
308              vulnerability-1:
309                  name: Some other vulnerability
310                  description: some-description
311                  technical: false
312                  class: CWE-1343
313              vulnerability-2:
314                  name: Some vulnerability
315                  description: some-description
316                  technical: false
317                  class: CWE-1341
318          evaluations:
319              evaluation-1:
320                  description: some description
321                  metrics:
322                      - metric-1
323                      - metric-2
324                  min-score: 50
325          tlos:
326              tlo-1:
327                  description: some description
328                  evaluation: evaluation-1
329          goals:
330              goal-1:
331                  description: "new goal"
332                  tlos:
333                    - tlo-1
334          entities:
335              my-organization:
336                  name: "My Organization"
337                  description: "This is my organization"
338                  role: White
339                  mission: "defend"
340                  categories:
341                    - Foundation
342                    - Organization
343                  vulnerabilities:
344                    - vulnerability-2
345                  tlos:
346                    - tlo-1
347                    - tlo-2
348                  entities:
349                    fish:
350                        name: "Shark"
351                        description: "This is my organization"
352                        mission: "swim around"
353                        categories:
354                            - Animal
355      "#;
356        let entities = parse_sdl(sdl).unwrap().entities;
357        insta::with_settings!({sort_maps => true}, {
358                insta::assert_yaml_snapshot!(entities);
359        });
360    }
361
362    #[test]
363    #[should_panic(
364        expected = "Entity \"my-organization.fish\" TLO \"tlo-i-don't-exist\" not found under Scenario TLOs"
365    )]
366    fn fails_parsing_child_entity_with_nonexisting_tlo() {
367        let sdl = r#"
368
369          name: test-scenario
370          description: some-description
371          conditions:
372            condition-1:
373                command: executable/path.sh
374                interval: 30
375          metrics:
376              metric-1:
377                  type: MANUAL
378                  artifact: true
379                  max-score: 10
380              metric-2:
381                  type: CONDITIONAL
382                  max-score: 10
383                  condition: condition-1
384          vulnerabilities:
385              vulnerability-1:
386                  name: Some other vulnerability
387                  description: some-description
388                  technical: false
389                  class: CWE-1343
390              vulnerability-2:
391                  name: Some vulnerability
392                  description: some-description
393                  technical: false
394                  class: CWE-1341
395          evaluations:
396              evaluation-1:
397                  description: some description
398                  metrics:
399                      - metric-1
400                      - metric-2
401                  min-score: 50
402          tlos:
403              tlo-1:
404                  description: some description
405                  evaluation: evaluation-1
406          goals:
407              goal-1:
408                  description: "new goal"
409                  tlos:
410                    - tlo-1
411          entities:
412              my-organization:
413                  name: "My Organization"
414                  description: "This is my organization"
415                  role: White
416                  mission: "defend"
417                  categories:
418                    - Foundation
419                    - Organization
420                  vulnerabilities:
421                    - vulnerability-2
422                  tlos:
423                    - tlo-1
424                  entities:
425                    fish:
426                        name: "Shark"
427                        description: "This is my organization"
428                        mission: "swim around"
429                        categories:
430                            - Animal
431                        tlos:
432                            - tlo-i-don't-exist
433      "#;
434        parse_sdl(sdl).unwrap();
435    }
436
437    #[test]
438    #[should_panic(
439        expected = "Entity \"my-organization.fish\" Vulnerability \"vulnerability-i-don't-exist\" not found under Scenario Vulnerabilities"
440    )]
441    fn fails_parsing_child_entity_with_nonexisting_vulnerability() {
442        let sdl = r#"
443
444          name: test-scenario
445          description: some-description
446          conditions:
447            condition-1:
448                command: executable/path.sh
449                interval: 30
450          metrics:
451              metric-1:
452                  type: MANUAL
453                  artifact: true
454                  max-score: 10
455              metric-2:
456                  type: CONDITIONAL
457                  max-score: 10
458                  condition: condition-1
459          vulnerabilities:
460              vulnerability-1:
461                  name: Some other vulnerability
462                  description: some-description
463                  technical: false
464                  class: CWE-1343
465              vulnerability-2:
466                  name: Some vulnerability
467                  description: some-description
468                  technical: false
469                  class: CWE-1341
470          evaluations:
471              evaluation-1:
472                  description: some description
473                  metrics:
474                      - metric-1
475                      - metric-2
476                  min-score: 50
477          tlos:
478              tlo-1:
479                  description: some description
480                  evaluation: evaluation-1
481          goals:
482              goal-1:
483                  description: "new goal"
484                  tlos:
485                    - tlo-1
486          entities:
487              my-organization:
488                  name: "My Organization"
489                  description: "This is my organization"
490                  role: White
491                  mission: "defend"
492                  categories:
493                    - Foundation
494                    - Organization
495                  vulnerabilities:
496                    - vulnerability-2
497                  tlos:
498                    - tlo-1
499                  entities:
500                    fish:
501                        name: "Shark"
502                        description: "This is my organization"
503                        mission: "swim around"
504                        categories:
505                            - Animal
506                        vulnerabilities:
507                            - vulnerability-i-don't-exist
508      "#;
509        parse_sdl(sdl).unwrap();
510    }
511
512    #[test]
513    fn parses_entity_with_events() {
514        let sdl = r#"
515          name: test-scenario
516          events:
517            my-cool-event:
518                description: "This is my event"
519            my-other-cool-event:
520                description: "This is my other event"
521          entities:
522            blue-team:
523                role: Blue
524                events:
525                    - my-cool-event
526                entities:
527                    blue-player:
528                        role: Blue
529                        events:
530                            -  my-other-cool-event
531      "#;
532        let entities = parse_sdl(sdl).unwrap().entities;
533        insta::assert_yaml_snapshot!(entities);
534    }
535
536    #[test]
537    #[should_panic(
538        expected = "Entity \"blue-team\" Event \"i-don't-exist\" not found under Scenario Events"
539    )]
540    fn fails_parsing_entity_with_nonexisting_event() {
541        let sdl = r#"
542          name: test-scenario
543          events:
544            my-cool-event:
545                description: "This is my event"
546          entities:
547            blue-team:
548                role: Blue
549                events:
550                - i-don't-exist
551      "#;
552        parse_sdl(sdl).unwrap();
553    }
554
555    #[test]
556    #[should_panic(
557        expected = "Entity \"blue-team.blue-player\" Event \"i-don't-exist\" not found under Scenario Events"
558    )]
559    fn fails_parsing_child_entity_with_nonexisting_event() {
560        let sdl = r#"
561          name: test-scenario
562          events:
563            my-cool-event:
564                description: "This is my event"
565          entities:
566            blue-team:
567                role: Blue
568                entities:
569                    blue-player:
570                        role: Blue
571                        events:
572                            - i-don't-exist
573      "#;
574        parse_sdl(sdl).unwrap();
575    }
576
577    #[test]
578    #[should_panic(expected = "Entity \"blue-team\" has Events but none found under Scenario")]
579    fn fails_parsing_entity_with_no_events_defined() {
580        let sdl = r#"
581          name: test-scenario
582          entities:
583            blue-team:
584                role: Blue
585                events:
586                - i-don't-exist
587      "#;
588        parse_sdl(sdl).unwrap();
589    }
590}