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}