use serde::Serialize;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
pub enum Category {
PrivilegedAccounts,
Trusts,
StaleObjects,
Anomalies,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub enum Severity {
Info = 0,
Low = 1,
Medium = 2,
High = 3,
Critical = 4,
}
impl Severity {
pub fn base_weight(self) -> u32 {
match self {
Severity::Info => 0,
Severity::Low => 5,
Severity::Medium => 15,
Severity::High => 30,
Severity::Critical => 50,
}
}
}
#[derive(Clone, Copy, Debug, Serialize)]
pub struct Mitre {
pub id: &'static str,
pub name: &'static str,
}
pub mod mitre {
use super::Mitre;
pub const KERBEROASTING: Mitre = Mitre {
id: "T1558.003",
name: "Kerberoasting",
};
pub const ASREP_ROAST: Mitre = Mitre {
id: "T1558.004",
name: "AS-REP Roasting",
};
pub const GOLDEN_TICKET: Mitre = Mitre {
id: "T1558.001",
name: "Golden Ticket",
};
pub const SILVER_TICKET: Mitre = Mitre {
id: "T1558.002",
name: "Silver Ticket",
};
pub const DCSYNC: Mitre = Mitre {
id: "T1003.006",
name: "DCSync",
};
pub const DCSHADOW: Mitre = Mitre {
id: "T1207",
name: "Rogue Domain Controller",
};
pub const GPO_MOD: Mitre = Mitre {
id: "T1484.001",
name: "Group Policy Modification",
};
pub const TRUST_MOD: Mitre = Mitre {
id: "T1484.002",
name: "Domain Trust Modification",
};
pub const CERT_ABUSE: Mitre = Mitre {
id: "T1649",
name: "Steal or Forge Auth Certificates",
};
pub const VALID_ACCOUNTS: Mitre = Mitre {
id: "T1078",
name: "Valid Accounts",
};
pub const COERCION: Mitre = Mitre {
id: "T1187",
name: "Forced Authentication",
};
}
#[derive(Clone, Debug, Serialize)]
pub struct Evidence {
pub source: String,
pub value: String,
}
impl Evidence {
pub fn new(source: impl Into<String>, value: impl Into<String>) -> Self {
Self {
source: source.into(),
value: value.into(),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum WireLayer {
Ldap,
Rrp,
Smb,
Kerberos,
Rpc,
Http,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum WireDirection {
Sent,
Recv,
}
#[derive(Clone, Debug, Serialize)]
pub struct WireExchange {
pub layer: WireLayer,
pub direction: WireDirection,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub opnum: Option<u16>,
pub summary: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub raw_hex: Option<String>,
}
impl WireExchange {
pub fn sent(layer: WireLayer, summary: impl Into<String>) -> Self {
Self {
layer,
direction: WireDirection::Sent,
opnum: None,
summary: summary.into(),
raw_hex: None,
}
}
pub fn recv(layer: WireLayer, summary: impl Into<String>) -> Self {
Self {
layer,
direction: WireDirection::Recv,
opnum: None,
summary: summary.into(),
raw_hex: None,
}
}
pub fn with_opnum(mut self, opnum: u16) -> Self {
self.opnum = Some(opnum);
self
}
pub fn with_raw_bytes(mut self, bytes: &[u8]) -> Self {
const MAX_RAW_HEX_BYTES: usize = 512;
let take = bytes.len().min(MAX_RAW_HEX_BYTES);
let mut hex = String::with_capacity(take * 2);
for b in &bytes[..take] {
use std::fmt::Write;
let _ = write!(&mut hex, "{b:02x}");
}
if bytes.len() > MAX_RAW_HEX_BYTES {
hex.push('…');
}
self.raw_hex = Some(hex);
self
}
}
#[derive(Clone, Debug, Serialize)]
pub struct Finding {
pub id: String, pub title: String,
pub category: Category,
pub severity: Severity,
pub mitre: Vec<Mitre>,
pub affected: Vec<String>,
pub detail: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub evidence: Vec<Evidence>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub exchange: Vec<WireExchange>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub impact: Option<String>,
pub remediation: String,
#[serde(default)]
pub weight_bonus: u32,
}
impl Finding {
pub fn with_impact(mut self, impact: impl Into<String>) -> Self {
self.impact = Some(impact.into());
self
}
pub fn with_evidence(mut self, source: impl Into<String>, value: impl Into<String>) -> Self {
self.evidence.push(Evidence::new(source, value));
self
}
pub fn with_evidences(mut self, ev: impl IntoIterator<Item = Evidence>) -> Self {
self.evidence.extend(ev);
self
}
pub fn with_wire(mut self, ex: WireExchange) -> Self {
self.exchange.push(ex);
self
}
pub fn with_wires(mut self, ex: impl IntoIterator<Item = WireExchange>) -> Self {
self.exchange.extend(ex);
self
}
}
impl Finding {
pub fn score(&self) -> u32 {
self.severity.base_weight() + self.weight_bonus
}
}
#[derive(Clone, Debug, Serialize)]
pub struct AttackResult {
pub command: String,
pub success: bool,
pub evidence: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub finding_id: Option<String>,
}
#[cfg(test)]
mod wire_tests {
use super::*;
#[test]
fn wire_exchange_builders_shape_sent_and_recv() {
let sent = WireExchange::sent(WireLayer::Ldap, "LDAP search filter=(objectClass=user)")
.with_opnum(3);
assert_eq!(sent.direction, WireDirection::Sent);
assert_eq!(sent.layer, WireLayer::Ldap);
assert_eq!(sent.opnum, Some(3));
assert!(sent.raw_hex.is_none());
let recv = WireExchange::recv(WireLayer::Http, "HTTP/1.1 401 Unauthorized");
assert_eq!(recv.direction, WireDirection::Recv);
assert!(recv.opnum.is_none());
}
#[test]
fn wire_raw_bytes_are_capped_against_hostile_server() {
let big = vec![0xABu8; 4096];
let ex = WireExchange::recv(WireLayer::Rpc, "big blob").with_raw_bytes(&big);
let hex = ex.raw_hex.expect("raw_hex populated");
assert!(hex.ends_with('…'), "hex truncated with ellipsis marker");
assert!(
hex.chars().count() <= 1025,
"hex string {} chars — cap not enforced",
hex.chars().count()
);
}
#[test]
fn finding_with_wire_and_with_wires_extend_the_field() {
let f = Finding {
id: "T".into(),
title: "t".into(),
category: Category::Anomalies,
severity: Severity::Low,
mitre: vec![],
affected: vec![],
detail: String::new(),
evidence: vec![],
exchange: vec![],
impact: None,
remediation: String::new(),
weight_bonus: 0,
}
.with_wire(WireExchange::sent(WireLayer::Ldap, "s1"))
.with_wires([
WireExchange::recv(WireLayer::Ldap, "r1"),
WireExchange::sent(WireLayer::Rrp, "s2").with_opnum(15),
]);
assert_eq!(f.exchange.len(), 3);
assert_eq!(f.exchange[2].opnum, Some(15));
}
}