Skip to main content

use_security_risk/
lib.rs

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/// Error returned when security risk metadata is invalid.
9#[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            /// Creates non-empty security risk text metadata.
33            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            /// Returns the stored text.
43            #[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            /// Returns the stable label.
77            #[must_use]
78            pub const fn as_str(self) -> &'static str {
79                match self {
80                    $(Self::$variant => $label,)+
81                }
82            }
83
84            /// Returns a numeric rank for sorting.
85            #[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/// Security risk metadata.
121#[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    /// Creates a security risk metadata record.
133    #[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    /// Returns the risk identifier.
153    #[must_use]
154    pub const fn id(&self) -> &SecurityRiskId {
155        &self.id
156    }
157
158    /// Returns the severity label.
159    #[must_use]
160    pub const fn severity(&self) -> RiskSeverity {
161        self.severity
162    }
163
164    /// Returns the likelihood label.
165    #[must_use]
166    pub const fn likelihood(&self) -> RiskLikelihood {
167        self.likelihood
168    }
169
170    /// Returns the impact label.
171    #[must_use]
172    pub const fn impact(&self) -> RiskImpact {
173        self.impact
174    }
175
176    /// Returns the status label.
177    #[must_use]
178    pub const fn status(&self) -> RiskStatus {
179        self.status
180    }
181
182    /// Returns the category label.
183    #[must_use]
184    pub const fn category(&self) -> RiskCategory {
185        self.category
186    }
187
188    /// Returns a sortable priority derived from likelihood and impact.
189    #[must_use]
190    pub const fn priority(&self) -> RiskPriority {
191        priority_from_likelihood_impact(self.likelihood, self.impact)
192    }
193}
194
195/// Risk severity labels.
196#[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/// Risk likelihood labels.
214#[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/// Risk impact labels.
232#[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/// Sortable risk priority labels.
250#[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    /// Returns a stable priority label.
261    #[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    /// Returns a numeric sort key where lower means more urgent.
273    #[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/// Risk status labels.
292#[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/// Risk treatment labels.
312#[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/// Risk category labels.
328#[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/// Returns a sortable priority from likelihood and impact.
356#[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}