Skip to main content

gem_audit/scanner/
report.rs

1use std::collections::BTreeMap;
2use std::fmt;
3
4use crate::advisory::Advisory;
5
6/// A scan result: an insecure source, an unpatched gem, or a vulnerable Ruby version.
7#[derive(Debug)]
8pub enum ScanResult {
9    InsecureSource(InsecureSource),
10    UnpatchedGem(Box<UnpatchedGem>),
11    VulnerableRuby(Box<VulnerableRuby>),
12}
13
14/// An insecure gem source (`git://` or `http://`).
15#[derive(Debug, Clone)]
16pub struct InsecureSource {
17    /// The insecure URI string.
18    pub source: String,
19}
20
21impl fmt::Display for InsecureSource {
22    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
23        write!(f, "Insecure Source URI found: {}", self.source)
24    }
25}
26
27/// A gem with a known vulnerability.
28#[derive(Debug)]
29pub struct UnpatchedGem {
30    /// The gem name.
31    pub name: String,
32    /// The installed version.
33    pub version: String,
34    /// The advisory describing the vulnerability.
35    pub advisory: Advisory,
36}
37
38impl fmt::Display for UnpatchedGem {
39    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40        write!(f, "{} ({}): {}", self.name, self.version, self.advisory.id)
41    }
42}
43
44/// A Ruby interpreter version with a known vulnerability.
45#[derive(Debug)]
46pub struct VulnerableRuby {
47    /// The Ruby engine (e.g., "ruby", "jruby").
48    pub engine: String,
49    /// The installed version.
50    pub version: String,
51    /// The advisory describing the vulnerability.
52    pub advisory: Advisory,
53}
54
55impl fmt::Display for VulnerableRuby {
56    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57        write!(
58            f,
59            "{} ({}): {}",
60            self.engine, self.version, self.advisory.id
61        )
62    }
63}
64
65/// A grouped remediation suggestion for a single gem.
66#[derive(Debug)]
67pub struct Remediation {
68    /// The gem name.
69    pub name: String,
70    /// The currently installed version.
71    pub version: String,
72    /// All advisories affecting this gem (deduplicated by advisory ID).
73    pub advisories: Vec<Advisory>,
74}
75
76/// Aggregated scan report.
77#[derive(Debug)]
78pub struct Report {
79    pub insecure_sources: Vec<InsecureSource>,
80    pub unpatched_gems: Vec<UnpatchedGem>,
81    pub vulnerable_rubies: Vec<VulnerableRuby>,
82    /// Number of gem versions that failed to parse.
83    pub version_parse_errors: usize,
84    /// Number of advisory YAML files that failed to load.
85    pub advisory_load_errors: usize,
86}
87
88impl Report {
89    /// Returns true if any vulnerabilities were found.
90    pub fn vulnerable(&self) -> bool {
91        !self.insecure_sources.is_empty()
92            || !self.unpatched_gems.is_empty()
93            || !self.vulnerable_rubies.is_empty()
94    }
95
96    /// Total number of issues found.
97    pub fn count(&self) -> usize {
98        self.insecure_sources.len() + self.unpatched_gems.len() + self.vulnerable_rubies.len()
99    }
100
101    /// Group unpatched gems into remediation suggestions.
102    ///
103    /// Groups vulnerabilities by gem name, deduplicates advisories (by ID),
104    /// and collects the union of all patched_versions across advisories.
105    pub fn remediations(&self) -> Vec<Remediation> {
106        let mut by_name: BTreeMap<&str, (&str, Vec<&Advisory>)> = BTreeMap::new();
107
108        for gem in &self.unpatched_gems {
109            let entry = by_name
110                .entry(&gem.name)
111                .or_insert((&gem.version, Vec::new()));
112            // Deduplicate advisories by ID
113            if !entry.1.iter().any(|a| a.id == gem.advisory.id) {
114                entry.1.push(&gem.advisory);
115            }
116        }
117
118        by_name
119            .into_iter()
120            .map(|(name, (version, advisories))| Remediation {
121                name: name.to_string(),
122                version: version.to_string(),
123                advisories: advisories.into_iter().cloned().collect(),
124            })
125            .collect()
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use std::path::Path;
133
134    #[test]
135    fn report_vulnerable_when_issues_found() {
136        let report = Report {
137            insecure_sources: vec![InsecureSource {
138                source: "http://rubygems.org/".to_string(),
139            }],
140            unpatched_gems: vec![],
141            vulnerable_rubies: vec![],
142            version_parse_errors: 0,
143            advisory_load_errors: 0,
144        };
145        assert!(report.vulnerable());
146        assert_eq!(report.count(), 1);
147    }
148
149    #[test]
150    fn report_not_vulnerable_when_clean() {
151        let report = Report {
152            insecure_sources: vec![],
153            unpatched_gems: vec![],
154            vulnerable_rubies: vec![],
155            version_parse_errors: 0,
156            advisory_load_errors: 0,
157        };
158        assert!(!report.vulnerable());
159        assert_eq!(report.count(), 0);
160    }
161
162    #[test]
163    fn remediations_empty_for_clean_report() {
164        let report = Report {
165            insecure_sources: vec![],
166            unpatched_gems: vec![],
167            vulnerable_rubies: vec![],
168            version_parse_errors: 0,
169            advisory_load_errors: 0,
170        };
171        assert!(report.remediations().is_empty());
172    }
173
174    #[test]
175    fn remediations_groups_by_gem_name() {
176        let yaml1 =
177            "---\ngem: test\ncve: 2020-1111\ncvss_v3: 9.0\npatched_versions:\n  - \">= 1.0.0\"\n";
178        let yaml2 =
179            "---\ngem: test\ncve: 2020-2222\ncvss_v3: 7.0\npatched_versions:\n  - \">= 1.2.0\"\n";
180        let yaml3 =
181            "---\ngem: other\ncve: 2020-3333\ncvss_v3: 5.0\npatched_versions:\n  - \">= 2.0.0\"\n";
182        let adv1 = Advisory::from_yaml(yaml1, Path::new("CVE-2020-1111.yml")).unwrap();
183        let adv2 = Advisory::from_yaml(yaml2, Path::new("CVE-2020-2222.yml")).unwrap();
184        let adv3 = Advisory::from_yaml(yaml3, Path::new("CVE-2020-3333.yml")).unwrap();
185
186        let report = Report {
187            insecure_sources: vec![],
188            unpatched_gems: vec![
189                UnpatchedGem {
190                    name: "test".to_string(),
191                    version: "0.5.0".to_string(),
192                    advisory: adv1,
193                },
194                UnpatchedGem {
195                    name: "test".to_string(),
196                    version: "0.5.0".to_string(),
197                    advisory: adv2,
198                },
199                UnpatchedGem {
200                    name: "other".to_string(),
201                    version: "1.0.0".to_string(),
202                    advisory: adv3,
203                },
204            ],
205            vulnerable_rubies: vec![],
206            version_parse_errors: 0,
207            advisory_load_errors: 0,
208        };
209
210        let remediations = report.remediations();
211        assert_eq!(remediations.len(), 2);
212
213        assert_eq!(remediations[0].name, "other");
214        assert_eq!(remediations[0].version, "1.0.0");
215        assert_eq!(remediations[0].advisories.len(), 1);
216
217        assert_eq!(remediations[1].name, "test");
218        assert_eq!(remediations[1].version, "0.5.0");
219        assert_eq!(remediations[1].advisories.len(), 2);
220    }
221
222    #[test]
223    fn remediations_deduplicates_advisories() {
224        let yaml =
225            "---\ngem: test\ncve: 2020-1111\ncvss_v3: 9.0\npatched_versions:\n  - \">= 1.0.0\"\n";
226        let adv1 = Advisory::from_yaml(yaml, Path::new("CVE-2020-1111.yml")).unwrap();
227        let adv2 = Advisory::from_yaml(yaml, Path::new("CVE-2020-1111.yml")).unwrap();
228
229        let report = Report {
230            insecure_sources: vec![],
231            unpatched_gems: vec![
232                UnpatchedGem {
233                    name: "test".to_string(),
234                    version: "0.5.0".to_string(),
235                    advisory: adv1,
236                },
237                UnpatchedGem {
238                    name: "test".to_string(),
239                    version: "0.5.0".to_string(),
240                    advisory: adv2,
241                },
242            ],
243            vulnerable_rubies: vec![],
244            version_parse_errors: 0,
245            advisory_load_errors: 0,
246        };
247
248        let remediations = report.remediations();
249        assert_eq!(remediations.len(), 1);
250        assert_eq!(remediations[0].advisories.len(), 1);
251    }
252
253    // ========== Display Impls ==========
254
255    #[test]
256    fn insecure_source_display() {
257        let src = InsecureSource {
258            source: "http://rubygems.org/".to_string(),
259        };
260        assert_eq!(
261            src.to_string(),
262            "Insecure Source URI found: http://rubygems.org/"
263        );
264    }
265
266    #[test]
267    fn unpatched_gem_display() {
268        let yaml =
269            "---\ngem: test\ncve: 2020-1234\ncvss_v3: 9.0\npatched_versions:\n  - \">= 1.0\"\n";
270        let advisory = Advisory::from_yaml(yaml, Path::new("CVE-2020-1234.yml")).unwrap();
271        let gem = UnpatchedGem {
272            name: "test".to_string(),
273            version: "0.5.0".to_string(),
274            advisory,
275        };
276        assert_eq!(gem.to_string(), "test (0.5.0): CVE-2020-1234");
277    }
278
279    #[test]
280    fn vulnerable_ruby_display() {
281        let yaml = "---\nengine: ruby\ncve: 2021-31810\ncvss_v3: 5.9\npatched_versions:\n  - \">= 3.0.2\"\n";
282        let advisory = Advisory::from_yaml(yaml, Path::new("CVE-2021-31810.yml")).unwrap();
283        let ruby = VulnerableRuby {
284            engine: "ruby".to_string(),
285            version: "2.6.0".to_string(),
286            advisory,
287        };
288        assert_eq!(ruby.to_string(), "ruby (2.6.0): CVE-2021-31810");
289    }
290}