Skip to main content

secfinding/
reportable.rs

1//! The `Reportable` trait — implement this on YOUR finding type to get
2//! free SARIF/JSON/Markdown output via `secreport`.
3//!
4//! You do NOT need to use `secfinding::Finding`. Any struct that implements
5//! `Reportable` works with the entire reporting pipeline.
6//!
7//! # Example
8//!
9//! ```rust
10//! use secfinding::{Reportable, Severity};
11//!
12//! struct MyFinding {
13//!     title: String,
14//!     sev: u8, // your own severity system
15//! }
16//!
17//! impl Reportable for MyFinding {
18//!     fn scanner(&self) -> &str { "my-tool" }
19//!     fn target(&self) -> &str { "target" }
20//!     fn severity(&self) -> Severity {
21//!         if self.sev > 8 { Severity::Critical } else { Severity::Medium }
22//!     }
23//!     fn title(&self) -> &str { &self.title }
24//!     fn detail(&self) -> &str { "" }
25//!     fn cwe_ids(&self) -> &[String] { &[] }
26//!     fn cve_ids(&self) -> &[String] { &[] }
27//!     fn tags(&self) -> &[String] { &[] }
28//! }
29//! ```
30
31use crate::Severity;
32
33/// Trait for any finding-like type that can be rendered into reports.
34///
35/// Implement this on your domain-specific finding type. The `secreport`
36/// crate accepts `&[impl Reportable]` for all output formats.
37///
38/// Only `scanner`, `target`, `severity`, and `title` are required.
39/// Everything else has sensible defaults.
40pub trait Reportable {
41    /// Which tool produced this finding.
42    fn scanner(&self) -> &str;
43    /// What was scanned (URL, file path, package name, etc.).
44    fn target(&self) -> &str;
45    /// How severe is this finding.
46    fn severity(&self) -> Severity;
47    /// Short human-readable title.
48    fn title(&self) -> &str;
49    /// Detailed description.
50    #[allow(clippy::unnecessary_literal_bound)]
51    fn detail(&self) -> &str {
52        ""
53    }
54    /// CWE identifiers (e.g. `["CWE-89"]`).
55    fn cwe_ids(&self) -> &[String];
56    /// CVE identifiers.
57    fn cve_ids(&self) -> &[String];
58    /// Free-form tags.
59    fn tags(&self) -> &[String];
60    /// Confidence score 0.0-1.0 (None = not applicable).
61    fn confidence(&self) -> Option<f64> {
62        None
63    }
64    /// SARIF rule ID (defaults to "scanner/title-slug").
65    fn rule_id(&self) -> String {
66        format!(
67            "{}/{}",
68            self.scanner(),
69            self.title().to_lowercase().replace(' ', "-")
70        )
71    }
72    /// SARIF severity level string.
73    fn sarif_level(&self) -> &str {
74        self.severity().sarif_level()
75    }
76    /// Exploit hint / `PoC` command.
77    fn exploit_hint(&self) -> Option<&str> {
78        None
79    }
80
81    /// Evidence attached to the finding.
82    fn evidence(&self) -> &[crate::Evidence] {
83        &[]
84    }
85}
86
87/// Blanket: secfinding's own `Finding` implements `Reportable`.
88impl Reportable for crate::Finding {
89    fn scanner(&self) -> &str {
90        &self.scanner
91    }
92    fn target(&self) -> &str {
93        &self.target
94    }
95    fn severity(&self) -> Severity {
96        self.severity
97    }
98    fn title(&self) -> &str {
99        &self.title
100    }
101    fn detail(&self) -> &str {
102        &self.detail
103    }
104    fn cwe_ids(&self) -> &[String] {
105        &[]
106    }
107    fn cve_ids(&self) -> &[String] {
108        &self.cve_ids
109    }
110    fn tags(&self) -> &[String] {
111        &self.tags
112    }
113    fn confidence(&self) -> Option<f64> {
114        self.confidence
115    }
116    fn exploit_hint(&self) -> Option<&str> {
117        self.exploit_hint.as_deref()
118    }
119    fn evidence(&self) -> &[crate::Evidence] {
120        &self.evidence
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use crate::{Finding, Severity};
128
129    #[test]
130    fn finding_implements_reportable() {
131        let f = Finding::new("scanner", "target", Severity::High, "Title", "Detail").unwrap();
132        assert_eq!(Reportable::scanner(&f), "scanner");
133        assert_eq!(Reportable::target(&f), "target");
134        assert_eq!(Reportable::severity(&f), Severity::High);
135        assert_eq!(Reportable::title(&f), "Title");
136        assert_eq!(Reportable::detail(&f), "Detail");
137    }
138
139    #[test]
140    fn custom_type_implements_reportable() {
141        struct CustomFinding {
142            name: String,
143        }
144
145        impl Reportable for CustomFinding {
146            fn scanner(&self) -> &str {
147                "custom"
148            }
149            fn target(&self) -> &str {
150                "custom-target"
151            }
152            fn severity(&self) -> Severity {
153                Severity::Critical
154            }
155            fn title(&self) -> &str {
156                &self.name
157            }
158            fn cwe_ids(&self) -> &[String] {
159                &[]
160            }
161            fn cve_ids(&self) -> &[String] {
162                &[]
163            }
164            fn tags(&self) -> &[String] {
165                &[]
166            }
167        }
168
169        let f = CustomFinding { name: "XSS".into() };
170        assert_eq!(f.scanner(), "custom");
171        assert_eq!(f.severity(), Severity::Critical);
172        assert_eq!(f.detail(), ""); // default
173        assert!(f.tags().is_empty()); // default
174        assert!(f.rule_id().contains("xss"));
175    }
176
177    #[test]
178    fn reportable_defaults_are_sensible() {
179        struct Minimal;
180        impl Reportable for Minimal {
181            fn scanner(&self) -> &str {
182                "s"
183            }
184            fn target(&self) -> &str {
185                "t"
186            }
187            fn severity(&self) -> Severity {
188                Severity::Info
189            }
190            fn title(&self) -> &str {
191                "minimal"
192            }
193            fn cwe_ids(&self) -> &[String] {
194                &[]
195            }
196            fn cve_ids(&self) -> &[String] {
197                &[]
198            }
199            fn tags(&self) -> &[String] {
200                &[]
201            }
202        }
203
204        let m = Minimal;
205        assert_eq!(m.detail(), "");
206        assert!(m.cwe_ids().is_empty());
207        assert!(m.cve_ids().is_empty());
208        assert!(m.tags().is_empty());
209        assert_eq!(m.confidence(), None);
210        assert_eq!(m.exploit_hint(), None);
211        assert_eq!(m.rule_id(), "s/minimal");
212    }
213
214    #[test]
215    fn reportable_custom_sarif_level() {
216        struct CustomSev;
217        impl Reportable for CustomSev {
218            fn scanner(&self) -> &str {
219                "s"
220            }
221            fn target(&self) -> &str {
222                "t"
223            }
224            fn severity(&self) -> Severity {
225                Severity::Critical
226            }
227            fn title(&self) -> &str {
228                "t"
229            }
230            fn cwe_ids(&self) -> &[String] {
231                &[]
232            }
233            fn cve_ids(&self) -> &[String] {
234                &[]
235            }
236            fn tags(&self) -> &[String] {
237                &[]
238            }
239        }
240        let f = CustomSev;
241        assert_eq!(f.sarif_level(), "error");
242    }
243
244    #[test]
245    fn reportable_custom_rule_id() {
246        struct CustomRuleId;
247        impl Reportable for CustomRuleId {
248            fn scanner(&self) -> &str {
249                "scanner"
250            }
251            fn target(&self) -> &str {
252                "target"
253            }
254            fn severity(&self) -> Severity {
255                Severity::Info
256            }
257            fn title(&self) -> &str {
258                "MY custom TITLE!"
259            }
260            fn rule_id(&self) -> String {
261                "CUSTOM-RULE-ID".to_string()
262            }
263            fn cwe_ids(&self) -> &[String] {
264                &[]
265            }
266            fn cve_ids(&self) -> &[String] {
267                &[]
268            }
269            fn tags(&self) -> &[String] {
270                &[]
271            }
272        }
273        let f = CustomRuleId;
274        assert_eq!(f.rule_id(), "CUSTOM-RULE-ID");
275    }
276
277    #[test]
278    fn reportable_default_rule_id_formatting() {
279        struct Spaces;
280        impl Reportable for Spaces {
281            fn scanner(&self) -> &str {
282                "scan"
283            }
284            fn target(&self) -> &str {
285                "target"
286            }
287            fn severity(&self) -> Severity {
288                Severity::Info
289            }
290            fn title(&self) -> &str {
291                "Some spaces here"
292            }
293            fn cwe_ids(&self) -> &[String] {
294                &[]
295            }
296            fn cve_ids(&self) -> &[String] {
297                &[]
298            }
299            fn tags(&self) -> &[String] {
300                &[]
301            }
302        }
303        let f = Spaces;
304        assert_eq!(f.rule_id(), "scan/some-spaces-here");
305    }
306}