1use core::fmt;
36use core::num::NonZeroU64;
37use std::borrow::Cow;
38
39#[non_exhaustive]
41#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
42#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
43pub enum Severity {
44 Info,
46 Low,
48 Medium,
50 High,
52 Critical,
54}
55
56impl fmt::Display for Severity {
57 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58 f.write_str(match self {
59 Severity::Info => "INFO",
60 Severity::Low => "LOW",
61 Severity::Medium => "MEDIUM",
62 Severity::High => "HIGH",
63 Severity::Critical => "CRITICAL",
64 })
65 }
66}
67
68#[non_exhaustive]
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
73#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
74pub enum Category {
75 Integrity,
77 Structure,
79 Residue,
81 Provenance,
83 History,
85 Concealment,
87 Threat,
90}
91
92impl Category {
93 #[must_use]
99 pub fn from_code(code: &str) -> Category {
100 let c = code.to_ascii_uppercase();
101 if c.contains("CRC")
102 || c.contains("INTEGRITY")
103 || c.contains("CHECKSUM")
104 || c.contains("HASH")
105 {
106 Category::Integrity
107 } else if c.contains("OVERLAP")
108 || c.contains("OOB")
109 || c.contains("BOUND")
110 || c.contains("CHS")
111 || c.contains("MAP-COUNT")
112 {
113 Category::Structure
114 } else if c.contains("HIDDEN")
115 || c.contains("CONCEAL")
116 || c.contains("WIPED")
117 || c.contains("ERASED")
118 || c.contains("SPOOF")
119 || c.contains("PROTECTIVE")
120 {
121 Category::Concealment
124 } else if c.contains("RESIDUAL")
125 || c.contains("SLACK")
126 || c.contains("GAP")
127 || c.contains("CARVE")
128 || c.contains("UNMAPPED")
129 || c.contains("ZEROLEN")
130 {
131 Category::Residue
132 } else if c.contains("BOOT") {
133 Category::Threat
134 } else {
135 Category::Structure
136 }
137 }
138}
139
140#[non_exhaustive]
144#[derive(Debug, Clone, PartialEq, Eq, Hash)]
145#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
146pub enum Location {
147 ByteOffset(u64),
149 Lba(u64),
151 Sector(u64),
153 Rva(u64),
155 RecordId(u64),
157 Path(String),
159 Field(String),
161 Key(String),
163 Other {
166 space: String,
168 value: u64,
170 },
171}
172
173#[derive(Debug, Clone, PartialEq, Eq)]
176#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
177pub struct Evidence {
178 pub field: String,
180 pub value: String,
182 pub location: Option<Location>,
184}
185
186#[derive(Debug, Clone, PartialEq, Eq, Hash)]
189#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
190pub struct SubjectRef {
191 pub scheme: String,
193 pub kind: String,
195 pub id: String,
197 pub label: Option<String>,
199}
200
201#[derive(Debug, Clone, PartialEq, Eq, Hash)]
204#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
205pub struct ExternalRef {
206 pub scheme: String,
208 pub id: String,
210 pub url: Option<String>,
212}
213
214impl ExternalRef {
215 #[must_use]
217 pub fn mitre_attack(id: impl Into<String>) -> Self {
218 Self {
219 scheme: "mitre-attack".to_string(),
220 id: id.into(),
221 url: None,
222 }
223 }
224}
225
226#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
229#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
230#[cfg_attr(feature = "serde", serde(try_from = "f32", into = "f32"))]
231pub struct Confidence(f32);
232
233impl Confidence {
234 #[must_use]
237 pub fn new(value: f32) -> Option<Self> {
238 if value.is_finite() && (0.0..=1.0).contains(&value) {
239 Some(Self(value))
240 } else {
241 None
242 }
243 }
244
245 #[must_use]
247 pub fn get(self) -> f32 {
248 self.0
249 }
250}
251
252impl TryFrom<f32> for Confidence {
253 type Error = &'static str;
254 fn try_from(value: f32) -> Result<Self, Self::Error> {
255 Self::new(value).ok_or("confidence must be finite and within 0.0..=1.0")
256 }
257}
258
259impl From<Confidence> for f32 {
260 fn from(c: Confidence) -> Self {
261 c.0
262 }
263}
264
265#[derive(Debug, Clone, PartialEq, Eq)]
267#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
268pub struct Timestamp {
269 pub value: String,
271 pub kind: String,
273 pub location: Option<Location>,
275}
276
277#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
279#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
280pub struct Source {
281 pub analyzer: String,
283 pub scope: String,
285 pub version: Option<String>,
287}
288
289#[non_exhaustive]
292#[derive(Debug, Clone, Default, PartialEq)]
293#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
294pub struct FindingContext {
295 pub confidence: Option<Confidence>,
297 pub occurrences: Option<NonZeroU64>,
299 pub timestamps: Vec<Timestamp>,
301 pub external_refs: Vec<ExternalRef>,
303 pub tags: Vec<Cow<'static, str>>,
306}
307
308#[non_exhaustive]
313#[derive(Debug, Clone, PartialEq)]
314#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
315pub struct Finding {
316 pub severity: Option<Severity>,
318 pub category: Category,
320 pub code: Cow<'static, str>,
322 pub note: String,
324 pub source: Source,
326 pub subjects: Vec<SubjectRef>,
328 pub evidence: Vec<Evidence>,
330 pub context: FindingContext,
332}
333
334impl Finding {
335 #[must_use]
337 pub fn observation(
338 severity: Severity,
339 category: Category,
340 code: impl Into<Cow<'static, str>>,
341 ) -> FindingBuilder {
342 FindingBuilder::new(Some(severity), category, code.into())
343 }
344
345 #[must_use]
347 pub fn unrated(category: Category, code: impl Into<Cow<'static, str>>) -> FindingBuilder {
348 FindingBuilder::new(None, category, code.into())
349 }
350}
351
352#[derive(Debug, Clone)]
354pub struct FindingBuilder {
355 finding: Finding,
356}
357
358impl FindingBuilder {
359 fn new(severity: Option<Severity>, category: Category, code: Cow<'static, str>) -> Self {
360 Self {
361 finding: Finding {
362 severity,
363 category,
364 code,
365 note: String::new(),
366 source: Source::default(),
367 subjects: Vec::new(),
368 evidence: Vec::new(),
369 context: FindingContext::default(),
370 },
371 }
372 }
373
374 #[must_use]
376 pub fn note(mut self, note: impl Into<String>) -> Self {
377 self.finding.note = note.into();
378 self
379 }
380
381 #[must_use]
383 pub fn source(mut self, source: Source) -> Self {
384 self.finding.source = source;
385 self
386 }
387
388 #[must_use]
390 pub fn evidence(self, field: impl Into<String>, value: impl Into<String>) -> Self {
391 self.evidence_item(Evidence {
392 field: field.into(),
393 value: value.into(),
394 location: None,
395 })
396 }
397
398 #[must_use]
400 pub fn evidence_at(
401 self,
402 field: impl Into<String>,
403 value: impl Into<String>,
404 location: Location,
405 ) -> Self {
406 self.evidence_item(Evidence {
407 field: field.into(),
408 value: value.into(),
409 location: Some(location),
410 })
411 }
412
413 #[must_use]
415 pub fn evidence_item(mut self, evidence: Evidence) -> Self {
416 self.finding.evidence.push(evidence);
417 self
418 }
419
420 #[must_use]
422 pub fn subject(mut self, subject: SubjectRef) -> Self {
423 self.finding.subjects.push(subject);
424 self
425 }
426
427 #[must_use]
429 pub fn mitre(self, technique: impl Into<String>) -> Self {
430 self.external_ref(ExternalRef::mitre_attack(technique))
431 }
432
433 #[must_use]
435 pub fn external_ref(mut self, reference: ExternalRef) -> Self {
436 self.finding.context.external_refs.push(reference);
437 self
438 }
439
440 #[must_use]
442 pub fn confidence(mut self, confidence: Confidence) -> Self {
443 self.finding.context.confidence = Some(confidence);
444 self
445 }
446
447 #[must_use]
449 pub fn occurrences(mut self, count: u64) -> Self {
450 self.finding.context.occurrences = NonZeroU64::new(count);
451 self
452 }
453
454 #[must_use]
456 pub fn timestamp(mut self, timestamp: Timestamp) -> Self {
457 self.finding.context.timestamps.push(timestamp);
458 self
459 }
460
461 #[must_use]
463 pub fn tag(mut self, tag: impl Into<Cow<'static, str>>) -> Self {
464 self.finding.context.tags.push(tag.into());
465 self
466 }
467
468 #[must_use]
470 pub fn build(self) -> Finding {
471 self.finding
472 }
473}
474
475pub trait Observation {
481 fn severity(&self) -> Option<Severity>;
483 fn code(&self) -> &'static str;
485 fn note(&self) -> String;
487
488 fn category(&self) -> Category {
491 Category::from_code(self.code())
492 }
493
494 fn subjects(&self) -> Vec<SubjectRef> {
496 Vec::new()
497 }
498 fn evidence(&self) -> Vec<Evidence> {
500 Vec::new()
501 }
502 fn mitre(&self) -> &'static [&'static str] {
504 &[]
505 }
506 fn confidence(&self) -> Option<Confidence> {
508 None
509 }
510
511 fn to_finding(&self, source: Source) -> Finding {
513 let mut builder = match self.severity() {
514 Some(sev) => Finding::observation(sev, self.category(), self.code()),
515 None => Finding::unrated(self.category(), self.code()),
516 }
517 .note(self.note())
518 .source(source);
519
520 for subject in self.subjects() {
521 builder = builder.subject(subject);
522 }
523 for evidence in self.evidence() {
524 builder = builder.evidence_item(evidence);
525 }
526 for technique in self.mitre() {
527 builder = builder.mitre(*technique);
528 }
529 if let Some(confidence) = self.confidence() {
530 builder = builder.confidence(confidence);
531 }
532 builder.build()
533 }
534}
535
536#[derive(Debug, Clone, PartialEq, Eq)]
538#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
539pub struct TimelineEvent {
540 pub when: Option<String>,
542 pub source: String,
544 pub event: String,
546}
547
548#[derive(Debug, Clone, PartialEq, Eq)]
550#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
551pub struct Provenance {
552 pub label: String,
554 pub value: String,
556 pub source: String,
558}
559
560#[non_exhaustive]
563#[derive(Debug, Clone, Default, PartialEq)]
564#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
565pub struct Report {
566 pub findings: Vec<Finding>,
568 pub provenance: Vec<Provenance>,
570 pub timeline: Vec<TimelineEvent>,
572 pub metadata: Vec<Evidence>,
574}
575
576impl Report {
577 #[must_use]
580 pub fn max_severity(&self) -> Option<Severity> {
581 self.findings.iter().filter_map(|f| f.severity).max()
582 }
583
584 pub fn findings_at_least(&self, min: Severity) -> impl Iterator<Item = &Finding> {
586 self.findings
587 .iter()
588 .filter(move |f| f.severity.is_some_and(|s| s >= min))
589 }
590
591 pub fn unrated_findings(&self) -> impl Iterator<Item = &Finding> {
593 self.findings.iter().filter(|f| f.severity.is_none())
594 }
595}
596
597#[cfg(test)]
598mod tests {
599 use super::*;
600
601 #[test]
602 fn severity_display_renders_uppercase_labels() {
603 assert_eq!(Severity::Info.to_string(), "INFO");
604 assert_eq!(Severity::Low.to_string(), "LOW");
605 assert_eq!(Severity::Medium.to_string(), "MEDIUM");
606 assert_eq!(Severity::High.to_string(), "HIGH");
607 assert_eq!(Severity::Critical.to_string(), "CRITICAL");
608 }
609
610 #[test]
611 fn category_from_code_classifies_each_lens() {
612 assert_eq!(Category::from_code("crc-bad"), Category::Integrity);
614 assert_eq!(Category::from_code("HASH-MISMATCH"), Category::Integrity);
615 assert_eq!(
617 Category::from_code("GPT-PARTITION-OVERLAP"),
618 Category::Structure
619 );
620 assert_eq!(Category::from_code("MAP-COUNT"), Category::Structure);
621 assert_eq!(Category::from_code("PROTECTIVE-MBR"), Category::Concealment);
623 assert_eq!(Category::from_code("ZEROLEN-GAP-ONLY"), Category::Residue);
625 assert_eq!(Category::from_code("BOOT-CODE"), Category::Threat);
627 assert_eq!(Category::from_code("MYSTERY"), Category::Structure);
629 }
630
631 #[test]
632 fn confidence_validates_range() {
633 assert_eq!(Confidence::new(0.5).map(Confidence::get), Some(0.5));
634 assert_eq!(Confidence::new(0.0).map(Confidence::get), Some(0.0));
635 assert!(Confidence::new(f32::NAN).is_none());
636 assert!(Confidence::new(1.5).is_none());
637 assert!(Confidence::new(-0.1).is_none());
638 }
639
640 #[test]
641 fn confidence_try_from_and_into_f32() {
642 let c = Confidence::try_from(0.25_f32).expect("0.25 is in range");
643 assert_eq!(c.get(), 0.25);
644 assert!(Confidence::try_from(2.0_f32).is_err());
645 let back: f32 = c.into();
646 assert_eq!(back, 0.25);
647 }
648
649 #[test]
650 fn finding_builder_populates_all_fields() {
651 let source = Source {
652 analyzer: "test-forensic".to_string(),
653 scope: "partition 1".to_string(),
654 version: Some("1.0".to_string()),
655 };
656 let conf = Confidence::new(0.9).expect("0.9 is in range");
657 let finding = Finding::observation(Severity::High, Category::Structure, "TEST-CODE")
658 .note("consistent with tampering")
659 .source(source.clone())
660 .evidence("field", "value")
661 .evidence_at("lba", "42", Location::Lba(42))
662 .evidence_item(Evidence {
663 field: "raw".to_string(),
664 value: "bytes".to_string(),
665 location: None,
666 })
667 .subject(SubjectRef {
668 scheme: "memory".to_string(),
669 kind: "process".to_string(),
670 id: "pid:4".to_string(),
671 label: Some("system".to_string()),
672 })
673 .confidence(conf)
674 .occurrences(7)
675 .timestamp(Timestamp {
676 value: "2026-01-01T00:00:00Z".to_string(),
677 kind: "observed".to_string(),
678 location: None,
679 })
680 .tag("flagged")
681 .build();
682
683 assert_eq!(finding.severity, Some(Severity::High));
684 assert_eq!(finding.note, "consistent with tampering");
685 assert_eq!(finding.source, source);
686 assert_eq!(finding.evidence.len(), 3);
687 assert_eq!(finding.subjects.len(), 1);
688 assert_eq!(finding.context.confidence, Some(conf));
689 assert_eq!(finding.context.occurrences, NonZeroU64::new(7));
690 assert_eq!(finding.context.timestamps.len(), 1);
691 assert_eq!(finding.context.tags, vec![Cow::Borrowed("flagged")]);
692 }
693
694 #[test]
695 fn unrated_finding_has_no_severity() {
696 let finding = Finding::unrated(Category::Provenance, "PROV-TOOL").build();
697 assert_eq!(finding.severity, None);
698 assert_eq!(finding.category, Category::Provenance);
699 }
700
701 struct RatedKind;
703 impl Observation for RatedKind {
704 fn severity(&self) -> Option<Severity> {
705 Some(Severity::Critical)
706 }
707 fn code(&self) -> &'static str {
708 "GPT-PARTITION-OVERLAP"
709 }
710 fn note(&self) -> String {
711 "overlap".to_string()
712 }
713 fn subjects(&self) -> Vec<SubjectRef> {
714 vec![SubjectRef {
715 scheme: "disk".to_string(),
716 kind: "partition".to_string(),
717 id: "1".to_string(),
718 label: None,
719 }]
720 }
721 fn evidence(&self) -> Vec<Evidence> {
722 vec![Evidence {
723 field: "range".to_string(),
724 value: "0..10".to_string(),
725 location: Some(Location::Lba(0)),
726 }]
727 }
728 fn mitre(&self) -> &'static [&'static str] {
729 &["T1542"]
730 }
731 fn confidence(&self) -> Option<Confidence> {
732 Confidence::new(0.8)
733 }
734 }
735
736 struct UnratedKind;
738 impl Observation for UnratedKind {
739 fn severity(&self) -> Option<Severity> {
740 None
741 }
742 fn code(&self) -> &'static str {
743 "PE-WX-SECTION"
744 }
745 fn note(&self) -> String {
746 "writable+executable".to_string()
747 }
748 }
749
750 #[test]
751 fn observation_to_finding_assembles_rated() {
752 let finding = RatedKind.to_finding(Source::default());
753 assert_eq!(finding.severity, Some(Severity::Critical));
754 assert_eq!(finding.category, Category::Structure);
756 assert_eq!(finding.code, "GPT-PARTITION-OVERLAP");
757 assert_eq!(finding.note, "overlap");
758 assert_eq!(finding.subjects.len(), 1);
759 assert_eq!(finding.evidence.len(), 1);
760 assert_eq!(finding.context.external_refs.len(), 1);
761 assert_eq!(finding.context.external_refs[0].scheme, "mitre-attack");
762 assert_eq!(finding.context.external_refs[0].id, "T1542");
763 assert_eq!(finding.context.confidence, Confidence::new(0.8));
764 }
765
766 #[test]
767 fn observation_to_finding_assembles_unrated_with_defaults() {
768 let finding = UnratedKind.to_finding(Source::default());
769 assert_eq!(finding.severity, None);
770 assert_eq!(finding.category, Category::Structure);
771 assert!(finding.subjects.is_empty());
772 assert!(finding.evidence.is_empty());
773 assert!(finding.context.external_refs.is_empty());
774 assert!(finding.context.confidence.is_none());
775 }
776
777 #[test]
778 fn report_severity_queries() {
779 let mut report = Report::default();
780 report
781 .findings
782 .push(Finding::observation(Severity::Low, Category::Provenance, "A").build());
783 report
784 .findings
785 .push(Finding::observation(Severity::Critical, Category::Structure, "B").build());
786 report
787 .findings
788 .push(Finding::unrated(Category::History, "C").build());
789
790 assert_eq!(report.max_severity(), Some(Severity::Critical));
791 assert_eq!(report.findings_at_least(Severity::Medium).count(), 1);
792 assert_eq!(report.unrated_findings().count(), 1);
793 }
794
795 #[test]
796 fn report_max_severity_none_when_empty() {
797 let report = Report::default();
798 assert_eq!(report.max_severity(), None);
799 }
800}