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 SecurityRiskError {
11 Empty,
12 Unknown,
13}
14
15impl fmt::Display for SecurityRiskError {
16 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
17 match self {
18 Self::Empty => formatter.write_str("security risk metadata cannot be empty"),
19 Self::Unknown => formatter.write_str("unknown security risk label"),
20 }
21 }
22}
23
24impl Error for SecurityRiskError {}
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, SecurityRiskError> {
34 let trimmed = input.as_ref().trim();
35 if trimmed.is_empty() {
36 Err(SecurityRiskError::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 = SecurityRiskError;
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 = SecurityRiskError;
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 => $rank:expr),+ $(,)? }) => {
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 #[must_use]
86 pub const fn sort_key(self) -> u8 {
87 match self {
88 $(Self::$variant => $rank,)+
89 }
90 }
91 }
92
93 impl fmt::Display for $name {
94 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
95 formatter.write_str(self.as_str())
96 }
97 }
98
99 impl FromStr for $name {
100 type Err = SecurityRiskError;
101
102 fn from_str(input: &str) -> Result<Self, Self::Err> {
103 let trimmed = input.trim();
104 if trimmed.is_empty() {
105 return Err(SecurityRiskError::Empty);
106 }
107 let normalized = trimmed.to_ascii_lowercase();
108 match normalized.as_str() {
109 $($label => Ok(Self::$variant),)+
110 _ => Err(SecurityRiskError::Unknown),
111 }
112 }
113 }
114 };
115}
116
117text_newtype!(SecurityRiskId);
118text_newtype!(RiskOwner);
119
120#[derive(Clone, Debug, Eq, PartialEq)]
122pub struct SecurityRisk {
123 id: SecurityRiskId,
124 severity: RiskSeverity,
125 likelihood: RiskLikelihood,
126 impact: RiskImpact,
127 status: RiskStatus,
128 category: RiskCategory,
129}
130
131impl SecurityRisk {
132 #[must_use]
134 pub const fn new(
135 id: SecurityRiskId,
136 severity: RiskSeverity,
137 likelihood: RiskLikelihood,
138 impact: RiskImpact,
139 status: RiskStatus,
140 category: RiskCategory,
141 ) -> Self {
142 Self {
143 id,
144 severity,
145 likelihood,
146 impact,
147 status,
148 category,
149 }
150 }
151
152 #[must_use]
154 pub const fn id(&self) -> &SecurityRiskId {
155 &self.id
156 }
157
158 #[must_use]
160 pub const fn severity(&self) -> RiskSeverity {
161 self.severity
162 }
163
164 #[must_use]
166 pub const fn likelihood(&self) -> RiskLikelihood {
167 self.likelihood
168 }
169
170 #[must_use]
172 pub const fn impact(&self) -> RiskImpact {
173 self.impact
174 }
175
176 #[must_use]
178 pub const fn status(&self) -> RiskStatus {
179 self.status
180 }
181
182 #[must_use]
184 pub const fn category(&self) -> RiskCategory {
185 self.category
186 }
187
188 #[must_use]
190 pub const fn priority(&self) -> RiskPriority {
191 priority_from_likelihood_impact(self.likelihood, self.impact)
192 }
193}
194
195#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
197pub enum RiskSeverity {
198 Informational,
199 Low,
200 Medium,
201 High,
202 Critical,
203}
204
205label_enum!(RiskSeverity {
206 Informational => "informational" => 0,
207 Low => "low" => 1,
208 Medium => "medium" => 2,
209 High => "high" => 3,
210 Critical => "critical" => 4,
211});
212
213#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
215pub enum RiskLikelihood {
216 Rare,
217 Unlikely,
218 Possible,
219 Likely,
220 AlmostCertain,
221}
222
223label_enum!(RiskLikelihood {
224 Rare => "rare" => 1,
225 Unlikely => "unlikely" => 2,
226 Possible => "possible" => 3,
227 Likely => "likely" => 4,
228 AlmostCertain => "almost-certain" => 5,
229});
230
231#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
233pub enum RiskImpact {
234 Negligible,
235 Minor,
236 Moderate,
237 Major,
238 Severe,
239}
240
241label_enum!(RiskImpact {
242 Negligible => "negligible" => 1,
243 Minor => "minor" => 2,
244 Moderate => "moderate" => 3,
245 Major => "major" => 4,
246 Severe => "severe" => 5,
247});
248
249#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
251pub enum RiskPriority {
252 P0,
253 P1,
254 P2,
255 P3,
256 P4,
257}
258
259impl RiskPriority {
260 #[must_use]
262 pub const fn as_str(self) -> &'static str {
263 match self {
264 Self::P0 => "p0",
265 Self::P1 => "p1",
266 Self::P2 => "p2",
267 Self::P3 => "p3",
268 Self::P4 => "p4",
269 }
270 }
271
272 #[must_use]
274 pub const fn sort_key(self) -> u8 {
275 match self {
276 Self::P0 => 0,
277 Self::P1 => 1,
278 Self::P2 => 2,
279 Self::P3 => 3,
280 Self::P4 => 4,
281 }
282 }
283}
284
285impl fmt::Display for RiskPriority {
286 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
287 formatter.write_str(self.as_str())
288 }
289}
290
291#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
293pub enum RiskStatus {
294 Open,
295 Accepted,
296 Mitigated,
297 Transferred,
298 Avoided,
299 Closed,
300}
301
302label_enum!(RiskStatus {
303 Open => "open" => 0,
304 Accepted => "accepted" => 1,
305 Mitigated => "mitigated" => 2,
306 Transferred => "transferred" => 3,
307 Avoided => "avoided" => 4,
308 Closed => "closed" => 5,
309});
310
311#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
313pub enum RiskTreatment {
314 Accept,
315 Mitigate,
316 Transfer,
317 Avoid,
318}
319
320label_enum!(RiskTreatment {
321 Accept => "accept" => 0,
322 Mitigate => "mitigate" => 1,
323 Transfer => "transfer" => 2,
324 Avoid => "avoid" => 3,
325});
326
327#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
329pub enum RiskCategory {
330 Application,
331 Infrastructure,
332 Data,
333 Identity,
334 SupplyChain,
335 Operational,
336 Compliance,
337 Privacy,
338 Ai,
339 Other,
340}
341
342label_enum!(RiskCategory {
343 Application => "application" => 0,
344 Infrastructure => "infrastructure" => 1,
345 Data => "data" => 2,
346 Identity => "identity" => 3,
347 SupplyChain => "supply-chain" => 4,
348 Operational => "operational" => 5,
349 Compliance => "compliance" => 6,
350 Privacy => "privacy" => 7,
351 Ai => "ai" => 8,
352 Other => "other" => 9,
353});
354
355#[must_use]
357pub const fn priority_from_likelihood_impact(
358 likelihood: RiskLikelihood,
359 impact: RiskImpact,
360) -> RiskPriority {
361 let score = likelihood.sort_key() * impact.sort_key();
362 if score >= 20 {
363 RiskPriority::P0
364 } else if score >= 16 {
365 RiskPriority::P1
366 } else if score >= 9 {
367 RiskPriority::P2
368 } else if score >= 4 {
369 RiskPriority::P3
370 } else {
371 RiskPriority::P4
372 }
373}
374
375#[cfg(test)]
376mod tests {
377 use super::{
378 RiskCategory, RiskImpact, RiskLikelihood, RiskPriority, RiskSeverity, RiskStatus,
379 SecurityRisk, SecurityRiskId, priority_from_likelihood_impact,
380 };
381
382 #[test]
383 fn validates_risk_id() {
384 let id = SecurityRiskId::new("RISK-1").expect("risk id");
385
386 assert_eq!(id.as_str(), "RISK-1");
387 assert!(SecurityRiskId::new(" ").is_err());
388 }
389
390 #[test]
391 fn parses_and_displays_labels() {
392 assert_eq!(
393 "critical".parse::<RiskSeverity>().expect("severity"),
394 RiskSeverity::Critical
395 );
396 assert_eq!(RiskCategory::SupplyChain.to_string(), "supply-chain");
397 }
398
399 #[test]
400 fn computes_sortable_priority() {
401 assert_eq!(
402 priority_from_likelihood_impact(RiskLikelihood::Likely, RiskImpact::Major),
403 RiskPriority::P1
404 );
405 assert!(RiskPriority::P0.sort_key() < RiskPriority::P4.sort_key());
406 }
407
408 #[test]
409 fn risk_record_reports_priority() {
410 let risk = SecurityRisk::new(
411 SecurityRiskId::new("R-1").expect("risk id"),
412 RiskSeverity::High,
413 RiskLikelihood::AlmostCertain,
414 RiskImpact::Severe,
415 RiskStatus::Open,
416 RiskCategory::Application,
417 );
418
419 assert_eq!(risk.priority(), RiskPriority::P0);
420 assert_eq!(risk.id().as_str(), "R-1");
421 }
422}