Skip to main content

use_security_finding/
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 finding metadata is invalid.
9#[derive(Clone, Copy, Debug, Eq, PartialEq)]
10pub enum SecurityFindingError {
11    Empty,
12    Unknown,
13}
14
15impl fmt::Display for SecurityFindingError {
16    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
17        match self {
18            Self::Empty => formatter.write_str("security finding metadata cannot be empty"),
19            Self::Unknown => formatter.write_str("unknown security finding label"),
20        }
21    }
22}
23
24impl Error for SecurityFindingError {}
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 finding text metadata.
33            pub fn new(input: impl AsRef<str>) -> Result<Self, SecurityFindingError> {
34                let trimmed = input.as_ref().trim();
35                if trimmed.is_empty() {
36                    Err(SecurityFindingError::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 = SecurityFindingError;
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 = SecurityFindingError;
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            /// 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
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 = SecurityFindingError;
93
94            fn from_str(input: &str) -> Result<Self, Self::Err> {
95                let trimmed = input.trim();
96                if trimmed.is_empty() {
97                    return Err(SecurityFindingError::Empty);
98                }
99                let normalized = trimmed.to_ascii_lowercase();
100                match normalized.as_str() {
101                    $($label => Ok(Self::$variant),)+
102                    _ => Err(SecurityFindingError::Unknown),
103                }
104            }
105        }
106    };
107}
108
109text_newtype!(SecurityFindingId);
110text_newtype!(FindingSource);
111text_newtype!(FindingLocation);
112text_newtype!(FindingEvidence);
113text_newtype!(FindingReference);
114
115/// Security finding metadata.
116#[derive(Clone, Debug, Eq, PartialEq)]
117pub struct SecurityFinding {
118    id: SecurityFindingId,
119    kind: FindingKind,
120    severity: FindingSeverity,
121    status: FindingStatus,
122    confidence: FindingConfidence,
123    references: Vec<FindingReference>,
124}
125
126impl SecurityFinding {
127    /// Creates security finding metadata.
128    #[must_use]
129    pub fn new(id: SecurityFindingId, kind: FindingKind, severity: FindingSeverity) -> Self {
130        Self {
131            id,
132            kind,
133            severity,
134            status: FindingStatus::New,
135            confidence: FindingConfidence::Low,
136            references: Vec::new(),
137        }
138    }
139
140    /// Returns the finding ID.
141    #[must_use]
142    pub const fn id(&self) -> &SecurityFindingId {
143        &self.id
144    }
145
146    /// Returns the finding kind.
147    #[must_use]
148    pub const fn kind(&self) -> FindingKind {
149        self.kind
150    }
151
152    /// Returns the severity label.
153    #[must_use]
154    pub const fn severity(&self) -> FindingSeverity {
155        self.severity
156    }
157
158    /// Returns the status label.
159    #[must_use]
160    pub const fn status(&self) -> FindingStatus {
161        self.status
162    }
163
164    /// Returns the confidence label.
165    #[must_use]
166    pub const fn confidence(&self) -> FindingConfidence {
167        self.confidence
168    }
169
170    /// Returns lightweight references such as CVE, CWE, CVSS, or OWASP IDs.
171    #[must_use]
172    pub fn references(&self) -> &[FindingReference] {
173        &self.references
174    }
175
176    /// Adds a lightweight reference to the finding.
177    #[must_use]
178    pub fn with_reference(mut self, reference: FindingReference) -> Self {
179        self.references.push(reference);
180        self
181    }
182
183    /// Returns a copy with updated status.
184    #[must_use]
185    pub const fn with_status(mut self, status: FindingStatus) -> Self {
186        self.status = status;
187        self
188    }
189
190    /// Returns a copy with updated confidence.
191    #[must_use]
192    pub const fn with_confidence(mut self, confidence: FindingConfidence) -> Self {
193        self.confidence = confidence;
194        self
195    }
196}
197
198/// Finding source category labels.
199#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
200pub enum FindingKind {
201    Vulnerability,
202    Weakness,
203    Misconfiguration,
204    Secret,
205    Dependency,
206    License,
207    PolicyViolation,
208    Malware,
209    SuspiciousPattern,
210    Other,
211}
212
213label_enum!(FindingKind {
214    Vulnerability => "vulnerability",
215    Weakness => "weakness",
216    Misconfiguration => "misconfiguration",
217    Secret => "secret",
218    Dependency => "dependency",
219    License => "license",
220    PolicyViolation => "policy-violation",
221    Malware => "malware",
222    SuspiciousPattern => "suspicious-pattern",
223    Other => "other",
224});
225
226/// Finding lifecycle status labels.
227#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
228pub enum FindingStatus {
229    New,
230    Triaged,
231    Confirmed,
232    FalsePositive,
233    AcceptedRisk,
234    Fixed,
235    Reopened,
236    Closed,
237}
238
239label_enum!(FindingStatus {
240    New => "new",
241    Triaged => "triaged",
242    Confirmed => "confirmed",
243    FalsePositive => "false-positive",
244    AcceptedRisk => "accepted-risk",
245    Fixed => "fixed",
246    Reopened => "reopened",
247    Closed => "closed",
248});
249
250/// Finding confidence labels.
251#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
252pub enum FindingConfidence {
253    Low,
254    Medium,
255    High,
256    Confirmed,
257}
258
259label_enum!(FindingConfidence {
260    Low => "low",
261    Medium => "medium",
262    High => "high",
263    Confirmed => "confirmed",
264});
265
266/// Finding severity labels.
267#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
268pub enum FindingSeverity {
269    Informational,
270    Low,
271    Medium,
272    High,
273    Critical,
274}
275
276label_enum!(FindingSeverity {
277    Informational => "informational",
278    Low => "low",
279    Medium => "medium",
280    High => "high",
281    Critical => "critical",
282});
283
284/// Remediation status labels.
285#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
286pub enum RemediationStatus {
287    NotStarted,
288    InProgress,
289    Blocked,
290    Remediated,
291    Accepted,
292    Deferred,
293}
294
295label_enum!(RemediationStatus {
296    NotStarted => "not-started",
297    InProgress => "in-progress",
298    Blocked => "blocked",
299    Remediated => "remediated",
300    Accepted => "accepted",
301    Deferred => "deferred",
302});
303
304/// Lightweight finding reference categories.
305#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
306pub enum FindingReferenceKind {
307    Cve,
308    Cwe,
309    Cvss,
310    Owasp,
311    Url,
312    Other,
313}
314
315label_enum!(FindingReferenceKind {
316    Cve => "cve",
317    Cwe => "cwe",
318    Cvss => "cvss",
319    Owasp => "owasp",
320    Url => "url",
321    Other => "other",
322});
323
324#[cfg(test)]
325mod tests {
326    use super::{
327        FindingConfidence, FindingKind, FindingReference, FindingSeverity, FindingStatus,
328        SecurityFinding, SecurityFindingId,
329    };
330
331    #[test]
332    fn validates_finding_id() {
333        let id = SecurityFindingId::new("F-1").expect("finding id");
334
335        assert_eq!(id.as_str(), "F-1");
336        assert!(SecurityFindingId::new(" ").is_err());
337    }
338
339    #[test]
340    fn parses_and_displays_labels() {
341        assert_eq!(
342            "secret".parse::<FindingKind>().expect("kind"),
343            FindingKind::Secret
344        );
345        assert_eq!(FindingStatus::FalsePositive.to_string(), "false-positive");
346    }
347
348    #[test]
349    fn finding_record_tracks_reference_metadata() {
350        let finding = SecurityFinding::new(
351            SecurityFindingId::new("F-1").expect("finding id"),
352            FindingKind::Vulnerability,
353            FindingSeverity::High,
354        )
355        .with_status(FindingStatus::Confirmed)
356        .with_confidence(FindingConfidence::Confirmed)
357        .with_reference(FindingReference::new("CVE-2024-12345").expect("reference"));
358
359        assert_eq!(finding.kind(), FindingKind::Vulnerability);
360        assert_eq!(finding.status(), FindingStatus::Confirmed);
361        assert_eq!(finding.references()[0].as_str(), "CVE-2024-12345");
362    }
363}