1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3#![allow(clippy::module_name_repetitions)]
4
5use core::{fmt, str::FromStr};
6use std::error::Error;
7
8#[derive(Clone, Copy, Debug, Eq, PartialEq)]
10pub enum ThreatError {
11 Empty,
12 Unknown,
13}
14
15impl fmt::Display for ThreatError {
16 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
17 match self {
18 Self::Empty => formatter.write_str("threat metadata cannot be empty"),
19 Self::Unknown => formatter.write_str("unknown threat label"),
20 }
21 }
22}
23
24impl Error for ThreatError {}
25
26macro_rules! text_newtype {
27 ($name:ident) => {
28 #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
29 pub struct $name(String);
30
31 impl $name {
32 pub fn new(input: impl AsRef<str>) -> Result<Self, ThreatError> {
34 let trimmed = input.as_ref().trim();
35 if trimmed.is_empty() {
36 Err(ThreatError::Empty)
37 } else {
38 Ok(Self(trimmed.to_owned()))
39 }
40 }
41
42 #[must_use]
44 pub fn as_str(&self) -> &str {
45 &self.0
46 }
47 }
48
49 impl fmt::Display for $name {
50 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
51 formatter.write_str(self.as_str())
52 }
53 }
54
55 impl FromStr for $name {
56 type Err = ThreatError;
57
58 fn from_str(input: &str) -> Result<Self, Self::Err> {
59 Self::new(input)
60 }
61 }
62
63 impl TryFrom<&str> for $name {
64 type Error = ThreatError;
65
66 fn try_from(value: &str) -> Result<Self, Self::Error> {
67 Self::new(value)
68 }
69 }
70 };
71}
72
73macro_rules! label_enum {
74 ($name:ident { $($variant:ident => $label:literal),+ $(,)? }) => {
75 impl $name {
76 #[must_use]
78 pub const fn as_str(self) -> &'static str {
79 match self {
80 $(Self::$variant => $label,)+
81 }
82 }
83 }
84
85 impl fmt::Display for $name {
86 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
87 formatter.write_str(self.as_str())
88 }
89 }
90
91 impl FromStr for $name {
92 type Err = ThreatError;
93
94 fn from_str(input: &str) -> Result<Self, Self::Err> {
95 let trimmed = input.trim();
96 if trimmed.is_empty() {
97 return Err(ThreatError::Empty);
98 }
99 let normalized = trimmed.to_ascii_lowercase();
100 match normalized.as_str() {
101 $($label => Ok(Self::$variant),)+
102 _ => Err(ThreatError::Unknown),
103 }
104 }
105 }
106 };
107}
108
109text_newtype!(ThreatId);
110text_newtype!(ThreatSurface);
111
112#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
114pub enum ThreatActorKind {
115 External,
116 Insider,
117 ThirdParty,
118 Automated,
119 NationState,
120 Criminal,
121 Researcher,
122 Unknown,
123}
124
125label_enum!(ThreatActorKind {
126 External => "external",
127 Insider => "insider",
128 ThirdParty => "third-party",
129 Automated => "automated",
130 NationState => "nation-state",
131 Criminal => "criminal",
132 Researcher => "researcher",
133 Unknown => "unknown",
134});
135
136#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
138pub enum ThreatCategory {
139 Spoofing,
140 Tampering,
141 Repudiation,
142 InformationDisclosure,
143 DenialOfService,
144 ElevationOfPrivilege,
145 SupplyChain,
146 SocialEngineering,
147 Other,
148}
149
150label_enum!(ThreatCategory {
151 Spoofing => "spoofing",
152 Tampering => "tampering",
153 Repudiation => "repudiation",
154 InformationDisclosure => "information-disclosure",
155 DenialOfService => "denial-of-service",
156 ElevationOfPrivilege => "elevation-of-privilege",
157 SupplyChain => "supply-chain",
158 SocialEngineering => "social-engineering",
159 Other => "other",
160});
161
162#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
164pub enum ThreatCapability {
165 Low,
166 Medium,
167 High,
168 Advanced,
169}
170
171label_enum!(ThreatCapability {
172 Low => "low",
173 Medium => "medium",
174 High => "high",
175 Advanced => "advanced",
176});
177
178#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
180pub enum ThreatIntent {
181 Curious,
182 Opportunistic,
183 Targeted,
184 Malicious,
185 Unknown,
186}
187
188label_enum!(ThreatIntent {
189 Curious => "curious",
190 Opportunistic => "opportunistic",
191 Targeted => "targeted",
192 Malicious => "malicious",
193 Unknown => "unknown",
194});
195
196#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
198pub enum ThreatModelKind {
199 Stride,
200 AttackTree,
201 KillChain,
202 MitreAttackLike,
203 Custom,
204}
205
206label_enum!(ThreatModelKind {
207 Stride => "stride",
208 AttackTree => "attack-tree",
209 KillChain => "kill-chain",
210 MitreAttackLike => "mitre-attack-like",
211 Custom => "custom",
212});
213
214#[derive(Clone, Debug, Eq, PartialEq)]
216pub struct ThreatScenario {
217 id: ThreatId,
218 category: ThreatCategory,
219 actor: ThreatActorKind,
220}
221
222impl ThreatScenario {
223 #[must_use]
225 pub const fn new(id: ThreatId, category: ThreatCategory, actor: ThreatActorKind) -> Self {
226 Self {
227 id,
228 category,
229 actor,
230 }
231 }
232
233 #[must_use]
235 pub const fn id(&self) -> &ThreatId {
236 &self.id
237 }
238
239 #[must_use]
241 pub const fn category(&self) -> ThreatCategory {
242 self.category
243 }
244
245 #[must_use]
247 pub const fn actor(&self) -> ThreatActorKind {
248 self.actor
249 }
250}
251
252#[cfg(test)]
253mod tests {
254 use super::{ThreatActorKind, ThreatCategory, ThreatId, ThreatModelKind, ThreatScenario};
255
256 #[test]
257 fn validates_threat_id() {
258 let id = ThreatId::new("T-1").expect("threat id");
259
260 assert_eq!(id.as_str(), "T-1");
261 assert!(ThreatId::new(" ").is_err());
262 }
263
264 #[test]
265 fn parses_and_displays_labels() {
266 assert_eq!(
267 "spoofing".parse::<ThreatCategory>().expect("category"),
268 ThreatCategory::Spoofing
269 );
270 assert_eq!(ThreatActorKind::ThirdParty.to_string(), "third-party");
271 assert_eq!(ThreatModelKind::Stride.to_string(), "stride");
272 }
273
274 #[test]
275 fn scenario_reports_metadata() {
276 let scenario = ThreatScenario::new(
277 ThreatId::new("T-1").expect("threat id"),
278 ThreatCategory::Spoofing,
279 ThreatActorKind::External,
280 );
281
282 assert_eq!(scenario.id().as_str(), "T-1");
283 assert_eq!(scenario.category(), ThreatCategory::Spoofing);
284 }
285}