use core::fmt;
use core::num::NonZeroU64;
use std::borrow::Cow;
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum Severity {
Info,
Low,
Medium,
High,
Critical,
}
impl fmt::Display for Severity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Severity::Info => "INFO",
Severity::Low => "LOW",
Severity::Medium => "MEDIUM",
Severity::High => "HIGH",
Severity::Critical => "CRITICAL",
})
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum Category {
Integrity,
Structure,
Residue,
Provenance,
History,
Concealment,
Threat,
}
impl Category {
#[must_use]
pub fn from_code(code: &str) -> Category {
let c = code.to_ascii_uppercase();
if c.contains("CRC")
|| c.contains("INTEGRITY")
|| c.contains("CHECKSUM")
|| c.contains("HASH")
{
Category::Integrity
} else if c.contains("OVERLAP")
|| c.contains("OOB")
|| c.contains("BOUND")
|| c.contains("CHS")
|| c.contains("MAP-COUNT")
{
Category::Structure
} else if c.contains("HIDDEN")
|| c.contains("CONCEAL")
|| c.contains("WIPED")
|| c.contains("ERASED")
|| c.contains("SPOOF")
|| c.contains("PROTECTIVE")
{
Category::Concealment
} else if c.contains("RESIDUAL")
|| c.contains("SLACK")
|| c.contains("GAP")
|| c.contains("CARVE")
|| c.contains("UNMAPPED")
|| c.contains("ZEROLEN")
{
Category::Residue
} else if c.contains("BOOT") {
Category::Threat
} else {
Category::Structure
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum Location {
ByteOffset(u64),
Lba(u64),
Sector(u64),
Rva(u64),
RecordId(u64),
Path(String),
Field(String),
Key(String),
Other {
space: String,
value: u64,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Evidence {
pub field: String,
pub value: String,
pub location: Option<Location>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct SubjectRef {
pub scheme: String,
pub kind: String,
pub id: String,
pub label: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ExternalRef {
pub scheme: String,
pub id: String,
pub url: Option<String>,
}
impl ExternalRef {
#[must_use]
pub fn mitre_attack(id: impl Into<String>) -> Self {
Self {
scheme: "mitre-attack".to_string(),
id: id.into(),
url: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(try_from = "f32", into = "f32"))]
pub struct Confidence(f32);
impl Confidence {
#[must_use]
pub fn new(value: f32) -> Option<Self> {
if value.is_finite() && (0.0..=1.0).contains(&value) {
Some(Self(value))
} else {
None
}
}
#[must_use]
pub fn get(self) -> f32 {
self.0
}
}
impl TryFrom<f32> for Confidence {
type Error = &'static str;
fn try_from(value: f32) -> Result<Self, Self::Error> {
Self::new(value).ok_or("confidence must be finite and within 0.0..=1.0")
}
}
impl From<Confidence> for f32 {
fn from(c: Confidence) -> Self {
c.0
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Timestamp {
pub value: String,
pub kind: String,
pub location: Option<Location>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Source {
pub analyzer: String,
pub scope: String,
pub version: Option<String>,
}
#[non_exhaustive]
#[derive(Debug, Clone, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct FindingContext {
pub confidence: Option<Confidence>,
pub occurrences: Option<NonZeroU64>,
pub timestamps: Vec<Timestamp>,
pub external_refs: Vec<ExternalRef>,
pub tags: Vec<Cow<'static, str>>,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Finding {
pub severity: Option<Severity>,
pub category: Category,
pub code: Cow<'static, str>,
pub note: String,
pub source: Source,
pub subjects: Vec<SubjectRef>,
pub evidence: Vec<Evidence>,
pub context: FindingContext,
}
impl Finding {
#[must_use]
pub fn observation(
severity: Severity,
category: Category,
code: impl Into<Cow<'static, str>>,
) -> FindingBuilder {
FindingBuilder::new(Some(severity), category, code.into())
}
#[must_use]
pub fn unrated(category: Category, code: impl Into<Cow<'static, str>>) -> FindingBuilder {
FindingBuilder::new(None, category, code.into())
}
}
#[derive(Debug, Clone)]
pub struct FindingBuilder {
finding: Finding,
}
impl FindingBuilder {
fn new(severity: Option<Severity>, category: Category, code: Cow<'static, str>) -> Self {
Self {
finding: Finding {
severity,
category,
code,
note: String::new(),
source: Source::default(),
subjects: Vec::new(),
evidence: Vec::new(),
context: FindingContext::default(),
},
}
}
#[must_use]
pub fn note(mut self, note: impl Into<String>) -> Self {
self.finding.note = note.into();
self
}
#[must_use]
pub fn source(mut self, source: Source) -> Self {
self.finding.source = source;
self
}
#[must_use]
pub fn evidence(self, field: impl Into<String>, value: impl Into<String>) -> Self {
self.evidence_item(Evidence {
field: field.into(),
value: value.into(),
location: None,
})
}
#[must_use]
pub fn evidence_at(
self,
field: impl Into<String>,
value: impl Into<String>,
location: Location,
) -> Self {
self.evidence_item(Evidence {
field: field.into(),
value: value.into(),
location: Some(location),
})
}
#[must_use]
pub fn evidence_item(mut self, evidence: Evidence) -> Self {
self.finding.evidence.push(evidence);
self
}
#[must_use]
pub fn subject(mut self, subject: SubjectRef) -> Self {
self.finding.subjects.push(subject);
self
}
#[must_use]
pub fn mitre(self, technique: impl Into<String>) -> Self {
self.external_ref(ExternalRef::mitre_attack(technique))
}
#[must_use]
pub fn external_ref(mut self, reference: ExternalRef) -> Self {
self.finding.context.external_refs.push(reference);
self
}
#[must_use]
pub fn confidence(mut self, confidence: Confidence) -> Self {
self.finding.context.confidence = Some(confidence);
self
}
#[must_use]
pub fn occurrences(mut self, count: u64) -> Self {
self.finding.context.occurrences = NonZeroU64::new(count);
self
}
#[must_use]
pub fn timestamp(mut self, timestamp: Timestamp) -> Self {
self.finding.context.timestamps.push(timestamp);
self
}
#[must_use]
pub fn tag(mut self, tag: impl Into<Cow<'static, str>>) -> Self {
self.finding.context.tags.push(tag.into());
self
}
#[must_use]
pub fn build(self) -> Finding {
self.finding
}
}
pub trait Observation {
fn severity(&self) -> Option<Severity>;
fn code(&self) -> &'static str;
fn note(&self) -> String;
fn category(&self) -> Category {
Category::from_code(self.code())
}
fn subjects(&self) -> Vec<SubjectRef> {
Vec::new()
}
fn evidence(&self) -> Vec<Evidence> {
Vec::new()
}
fn mitre(&self) -> &'static [&'static str] {
&[]
}
fn confidence(&self) -> Option<Confidence> {
None
}
fn to_finding(&self, source: Source) -> Finding {
let mut builder = match self.severity() {
Some(sev) => Finding::observation(sev, self.category(), self.code()),
None => Finding::unrated(self.category(), self.code()),
}
.note(self.note())
.source(source);
for subject in self.subjects() {
builder = builder.subject(subject);
}
for evidence in self.evidence() {
builder = builder.evidence_item(evidence);
}
for technique in self.mitre() {
builder = builder.mitre(*technique);
}
if let Some(confidence) = self.confidence() {
builder = builder.confidence(confidence);
}
builder.build()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct TimelineEvent {
pub when: Option<String>,
pub source: String,
pub event: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Provenance {
pub label: String,
pub value: String,
pub source: String,
}
#[non_exhaustive]
#[derive(Debug, Clone, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Report {
pub findings: Vec<Finding>,
pub provenance: Vec<Provenance>,
pub timeline: Vec<TimelineEvent>,
pub metadata: Vec<Evidence>,
}
impl Report {
#[must_use]
pub fn max_severity(&self) -> Option<Severity> {
self.findings.iter().filter_map(|f| f.severity).max()
}
pub fn findings_at_least(&self, min: Severity) -> impl Iterator<Item = &Finding> {
self.findings
.iter()
.filter(move |f| f.severity.is_some_and(|s| s >= min))
}
pub fn unrated_findings(&self) -> impl Iterator<Item = &Finding> {
self.findings.iter().filter(|f| f.severity.is_none())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn severity_display_renders_uppercase_labels() {
assert_eq!(Severity::Info.to_string(), "INFO");
assert_eq!(Severity::Low.to_string(), "LOW");
assert_eq!(Severity::Medium.to_string(), "MEDIUM");
assert_eq!(Severity::High.to_string(), "HIGH");
assert_eq!(Severity::Critical.to_string(), "CRITICAL");
}
#[test]
fn category_from_code_classifies_each_lens() {
assert_eq!(Category::from_code("crc-bad"), Category::Integrity);
assert_eq!(Category::from_code("HASH-MISMATCH"), Category::Integrity);
assert_eq!(
Category::from_code("GPT-PARTITION-OVERLAP"),
Category::Structure
);
assert_eq!(Category::from_code("MAP-COUNT"), Category::Structure);
assert_eq!(Category::from_code("PROTECTIVE-MBR"), Category::Concealment);
assert_eq!(Category::from_code("ZEROLEN-GAP-ONLY"), Category::Residue);
assert_eq!(Category::from_code("BOOT-CODE"), Category::Threat);
assert_eq!(Category::from_code("MYSTERY"), Category::Structure);
}
#[test]
fn confidence_validates_range() {
assert_eq!(Confidence::new(0.5).map(Confidence::get), Some(0.5));
assert_eq!(Confidence::new(0.0).map(Confidence::get), Some(0.0));
assert!(Confidence::new(f32::NAN).is_none());
assert!(Confidence::new(1.5).is_none());
assert!(Confidence::new(-0.1).is_none());
}
#[test]
fn confidence_try_from_and_into_f32() {
let c = Confidence::try_from(0.25_f32).expect("0.25 is in range");
assert_eq!(c.get(), 0.25);
assert!(Confidence::try_from(2.0_f32).is_err());
let back: f32 = c.into();
assert_eq!(back, 0.25);
}
#[test]
fn finding_builder_populates_all_fields() {
let source = Source {
analyzer: "test-forensic".to_string(),
scope: "partition 1".to_string(),
version: Some("1.0".to_string()),
};
let conf = Confidence::new(0.9).expect("0.9 is in range");
let finding = Finding::observation(Severity::High, Category::Structure, "TEST-CODE")
.note("consistent with tampering")
.source(source.clone())
.evidence("field", "value")
.evidence_at("lba", "42", Location::Lba(42))
.evidence_item(Evidence {
field: "raw".to_string(),
value: "bytes".to_string(),
location: None,
})
.subject(SubjectRef {
scheme: "memory".to_string(),
kind: "process".to_string(),
id: "pid:4".to_string(),
label: Some("system".to_string()),
})
.confidence(conf)
.occurrences(7)
.timestamp(Timestamp {
value: "2026-01-01T00:00:00Z".to_string(),
kind: "observed".to_string(),
location: None,
})
.tag("flagged")
.build();
assert_eq!(finding.severity, Some(Severity::High));
assert_eq!(finding.note, "consistent with tampering");
assert_eq!(finding.source, source);
assert_eq!(finding.evidence.len(), 3);
assert_eq!(finding.subjects.len(), 1);
assert_eq!(finding.context.confidence, Some(conf));
assert_eq!(finding.context.occurrences, NonZeroU64::new(7));
assert_eq!(finding.context.timestamps.len(), 1);
assert_eq!(finding.context.tags, vec![Cow::Borrowed("flagged")]);
}
#[test]
fn unrated_finding_has_no_severity() {
let finding = Finding::unrated(Category::Provenance, "PROV-TOOL").build();
assert_eq!(finding.severity, None);
assert_eq!(finding.category, Category::Provenance);
}
struct RatedKind;
impl Observation for RatedKind {
fn severity(&self) -> Option<Severity> {
Some(Severity::Critical)
}
fn code(&self) -> &'static str {
"GPT-PARTITION-OVERLAP"
}
fn note(&self) -> String {
"overlap".to_string()
}
fn subjects(&self) -> Vec<SubjectRef> {
vec![SubjectRef {
scheme: "disk".to_string(),
kind: "partition".to_string(),
id: "1".to_string(),
label: None,
}]
}
fn evidence(&self) -> Vec<Evidence> {
vec![Evidence {
field: "range".to_string(),
value: "0..10".to_string(),
location: Some(Location::Lba(0)),
}]
}
fn mitre(&self) -> &'static [&'static str] {
&["T1542"]
}
fn confidence(&self) -> Option<Confidence> {
Confidence::new(0.8)
}
}
struct UnratedKind;
impl Observation for UnratedKind {
fn severity(&self) -> Option<Severity> {
None
}
fn code(&self) -> &'static str {
"PE-WX-SECTION"
}
fn note(&self) -> String {
"writable+executable".to_string()
}
}
#[test]
fn observation_to_finding_assembles_rated() {
let finding = RatedKind.to_finding(Source::default());
assert_eq!(finding.severity, Some(Severity::Critical));
assert_eq!(finding.category, Category::Structure);
assert_eq!(finding.code, "GPT-PARTITION-OVERLAP");
assert_eq!(finding.note, "overlap");
assert_eq!(finding.subjects.len(), 1);
assert_eq!(finding.evidence.len(), 1);
assert_eq!(finding.context.external_refs.len(), 1);
assert_eq!(finding.context.external_refs[0].scheme, "mitre-attack");
assert_eq!(finding.context.external_refs[0].id, "T1542");
assert_eq!(finding.context.confidence, Confidence::new(0.8));
}
#[test]
fn observation_to_finding_assembles_unrated_with_defaults() {
let finding = UnratedKind.to_finding(Source::default());
assert_eq!(finding.severity, None);
assert_eq!(finding.category, Category::Structure);
assert!(finding.subjects.is_empty());
assert!(finding.evidence.is_empty());
assert!(finding.context.external_refs.is_empty());
assert!(finding.context.confidence.is_none());
}
#[test]
fn report_severity_queries() {
let mut report = Report::default();
report
.findings
.push(Finding::observation(Severity::Low, Category::Provenance, "A").build());
report
.findings
.push(Finding::observation(Severity::Critical, Category::Structure, "B").build());
report
.findings
.push(Finding::unrated(Category::History, "C").build());
assert_eq!(report.max_severity(), Some(Severity::Critical));
assert_eq!(report.findings_at_least(Severity::Medium).count(), 1);
assert_eq!(report.unrated_findings().count(), 1);
}
#[test]
fn report_max_severity_none_when_empty() {
let report = Report::default();
assert_eq!(report.max_severity(), None);
}
}