1use std::collections::BTreeMap;
2use std::fmt;
3
4use crate::advisory::Advisory;
5
6#[derive(Debug)]
8pub enum ScanResult {
9 InsecureSource(InsecureSource),
10 UnpatchedGem(Box<UnpatchedGem>),
11 VulnerableRuby(Box<VulnerableRuby>),
12}
13
14#[derive(Debug, Clone)]
16pub struct InsecureSource {
17 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#[derive(Debug)]
29pub struct UnpatchedGem {
30 pub name: String,
32 pub version: String,
34 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#[derive(Debug)]
46pub struct VulnerableRuby {
47 pub engine: String,
49 pub version: String,
51 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#[derive(Debug)]
67pub struct Remediation {
68 pub name: String,
70 pub version: String,
72 pub advisories: Vec<Advisory>,
74}
75
76#[derive(Debug)]
78pub struct Report {
79 pub insecure_sources: Vec<InsecureSource>,
80 pub unpatched_gems: Vec<UnpatchedGem>,
81 pub vulnerable_rubies: Vec<VulnerableRuby>,
82 pub version_parse_errors: usize,
84 pub advisory_load_errors: usize,
86}
87
88impl Report {
89 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 pub fn count(&self) -> usize {
98 self.insecure_sources.len() + self.unpatched_gems.len() + self.vulnerable_rubies.len()
99 }
100
101 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 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 #[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}