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 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 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 #[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 #[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#[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 #[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 #[must_use]
142 pub const fn id(&self) -> &SecurityFindingId {
143 &self.id
144 }
145
146 #[must_use]
148 pub const fn kind(&self) -> FindingKind {
149 self.kind
150 }
151
152 #[must_use]
154 pub const fn severity(&self) -> FindingSeverity {
155 self.severity
156 }
157
158 #[must_use]
160 pub const fn status(&self) -> FindingStatus {
161 self.status
162 }
163
164 #[must_use]
166 pub const fn confidence(&self) -> FindingConfidence {
167 self.confidence
168 }
169
170 #[must_use]
172 pub fn references(&self) -> &[FindingReference] {
173 &self.references
174 }
175
176 #[must_use]
178 pub fn with_reference(mut self, reference: FindingReference) -> Self {
179 self.references.push(reference);
180 self
181 }
182
183 #[must_use]
185 pub const fn with_status(mut self, status: FindingStatus) -> Self {
186 self.status = status;
187 self
188 }
189
190 #[must_use]
192 pub const fn with_confidence(mut self, confidence: FindingConfidence) -> Self {
193 self.confidence = confidence;
194 self
195 }
196}
197
198#[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#[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#[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#[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#[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#[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}