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())
}
}