Skip to main content

adhammer_core/
finding.rs

1//! The output vocabulary: a Finding is one rule firing, tagged with a hygiene
2//! category, a severity, and one or more MITRE ATT&CK techniques.
3
4use serde::Serialize;
5
6/// The four top-level AD hygiene categories a Finding rolls up under.
7#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
8pub enum Category {
9    PrivilegedAccounts,
10    Trusts,
11    StaleObjects,
12    Anomalies,
13}
14
15#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
16pub enum Severity {
17    Info = 0,
18    Low = 1,
19    Medium = 2,
20    High = 3,
21    Critical = 4,
22}
23
24impl Severity {
25    /// Base weight fed into the risk engine (overridable via config).
26    pub fn base_weight(self) -> u32 {
27        match self {
28            Severity::Info => 0,
29            Severity::Low => 5,
30            Severity::Medium => 15,
31            Severity::High => 30,
32            Severity::Critical => 50,
33        }
34    }
35}
36
37/// MITRE ATT&CK technique reference, e.g. ("T1558.003", "Kerberoasting").
38#[derive(Clone, Copy, Debug, Serialize)]
39pub struct Mitre {
40    pub id: &'static str,
41    pub name: &'static str,
42}
43
44/// Common techniques, referenced by checks so the mapping lives in one place.
45pub mod mitre {
46    use super::Mitre;
47    pub const KERBEROASTING: Mitre = Mitre {
48        id: "T1558.003",
49        name: "Kerberoasting",
50    };
51    pub const ASREP_ROAST: Mitre = Mitre {
52        id: "T1558.004",
53        name: "AS-REP Roasting",
54    };
55    pub const GOLDEN_TICKET: Mitre = Mitre {
56        id: "T1558.001",
57        name: "Golden Ticket",
58    };
59    pub const SILVER_TICKET: Mitre = Mitre {
60        id: "T1558.002",
61        name: "Silver Ticket",
62    };
63    pub const DCSYNC: Mitre = Mitre {
64        id: "T1003.006",
65        name: "DCSync",
66    };
67    pub const DCSHADOW: Mitre = Mitre {
68        id: "T1207",
69        name: "Rogue Domain Controller",
70    };
71    pub const GPO_MOD: Mitre = Mitre {
72        id: "T1484.001",
73        name: "Group Policy Modification",
74    };
75    pub const TRUST_MOD: Mitre = Mitre {
76        id: "T1484.002",
77        name: "Domain Trust Modification",
78    };
79    pub const CERT_ABUSE: Mitre = Mitre {
80        id: "T1649",
81        name: "Steal or Forge Auth Certificates",
82    };
83    pub const VALID_ACCOUNTS: Mitre = Mitre {
84        id: "T1078",
85        name: "Valid Accounts",
86    };
87    pub const COERCION: Mitre = Mitre {
88        id: "T1187",
89        name: "Forced Authentication",
90    };
91}
92
93/// A single piece of **ground-truth evidence** substantiating a finding (WS-PROOF): the actual
94/// server/client artifact — an LDAP attribute value, an MS-RRP registry key, a SAMR field, a wire
95/// status code — that a reviewer can verify **by hand, independent of adhammer's verdict**. This is
96/// the difference between "you have X" (our word) and "the server returned Y, which is X" (proof).
97#[derive(Clone, Debug, Serialize)]
98pub struct Evidence {
99    /// Where it came from, expressed so a reviewer can reproduce it — e.g.
100    /// `LDAP CN=svc_sql,…:msDS-SupportedEncryptionTypes`,
101    /// `MS-RRP HKLM\SYSTEM\CurrentControlSet\…\StrongCertificateBindingEnforcement`,
102    /// `SAMR DOMAIN_PASSWORD_INFORMATION.MinPasswordLength`.
103    pub source: String,
104    /// The raw value exactly as the server/client returned it (decoded/hex as needed for legibility).
105    pub value: String,
106}
107
108impl Evidence {
109    pub fn new(source: impl Into<String>, value: impl Into<String>) -> Self {
110        Self {
111            source: source.into(),
112            value: value.into(),
113        }
114    }
115}
116
117#[derive(Clone, Debug, Serialize)]
118pub struct Finding {
119    pub id: String, // stable rule id, e.g. "P-KerberoastAdmin"
120    pub title: String,
121    pub category: Category,
122    pub severity: Severity,
123    pub mitre: Vec<Mitre>,
124    /// DNs / SIDs the finding points at.
125    pub affected: Vec<String>,
126    /// What was observed (evidence-level: raw stat, matched attribute, etc.).
127    pub detail: String,
128    /// Ground-truth evidence (WS-PROOF): the raw server/client artifacts that prove this finding,
129    /// each verifiable by hand. Empty only for not-yet-evidenced legacy rules; the 1.4.3 goal is
130    /// every finding carries ≥1. Reports/UIs render it under a distinct "Evidence" heading.
131    #[serde(default, skip_serializing_if = "Vec::is_empty")]
132    pub evidence: Vec<Evidence>,
133    /// Attack-chain narrative: if an attacker acted on this finding, what would happen?
134    /// 1-2 sentences. Optional so downstream Finding producers can leave it blank; UIs
135    /// render it under a distinct "Impact" heading and reports omit the section if `None`.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub impact: Option<String>,
138    pub remediation: String,
139    /// Extra weight beyond the severity base (e.g. per-object scaling).
140    #[serde(default)]
141    pub weight_bonus: u32,
142}
143
144impl Finding {
145    /// Chainable setter for [`Self::impact`] — used by rule constructors that want to
146    /// annotate the attack-chain narrative alongside the raw evidence.
147    pub fn with_impact(mut self, impact: impl Into<String>) -> Self {
148        self.impact = Some(impact.into());
149        self
150    }
151
152    /// Attach one piece of ground-truth evidence (chainable) — see [`Evidence`].
153    pub fn with_evidence(mut self, source: impl Into<String>, value: impl Into<String>) -> Self {
154        self.evidence.push(Evidence::new(source, value));
155        self
156    }
157
158    /// Attach several evidence rows at once (chainable).
159    pub fn with_evidences(mut self, ev: impl IntoIterator<Item = Evidence>) -> Self {
160        self.evidence.extend(ev);
161        self
162    }
163}
164
165impl Finding {
166    pub fn score(&self) -> u32 {
167        self.severity.base_weight() + self.weight_bonus
168    }
169}
170
171#[derive(Clone, Debug, Serialize)]
172pub struct AttackResult {
173    pub command: String,
174    pub success: bool,
175    pub evidence: String,
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub finding_id: Option<String>,
178}