Skip to main content

forensicnomicon_core/
report.rs

1//! Normalized cross-scheme forensic report vocabulary — the superset model.
2//!
3//! The shared model the SecurityRonin analyzers (`mbr-forensic`, `gpt-forensic`,
4//! `apm-forensic`, `iso9660-forensic`, `vmdk-forensic`, `vhdx-forensic`,
5//! `ewf-forensic`, `srum-forensic`, `winevt-forensic`, `usnjrnl-forensic`,
6//! `memory-forensic`, `exec-pe-forensic`, …) normalize into, and that the
7//! `disk-forensic`/`disk4n6` CLI and the `issen` triage product render. Hosting
8//! it here keeps the vocabulary a single source of truth and avoids a dependency
9//! cycle: `forensicnomicon` is a leaf, so every analyzer can depend *down* onto
10//! it.
11//!
12//! A [`Finding`] is an **observation with evidence**, never an assertion of
13//! intent; the analyst/tribunal draws conclusions. It is the **union (superset)
14//! of the analyzers' data, not a flattening**: scheme-specific detail is
15//! preserved losslessly as [`Evidence`], non-disk targets as [`SubjectRef`], and
16//! behavioral context (MITRE technique refs, confidence, occurrence count,
17//! timestamps) as [`FindingContext`].
18//!
19//! ## Design contract
20//!
21//! - Analyzers keep their own typed `AnomalyKind` enums (domain knowledge) and
22//!   implement [`Observation`] on them; [`Observation::to_finding`] assembles the
23//!   canonical [`Finding`]. The shared crate never enumerates every anomaly kind.
24//! - [`Finding`] is built through [`Finding::observation`] / [`Finding::unrated`]
25//!   and the returned [`FindingBuilder`] — never a struct literal — so adding
26//!   fields later is a non-breaking change for the published fleet.
27//! - A finding's [`Finding::severity`] is `Option<Severity>`: `None` ("not
28//!   scored") is forensically distinct from `Some(Severity::Info)` ("scored,
29//!   benign"). Analyzers that cannot grade a finding (e.g. a PE writable+
30//!   executable section) emit it unrated rather than inventing a grade.
31//! - Stable `code` strings are a published contract: prefix with the scheme
32//!   (`VMDK-…`, `GPT-…`) and never change a shipped code.
33//! - MITRE / threat refs are **"consistent with"**, never a verdict.
34
35use core::fmt;
36use core::num::NonZeroU64;
37use std::borrow::Cow;
38
39/// Severity of a forensic finding (`Info` < `Low` < `Medium` < `High` < `Critical`).
40#[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    /// Informational — provenance/context, not suspicious on its own.
45    Info,
46    /// Low — minor irregularity with a common benign explanation.
47    Low,
48    /// Medium — notable irregularity worth examiner attention.
49    Medium,
50    /// High — strong indicator of tampering or concealment.
51    High,
52    /// Critical — structural contradiction; the medium cannot be trusted as-is.
53    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/// The forensic lens a finding belongs to — the analytical category, not a
69/// severity. Fine-grained threat taxonomy (C2, ransomware, injection) lives in
70/// the finding's `code` and MITRE refs, not in new categories.
71#[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 / authenticity (CRC, checksum, image completeness).
76    Integrity,
77    /// Structural contradiction (overlap, out-of-bounds, internal mismatch).
78    Structure,
79    /// Residue / recoverability (deleted entries, slack, hidden data).
80    Residue,
81    /// Provenance / attribution (tool, OS, era, vendor fingerprints).
82    Provenance,
83    /// History (resize, move, clone, format — the medium's biography).
84    History,
85    /// Concealment / anti-forensics (hidden flags, wiping, misdirection).
86    Concealment,
87    /// Threat — malicious code or behavior (bootkits, rootkits, injection,
88    /// C2/beaconing, ransomware indicators).
89    Threat,
90}
91
92impl Category {
93    /// Classify a stable finding `code` into a coarse [`Category`] by keyword.
94    ///
95    /// A pragmatic, scheme-agnostic default so analyzers need not hand-map every
96    /// anomaly variant; an analyzer overrides [`Observation::category`] for the
97    /// codes where this heuristic is wrong (e.g. overloaded `BOOT` prefixes).
98    #[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            // Checked before Residue so a wiped/erased *gap* reads as anti-forensics,
122            // not mere slack.
123            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/// Where a finding's evidence sits on the medium — spanning partition-table
141/// (byte/LBA/sector), filesystem (path/field), executable/memory (RVA),
142/// record-oriented (event-log/journal/database) and registry positions.
143#[non_exhaustive]
144#[derive(Debug, Clone, PartialEq, Eq, Hash)]
145#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
146pub enum Location {
147    /// Absolute byte offset in the image.
148    ByteOffset(u64),
149    /// Logical block address.
150    Lba(u64),
151    /// Physical/optical sector index.
152    Sector(u64),
153    /// Relative virtual address (executable image / memory).
154    Rva(u64),
155    /// Record identity (event-log record, USN/journal record, database row).
156    RecordId(u64),
157    /// Path within a filesystem/volume.
158    Path(String),
159    /// A named structure field (e.g. `volume_space_size`).
160    Field(String),
161    /// A registry key path.
162    Key(String),
163    /// Escape hatch: a numeric address in a named space (e.g. `memory:va`).
164    /// Prefer a dedicated variant when one fits; this keeps rare cases lossless.
165    Other {
166        /// Namespaced address space, e.g. `memory:va`.
167        space: String,
168        /// The numeric value in that space.
169        value: u64,
170    },
171}
172
173/// One piece of evidence backing a finding: a named field, its observed value
174/// (rendered as text), and where it was found.
175#[derive(Debug, Clone, PartialEq, Eq)]
176#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
177pub struct Evidence {
178    /// Field / observation name.
179    pub field: String,
180    /// Observed value, rendered as text.
181    pub value: String,
182    /// Where it was observed, if locatable.
183    pub location: Option<Location>,
184}
185
186/// A non-disk subject a finding is *about* — a process, module, connection,
187/// registry key, PE section, etc. Disk findings leave this empty.
188#[derive(Debug, Clone, PartialEq, Eq, Hash)]
189#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
190pub struct SubjectRef {
191    /// Namespaced subject scheme, e.g. `memory`, `winevt`, `pe`, `filesystem`.
192    pub scheme: String,
193    /// Generic type within the scheme, e.g. `process`, `module`, `registry_key`.
194    pub kind: String,
195    /// Stable identifier in that scheme, e.g. `pid:4242`, `0x401000`.
196    pub id: String,
197    /// Optional human label, e.g. an image name.
198    pub label: Option<String>,
199}
200
201/// An external reference a finding is **consistent with** — never a verdict.
202/// Most commonly a MITRE ATT&CK technique; also CVEs, vendor docs, case tags.
203#[derive(Debug, Clone, PartialEq, Eq, Hash)]
204#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
205pub struct ExternalRef {
206    /// Reference scheme, e.g. `mitre-attack`, `cve`.
207    pub scheme: String,
208    /// Identifier within the scheme, e.g. `T1003`, `T1055.001`.
209    pub id: String,
210    /// Optional canonical URL.
211    pub url: Option<String>,
212}
213
214impl ExternalRef {
215    /// A MITRE ATT&CK technique reference (e.g. `"T1003"`).
216    #[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/// A confidence score in `0.0..=1.0`, validated at construction so a producer
227/// can never emit `NaN`, a negative, or an out-of-range value.
228#[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    /// Construct a confidence in `0.0..=1.0`; returns `None` for `NaN` or
235    /// out-of-range input.
236    #[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    /// The score as `f32`.
246    #[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/// A timestamp attached to a finding (distinct from the merged super-timeline).
266#[derive(Debug, Clone, PartialEq, Eq)]
267#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
268pub struct Timestamp {
269    /// RFC 3339 when known; analyzer-native string otherwise.
270    pub value: String,
271    /// What the time means, e.g. `observed`, `created`, `event`, `inferred`.
272    pub kind: String,
273    /// Where the timestamp was read, if locatable.
274    pub location: Option<Location>,
275}
276
277/// The analyzer (and the scope within the medium) that produced a finding.
278#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
279#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
280pub struct Source {
281    /// Analyzer name, e.g. `gpt-forensic`.
282    pub analyzer: String,
283    /// Scope within the medium, e.g. `partition 1` or `volume: Macintosh HD`.
284    pub scope: String,
285    /// Analyzer version, for court-grade reproducibility.
286    pub version: Option<String>,
287}
288
289/// Optional behavioral / aggregation context for a finding. Disk findings leave
290/// this at its default; behavioral analyzers (memory, winevt, srum) populate it.
291#[non_exhaustive]
292#[derive(Debug, Clone, Default, PartialEq)]
293#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
294pub struct FindingContext {
295    /// Heuristic confidence, when the finding is inferential rather than structural.
296    pub confidence: Option<Confidence>,
297    /// Number of underlying occurrences this finding aggregates (default 1).
298    pub occurrences: Option<NonZeroU64>,
299    /// Timestamps anchoring the finding in time.
300    pub timestamps: Vec<Timestamp>,
301    /// External references the finding is consistent with (MITRE ATT&CK, CVE …).
302    pub external_refs: Vec<ExternalRef>,
303    /// Analyzer-specific labels (filter flags, sub-classifications) without
304    /// schema churn.
305    pub tags: Vec<Cow<'static, str>>,
306}
307
308/// A normalized forensic finding — an observation, never an assertion of intent.
309///
310/// Construct via [`Finding::observation`] / [`Finding::unrated`]; the type is
311/// `#[non_exhaustive]` so adding fields later does not break consumers.
312#[non_exhaustive]
313#[derive(Debug, Clone, PartialEq)]
314#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
315pub struct Finding {
316    /// Severity, or `None` when the analyzer deliberately did not score it.
317    pub severity: Option<Severity>,
318    /// Analytical lens.
319    pub category: Category,
320    /// Stable machine-readable, scheme-prefixed code, e.g. `GPT-PARTITION-OVERLAP`.
321    pub code: Cow<'static, str>,
322    /// Human-readable observation (consistent-with language, never "proves").
323    pub note: String,
324    /// Producing analyzer + scope.
325    pub source: Source,
326    /// Non-disk subjects the finding is about (empty for disk findings).
327    pub subjects: Vec<SubjectRef>,
328    /// Backing evidence (lossless per-analyzer detail).
329    pub evidence: Vec<Evidence>,
330    /// Behavioral / aggregation context (default-empty for disk findings).
331    pub context: FindingContext,
332}
333
334impl Finding {
335    /// Begin a rated finding.
336    #[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    /// Begin a finding the analyzer deliberately leaves unrated (`severity: None`).
346    #[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/// Builder for [`Finding`] — the only supported construction path.
353#[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    /// Human-readable observation.
375    #[must_use]
376    pub fn note(mut self, note: impl Into<String>) -> Self {
377        self.finding.note = note.into();
378        self
379    }
380
381    /// The producing analyzer + scope.
382    #[must_use]
383    pub fn source(mut self, source: Source) -> Self {
384        self.finding.source = source;
385        self
386    }
387
388    /// Add an evidence row without a location.
389    #[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    /// Add an evidence row anchored at a location.
399    #[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    /// Add a fully-formed evidence row.
414    #[must_use]
415    pub fn evidence_item(mut self, evidence: Evidence) -> Self {
416        self.finding.evidence.push(evidence);
417        self
418    }
419
420    /// Add a non-disk subject the finding is about.
421    #[must_use]
422    pub fn subject(mut self, subject: SubjectRef) -> Self {
423        self.finding.subjects.push(subject);
424        self
425    }
426
427    /// Add a MITRE ATT&CK technique the finding is consistent with.
428    #[must_use]
429    pub fn mitre(self, technique: impl Into<String>) -> Self {
430        self.external_ref(ExternalRef::mitre_attack(technique))
431    }
432
433    /// Add an external reference (MITRE, CVE, vendor doc, case tag).
434    #[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    /// Attach a heuristic confidence.
441    #[must_use]
442    pub fn confidence(mut self, confidence: Confidence) -> Self {
443        self.finding.context.confidence = Some(confidence);
444        self
445    }
446
447    /// Set the number of occurrences this finding aggregates (`0` clears it).
448    #[must_use]
449    pub fn occurrences(mut self, count: u64) -> Self {
450        self.finding.context.occurrences = NonZeroU64::new(count);
451        self
452    }
453
454    /// Anchor the finding in time.
455    #[must_use]
456    pub fn timestamp(mut self, timestamp: Timestamp) -> Self {
457        self.finding.context.timestamps.push(timestamp);
458        self
459    }
460
461    /// Add an analyzer-specific tag.
462    #[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    /// Finish building.
469    #[must_use]
470    pub fn build(self) -> Finding {
471        self.finding
472    }
473}
474
475/// The producer trait analyzers implement on their own typed anomaly kind.
476///
477/// Only `severity`, `category`, `code`, and `note` are required; the rest carry
478/// behavioral detail and default to empty. [`Observation::to_finding`] assembles
479/// the canonical [`Finding`] so the construction logic lives in one place.
480pub trait Observation {
481    /// Severity, or `None` if the analyzer deliberately does not grade this kind.
482    fn severity(&self) -> Option<Severity>;
483    /// Stable, scheme-prefixed machine code.
484    fn code(&self) -> &'static str;
485    /// Human-readable, consistent-with note.
486    fn note(&self) -> String;
487
488    /// Analytical lens; defaults to [`Category::from_code`] of [`Observation::code`].
489    /// Override when a code's keyword classification is wrong.
490    fn category(&self) -> Category {
491        Category::from_code(self.code())
492    }
493
494    /// Non-disk subjects this kind is about (default: none).
495    fn subjects(&self) -> Vec<SubjectRef> {
496        Vec::new()
497    }
498    /// Backing evidence rows (default: none).
499    fn evidence(&self) -> Vec<Evidence> {
500        Vec::new()
501    }
502    /// MITRE ATT&CK technique ids this kind is consistent with (default: none).
503    fn mitre(&self) -> &'static [&'static str] {
504        &[]
505    }
506    /// Heuristic confidence, if inferential (default: none).
507    fn confidence(&self) -> Option<Confidence> {
508        None
509    }
510
511    /// Assemble the canonical [`Finding`] from this kind and its producing source.
512    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/// One event in the merged super-timeline reconstructed across analyzers.
537#[derive(Debug, Clone, PartialEq, Eq)]
538#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
539pub struct TimelineEvent {
540    /// When it happened (`YYYY[-MM[-DD …]]`), if datable.
541    pub when: Option<String>,
542    /// Analyzer that inferred the event.
543    pub source: String,
544    /// What was observed/inferred.
545    pub event: String,
546}
547
548/// A provenance breadcrumb — a tool/OS/era/vendor fingerprint.
549#[derive(Debug, Clone, PartialEq, Eq)]
550#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
551pub struct Provenance {
552    /// What the breadcrumb identifies (e.g. `alignment`, `bootloader`).
553    pub label: String,
554    /// The observed value / inference.
555    pub value: String,
556    /// Analyzer that observed it.
557    pub source: String,
558}
559
560/// The aggregate normalized report: every analyzer's findings, the merged
561/// timeline, provenance breadcrumbs, and report-level metadata.
562#[non_exhaustive]
563#[derive(Debug, Clone, Default, PartialEq)]
564#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
565pub struct Report {
566    /// All findings, normalized across analyzers.
567    pub findings: Vec<Finding>,
568    /// Provenance breadcrumbs (attribution).
569    pub provenance: Vec<Provenance>,
570    /// Merged super-timeline.
571    pub timeline: Vec<TimelineEvent>,
572    /// Report-level metadata (tool versions, case identifiers, …).
573    pub metadata: Vec<Evidence>,
574}
575
576impl Report {
577    /// The highest *rated* severity among all findings, or `None` when clean or
578    /// entirely unrated.
579    #[must_use]
580    pub fn max_severity(&self) -> Option<Severity> {
581        self.findings.iter().filter_map(|f| f.severity).max()
582    }
583
584    /// Findings rated at least `min` (unrated findings are excluded).
585    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    /// Findings the analyzer deliberately left unrated (`severity: None`).
592    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        // Integrity — lowercase input also exercises the ASCII-uppercasing.
613        assert_eq!(Category::from_code("crc-bad"), Category::Integrity);
614        assert_eq!(Category::from_code("HASH-MISMATCH"), Category::Integrity);
615        // Structure — first (OVERLAP) and last (MAP-COUNT) disjuncts.
616        assert_eq!(
617            Category::from_code("GPT-PARTITION-OVERLAP"),
618            Category::Structure
619        );
620        assert_eq!(Category::from_code("MAP-COUNT"), Category::Structure);
621        // Concealment checked before Residue (PROTECTIVE is the last disjunct).
622        assert_eq!(Category::from_code("PROTECTIVE-MBR"), Category::Concealment);
623        // Residue — ZEROLEN is the last disjunct.
624        assert_eq!(Category::from_code("ZEROLEN-GAP-ONLY"), Category::Residue);
625        // Threat — bare BOOT keyword.
626        assert_eq!(Category::from_code("BOOT-CODE"), Category::Threat);
627        // Fallthrough default.
628        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    /// Overrides every hook so `to_finding` walks all of its assembly loops.
702    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    /// Uses only the required methods so the trait's default hooks are exercised.
737    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        // Default category() delegates to from_code -> Structure.
755        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}