audit_filter/
lib.rs

1extern crate failure;
2
3#[macro_use]
4extern crate serde_derive;
5
6extern crate serde;
7extern crate serde_json;
8
9extern crate wasm_bindgen;
10
11use std::cmp::Ordering;
12use std::collections::HashMap;
13use std::fs::File;
14use std::io;
15use std::result::Result;
16
17use failure::Error;
18use failure::ResultExt;
19use wasm_bindgen::prelude::*;
20
21pub const STDIN_STR: &str = "-";
22const NO_ADVISORIES_FOUND: &str = "No advisories found after filtering.";
23
24pub type AdvisoryID = u32;
25pub type AdvisoryURL = String;
26
27#[derive(Serialize, Deserialize, Debug)]
28pub struct NPMAudit {
29    pub advisories: HashMap<AdvisoryID, Advisory>,
30}
31
32#[derive(Serialize, Deserialize, Debug, Eq)]
33pub struct Advisory {
34    pub findings: Vec<AdvisoryFinding>,
35    pub id: AdvisoryID,
36    pub title: String,
37    pub module_name: String,
38    pub overview: String,
39    pub recommendation: String,
40    pub severity: String,
41    pub url: AdvisoryURL,
42}
43
44impl Ord for Advisory {
45    fn cmp(&self, other: &Advisory) -> Ordering {
46        self.url.cmp(&other.url)
47    }
48}
49
50impl PartialOrd for Advisory {
51    fn partial_cmp(&self, other: &Advisory) -> Option<Ordering> {
52        Some(self.cmp(other))
53    }
54}
55
56impl PartialEq for Advisory {
57    fn eq(&self, other: &Advisory) -> bool {
58        self.url == other.url
59    }
60}
61
62#[derive(Serialize, Deserialize, Debug, Eq)]
63pub struct AdvisoryFinding {
64    pub version: String,
65    pub paths: Vec<String>,
66
67    // older npm audit output includes these fields
68    pub dev: Option<bool>,
69    pub optional: Option<bool>,
70    pub bundled: Option<bool>,
71}
72
73impl PartialEq for AdvisoryFinding {
74    fn eq(&self, other: &AdvisoryFinding) -> bool {
75        self.version == other.version && self.paths == other.paths
76    }
77}
78
79#[derive(Serialize, Deserialize, Debug)]
80pub struct NSPConfig {
81    pub exceptions: Vec<AdvisoryURL>,
82}
83
84pub fn parse_audit(path: &str) -> Result<NPMAudit, Error> {
85    let audit: NPMAudit;
86
87    if path == STDIN_STR {
88        audit = serde_json::from_reader(io::stdin())
89            .with_context(|e| format!("Error parsing audit JSON from stdin: {}", e))?
90    } else {
91        let fin = File::open(path)
92            .with_context(|e| format!("Error opening audit JSON {}: {}", path, e))?;
93        audit = serde_json::from_reader(fin)
94            .with_context(|e| format!("Error parsing audit JSON: {}", e))?
95    }
96    Ok(audit)
97}
98
99fn parse_audit_from_str(s: &str) -> Result<NPMAudit, Error> {
100    let audit: NPMAudit =
101        serde_json::from_str(s).with_context(|e| format!("Error parsing audit JSON: {}", e))?;
102
103    Ok(audit)
104}
105
106fn parse_nsp_config_from_str(s: &str) -> Result<NSPConfig, Error> {
107    let config: NSPConfig = serde_json::from_str(s)
108        .with_context(|e| format!("Error parsing nsp config JSON: {}", e))?;
109
110    Ok(config)
111}
112
113pub fn parse_nsp_config(path: &str) -> Result<NSPConfig, Error> {
114    let config: NSPConfig;
115
116    if path == STDIN_STR {
117        config = serde_json::from_reader(io::stdin())
118            .with_context(|e| format!("Error parsing nsp config JSON from stdin: {}", e))?
119    } else {
120        let fin = File::open(path)
121            .with_context(|e| format!("Error opening nsp config JSON {}: {}", path, e))?;
122        config = serde_json::from_reader(fin)
123            .with_context(|e| format!("Error parsing nsp config JSON: {}", e))?
124    }
125    Ok(config)
126}
127
128pub fn filter_advisories_by_url(
129    audit: NPMAudit,
130    nsp_config: &NSPConfig,
131) -> Result<Vec<Advisory>, Error> {
132    let mut unacked_advisories: Vec<Advisory> = vec![];
133
134    for (_, advisory) in audit.advisories {
135        if !nsp_config.exceptions.contains(&advisory.url) {
136            unacked_advisories.push(advisory)
137        }
138    }
139
140    unacked_advisories.sort_unstable_by_key(|a| a.id);
141    Ok(unacked_advisories)
142}
143
144#[wasm_bindgen]
145pub fn version() -> String {
146    let (maj, min, pat) = (
147        option_env!("CARGO_PKG_VERSION_MAJOR"),
148        option_env!("CARGO_PKG_VERSION_MINOR"),
149        option_env!("CARGO_PKG_VERSION_PATCH"),
150    );
151    match (maj, min, pat) {
152        (Some(maj), Some(min), Some(pat)) => format!("{}.{}.{}", maj, min, pat),
153        _ => "".to_owned(),
154    }
155}
156
157pub fn parse_files_and_filter_advisories_by_url(
158    audit_path: &str,
159    nsp_config_path: &str,
160) -> Result<Vec<Advisory>, Error> {
161    let nsp_config = parse_nsp_config(nsp_config_path)?;
162    let audit = parse_audit(audit_path)?;
163    let unacked_advisories = filter_advisories_by_url(audit, &nsp_config)?;
164    Ok(unacked_advisories)
165}
166
167pub fn parse_strs_and_filter_advisories_by_url(
168    audit_str: &str,
169    nsp_config_str: &str,
170) -> Result<Vec<Advisory>, Error> {
171    let nsp_config = parse_nsp_config_from_str(nsp_config_str)?;
172    let audit = parse_audit_from_str(audit_str)?;
173    let unacked_advisories = filter_advisories_by_url(audit, &nsp_config)?;
174    Ok(unacked_advisories)
175}
176
177pub fn get_advisory_urls(advisories: Vec<Advisory>) -> Vec<AdvisoryURL> {
178    advisories
179        .into_iter()
180        .map(|a| a.url)
181        .collect::<Vec<AdvisoryURL>>()
182}
183
184pub fn format_json_output(advisories: &[Advisory]) -> Result<String, Error> {
185    let formatted = serde_json::to_string_pretty(&advisories).with_context(|e| {
186        format!(
187            "{{\"error\": \"error formatting advisories as json: {}\"}}",
188            e
189        )
190    })?;
191    Ok(formatted)
192}
193
194#[wasm_bindgen]
195extern "C" {
196    #[wasm_bindgen(js_namespace = console)]
197    fn log(msg: &str);
198
199    #[wasm_bindgen(js_namespace = console)]
200    fn error(msg: &str);
201}
202
203// A macro to provide `println!(..)`-style syntax for `console.log` logging.
204macro_rules! log {
205    ($($t:tt)*) => (log(&format!($($t)*)))
206}
207macro_rules! err {
208    ($($t:tt)*) => (error(&format!($($t)*)))
209}
210
211#[wasm_bindgen]
212pub fn run_wasm(audit_str: &str, nsp_config_str: &str, output_json: bool) -> i32 {
213    match parse_strs_and_filter_advisories_by_url(audit_str, nsp_config_str) {
214        Ok(unacked_advisories) => {
215            if output_json {
216                log!("{}", format_json_output(&unacked_advisories).unwrap())
217            }
218            if unacked_advisories.is_empty() {
219                if !output_json {
220                    log!("{}", NO_ADVISORIES_FOUND);
221                }
222                return 0;
223            } else if !unacked_advisories.is_empty() {
224                if !output_json {
225                    err!(
226                        "Unfiltered advisories:\n  {}",
227                        get_advisory_urls(unacked_advisories).join("\n  ")
228                    );
229                }
230                return 1;
231            }
232            unimplemented!() // should never haappen
233        }
234        Err(err) => {
235            err!("{}", err);
236            2
237        }
238    }
239}
240
241pub fn run(audit_path: &str, nsp_config_path: &str, output_json: bool) -> i32 {
242    match parse_files_and_filter_advisories_by_url(audit_path, nsp_config_path) {
243        Ok(unacked_advisories) => {
244            if output_json {
245                println!("{}", format_json_output(&unacked_advisories).unwrap())
246            }
247            if unacked_advisories.is_empty() {
248                if !output_json {
249                    println!("{}", NO_ADVISORIES_FOUND);
250                }
251                return 0;
252            } else if !unacked_advisories.is_empty() {
253                if !output_json {
254                    eprintln!(
255                        "Unfiltered advisories:\n  {}",
256                        get_advisory_urls(unacked_advisories).join("\n  ")
257                    );
258                }
259                return 1;
260            }
261            unimplemented!() // should never haappen
262        }
263        Err(err) => {
264            eprintln!("{}", err);
265            2
266        }
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    // not a real advisory just a copy of 566 to test numeric sorting by ID
275    fn setup_test_adv_51600() -> Advisory {
276        Advisory {
277            findings: vec![AdvisoryFinding {
278                version: "2.16.3".to_string(),
279                paths: vec![
280                    "david>npm>npm-registry-client>request>hawk>boom>hoek".to_string(),
281                    "david>npm>npm-registry-client>request>hawk>hoek".to_string(),
282                ],
283                dev: Some(false),
284                optional: Some(false),
285                bundled: Some(false),
286            }],
287            id: 51600,
288            title: "Prototype Pollution".to_string(),
289            module_name: "hoek".to_string(),
290            url: "https://nodesecurity.io/advisories/51600".to_string(),
291            overview: "Versions of `hoek` prior to 4.2.1 and 5.0.3 are vulnerable to prototype pollution.\n\nThe `merge` function, and the `applyToDefaults` and
292 `applyToDefaultsWithShallow` functions which leverage `merge` behind the scenes, are vulnerable to a prototype pollution attack when provided an _unvalidated
293_ payload created from a JSON string containing the `__proto__` property.\n\nThis can be demonstrated like so:\n\n```javascript\nvar Hoek = require('hoek');\n
294var malicious_payload = '{\"__proto__\":{\"oops\":\"It works !\"}}';\n\nvar a = {};\nconsole.log(\"Before : \" + a.oops);\nHoek.merge({}, JSON.parse(malicious
295_payload));\nconsole.log(\"After : \" + a.oops);\n```\n\nThis type of attack can be used to overwrite existing properties causing a potential denial of servic
296e.".to_string(),
297            severity: "moderate".to_string(),
298            recommendation: "Update to version 4.2.1, 5.0.3 or later.".to_string(),
299        }
300    }
301
302    fn setup_test_adv_566() -> Advisory {
303        Advisory {
304            findings: vec![AdvisoryFinding {
305                version: "2.16.3".to_string(),
306                paths: vec![
307                    "david>npm>npm-registry-client>request>hawk>boom>hoek".to_string(),
308                    "david>npm>npm-registry-client>request>hawk>hoek".to_string(),
309                ],
310                dev: Some(false),
311                optional: Some(false),
312                bundled: Some(false),
313            }],
314            id: 566,
315            title: "Prototype Pollution".to_string(),
316            module_name: "hoek".to_string(),
317            url: "https://nodesecurity.io/advisories/566".to_string(),
318            overview: "Versions of `hoek` prior to 4.2.1 and 5.0.3 are vulnerable to prototype pollution.\n\nThe `merge` function, and the `applyToDefaults` and
319 `applyToDefaultsWithShallow` functions which leverage `merge` behind the scenes, are vulnerable to a prototype pollution attack when provided an _unvalidated
320_ payload created from a JSON string containing the `__proto__` property.\n\nThis can be demonstrated like so:\n\n```javascript\nvar Hoek = require('hoek');\n
321var malicious_payload = '{\"__proto__\":{\"oops\":\"It works !\"}}';\n\nvar a = {};\nconsole.log(\"Before : \" + a.oops);\nHoek.merge({}, JSON.parse(malicious
322_payload));\nconsole.log(\"After : \" + a.oops);\n```\n\nThis type of attack can be used to overwrite existing properties causing a potential denial of servic
323e.".to_string(),
324            severity: "moderate".to_string(),
325            recommendation: "Update to version 4.2.1, 5.0.3 or later.".to_string(),
326        }
327    }
328
329    fn setup_test_adv_577() -> Advisory {
330        Advisory {
331            findings: vec![AdvisoryFinding {
332                version: "4.12.0".to_string(),
333                paths: vec!["jpm>firefox-profile>lodash".to_string()],
334                dev: Some(false),
335                optional: Some(false),
336                bundled: Some(false),
337            }],
338            id: 577,
339            title: "Prototype Pollution".to_string(),
340            module_name: "lodash".to_string(),
341            url: "https://nodesecurity.io/advisories/577".to_string(),
342            overview: "Versions of `lodash` before 4.17.5 are vulnerable to prototype pollution. \n\nThe vulnerable functions are 'defaultsDeep', 'merge', and 'me
343rgeWith' which allow a malicious user to modify the prototype of `Object` via `__proto__` causing the addition or modification of an existing property that wi
344ll exist on all objects.\n\n".to_string(),
345            severity: "low".to_string(),
346            recommendation: "Update to version 4.17.5 or later.".to_string(),
347        }
348    }
349
350    fn setup_test_audit() -> NPMAudit {
351        let mut advisories = HashMap::new();
352        advisories.insert(566, setup_test_adv_566());
353        advisories.insert(577, setup_test_adv_577());
354
355        NPMAudit { advisories }
356    }
357
358    #[test]
359    fn it_should_treat_advisories_with_the_same_url_as_equal() {
360        assert_eq!(setup_test_adv_566(), setup_test_adv_566())
361    }
362
363    #[test]
364    fn it_should_treat_advisories_with_different_urls_as_not_equal() {
365        assert_ne!(setup_test_adv_566(), setup_test_adv_577())
366    }
367
368    #[test]
369    fn it_should_filter_none_for_empty_nsp_config() {
370        let audit = setup_test_audit();
371        let empty_nsp_config = &NSPConfig { exceptions: vec![] };
372
373        let empty_filtered_result = filter_advisories_by_url(audit, empty_nsp_config);
374        assert!(empty_filtered_result.is_ok());
375        let empty_filtered = get_advisory_urls(empty_filtered_result.unwrap());
376        assert_eq!(
377            vec![
378                "https://nodesecurity.io/advisories/566".to_string(),
379                "https://nodesecurity.io/advisories/577".to_string(),
380            ],
381            empty_filtered
382        );
383    }
384
385    #[test]
386    fn it_should_filter_an_advisory() {
387        let audit = setup_test_audit();
388        let nsp_config = &NSPConfig {
389            exceptions: vec![
390                "https://nodesecurity.io/advisories/577".to_string(),
391                "https://nodesecurity.io/advisories/566".to_string(),
392            ],
393        };
394
395        let filtered_result = filter_advisories_by_url(audit, nsp_config);
396        assert!(filtered_result.is_ok());
397        let filtered = filtered_result.unwrap();
398        assert!(filtered.is_empty());
399    }
400
401    #[test]
402    fn it_should_filter_an_advisory_into_an_numerically_sorted_list() {
403        let mut audit = setup_test_audit();
404        audit.advisories.insert(5660, setup_test_adv_51600());
405
406        let nsp_config = &NSPConfig { exceptions: vec![] };
407
408        let filtered_result = filter_advisories_by_url(audit, nsp_config);
409        assert!(filtered_result.is_ok());
410        let filtered = get_advisory_urls(filtered_result.unwrap());
411        assert_eq!(
412            vec![
413                "https://nodesecurity.io/advisories/566".to_string(),
414                "https://nodesecurity.io/advisories/577".to_string(),
415                "https://nodesecurity.io/advisories/51600".to_string(),
416            ],
417            filtered
418        );
419    }
420}