npm_parser/
audit.rs

1//! This parses the output of npm-audit
2//!
3//! [npm-audit](https://docs.npmjs.com/cli/v7/commands/npm-audit)
4
5use serde::{Deserialize, Serialize};
6use std::collections::BTreeMap;
7use std::process::Command;
8use std::str::from_utf8;
9use tracing::{debug, warn};
10
11/// This is used to return the data from audit()
12/// but not used for parsing since we can not easily tell
13/// serde how to decide which to use and the untagged union
14/// error messages are not great
15#[derive(Debug, serde::Serialize, serde::Deserialize)]
16#[serde(rename_all = "camelCase", untagged)]
17pub enum NpmAuditData {
18    /// audit report version 1 (npm 6 or below)
19    Version1(NpmAuditDataV1),
20    /// audit report version 2 (npm 8)
21    Version2(NpmAuditDataV2),
22}
23
24/// audit report version 1
25#[derive(Debug, serde::Serialize, serde::Deserialize)]
26#[serde(rename_all = "camelCase")]
27pub struct NpmAuditDataV1 {
28    /// UUID identitying the run of npm-audit
29    ///
30    /// only included in some versions of npm
31    pub run_id: Option<String>,
32    /// actions to perform to fix vulnerabilities
33    pub actions: Vec<Action>,
34    /// advisories by id
35    pub advisories: BTreeMap<String, Advisory>,
36    /// list of muted packages
37    ///
38    /// only included in some versions of npm audit
39    pub muted: Option<Vec<String>>,
40    /// vulnerability and dependency counts
41    pub metadata: MetadataV1,
42}
43
44/// helper to parse module paths
45pub fn deserialize_module_path<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
46where
47    D: serde::Deserializer<'de>,
48{
49    let s = String::deserialize(deserializer)?;
50
51    Ok(s.split('>').map(|s| s.to_string()).collect())
52}
53
54/// helper to serialize module paths
55pub fn serialize_module_path<S>(xs: &[String], serializer: S) -> Result<S::Ok, S::Error>
56where
57    S: serde::Serializer,
58{
59    let s = xs.join(">");
60
61    s.serialize(serializer)
62}
63
64/// helper to parse Vec of module paths
65pub fn deserialize_module_path_vec<'de, D>(deserializer: D) -> Result<Vec<Vec<String>>, D::Error>
66where
67    D: serde::Deserializer<'de>,
68{
69    let xs = <Vec<String>>::deserialize(deserializer)?;
70
71    Ok(xs
72        .into_iter()
73        .map(|x| x.split('>').map(|s| s.to_string()).collect())
74        .collect())
75}
76
77/// helper to serialize Vec of module paths
78pub fn serialize_module_path_vec<S>(xxs: &[Vec<String>], serializer: S) -> Result<S::Ok, S::Error>
79where
80    S: serde::Serializer,
81{
82    let v: Vec<String> = xxs.iter().map(|xs| xs.join(">")).collect();
83
84    v.serialize(serializer)
85}
86
87/// helper to parse created in the correct format
88/// (default time serde implementation seems to use a different format)
89pub fn deserialize_rfc3339<'de, D>(deserializer: D) -> Result<time::OffsetDateTime, D::Error>
90where
91    D: serde::Deserializer<'de>,
92{
93    let s = String::deserialize(deserializer)?;
94
95    time::OffsetDateTime::parse(&s, &time::format_description::well_known::Rfc3339)
96        .map_err(serde::de::Error::custom)
97}
98
99/// helper to serialize created in the correct format
100/// (default time serde implementation seems to use a different format)
101pub fn serialize_rfc3339<S>(t: &time::OffsetDateTime, serializer: S) -> Result<S::Ok, S::Error>
102where
103    S: serde::Serializer,
104{
105    let s = t
106        .format(&time::format_description::well_known::Rfc3339)
107        .map_err(serde::ser::Error::custom)?;
108
109    s.serialize(serializer)
110}
111
112/// helper to parse updated and deleted in the correct format
113/// (default time serde implementation seems to use a different format)
114pub fn deserialize_optional_rfc3339<'de, D>(
115    deserializer: D,
116) -> Result<Option<time::OffsetDateTime>, D::Error>
117where
118    D: serde::Deserializer<'de>,
119{
120    let s = <Option<String> as Deserialize<'de>>::deserialize(deserializer)?;
121
122    if let Some(s) = s {
123        Ok(Some(
124            time::OffsetDateTime::parse(&s, &time::format_description::well_known::Rfc3339)
125                .map_err(serde::de::Error::custom)?,
126        ))
127    } else {
128        Ok(None)
129    }
130}
131
132/// helper to serialize updated and deleted in the correct format
133/// (default time serde implementation seems to use a different format)
134pub fn serialize_optional_rfc3339<S>(
135    t: &Option<time::OffsetDateTime>,
136    serializer: S,
137) -> Result<S::Ok, S::Error>
138where
139    S: serde::Serializer,
140{
141    if let Some(t) = t {
142        let s = t
143            .format(&time::format_description::well_known::Rfc3339)
144            .map_err(serde::ser::Error::custom)?;
145
146        s.serialize(serializer)
147    } else {
148        let n: Option<String> = None;
149        n.serialize(serializer)
150    }
151}
152
153/// advisory in report version 1
154///
155/// there is a field metadata in the output here but since I could not find
156/// information on its structure it is not parsed (was always null for me)
157#[derive(Debug, serde::Serialize, serde::Deserialize)]
158#[serde(rename_all = "camelCase")]
159pub struct Advisory {
160    /// numeric id
161    pub id: u64,
162    /// human readable title
163    pub title: String,
164    /// where was the module affected by this advisory found in the dependency
165    /// tree
166    pub findings: Vec<Finding>,
167    /// which versions of the affected module are vulnerable
168    pub vulnerable_versions: Option<String>,
169    /// name of the affected node module
170    pub module_name: Option<String>,
171    /// how severe is the issue
172    pub severity: Severity,
173    /// GitHub advisory Id
174    pub github_advisory_id: Option<String>,
175    /// CVE numbers
176    pub cves: Option<Vec<String>>,
177    /// if this advisory is public
178    pub access: String,
179    /// which versions of the affected package are patched
180    pub patched_versions: Option<String>,
181    /// a human readable recommendation on how to fix this
182    pub recommendation: String,
183    /// a CWE (common weakness enumeration) identifier
184    pub cwe: Option<Vec<String>>,
185    /// who found this security issue
186    pub found_by: Option<String>,
187    /// who reported this security issue
188    pub reported_by: Option<String>,
189    /// when was this advisory created
190    #[serde(
191        serialize_with = "serialize_rfc3339",
192        deserialize_with = "deserialize_rfc3339"
193    )]
194    pub created: time::OffsetDateTime,
195    /// when was this advisory last updated
196    #[serde(
197        serialize_with = "serialize_optional_rfc3339",
198        deserialize_with = "deserialize_optional_rfc3339"
199    )]
200    pub updated: Option<time::OffsetDateTime>,
201    /// when was this deleted
202    #[serde(
203        serialize_with = "serialize_optional_rfc3339",
204        deserialize_with = "deserialize_optional_rfc3339"
205    )]
206    pub deleted: Option<time::OffsetDateTime>,
207    /// external references, all in one String, with newlines
208    pub references: Option<String>,
209    /// npm advisory id
210    pub npm_advisory_id: Option<String>,
211    /// human-readable description
212    pub overview: String,
213    /// URL to learn more
214    pub url: String,
215}
216
217/// findings in advisory in report version 1
218#[derive(Debug, serde::Serialize, serde::Deserialize)]
219#[serde(rename_all = "camelCase")]
220pub struct Finding {
221    /// dependency version found
222    version: String,
223    /// paths from current module to dependency
224    #[serde(
225        serialize_with = "serialize_module_path_vec",
226        deserialize_with = "deserialize_module_path_vec"
227    )]
228    paths: Vec<Vec<String>>,
229}
230
231/// audit report version 2
232#[derive(Debug, serde::Serialize, serde::Deserialize)]
233#[serde(rename_all = "camelCase")]
234pub struct NpmAuditDataV2 {
235    /// version of the audit report
236    ///
237    /// not all versions of npm produce this field
238    pub audit_report_version: Option<u32>,
239    /// Vulnerabilities found in dependencies
240    pub vulnerabilities: BTreeMap<String, VulnerablePackage>,
241    /// vulnerability and dependency counts
242    pub metadata: MetadataV2,
243}
244
245/// Actions to perform to fix security issues
246#[derive(Debug, Eq, PartialEq, Ord, PartialOrd, serde::Serialize, serde::Deserialize)]
247#[serde(rename_all = "camelCase", tag = "action")]
248pub enum Action {
249    /// install a new package
250    #[serde(rename_all = "camelCase")]
251    Install {
252        /// which advisories will this action resolve
253        resolves: Vec<Resolves>,
254        /// which package do we need to install
255        module: String,
256        /// how deep in our dependency tree is this package
257        depth: Option<u32>,
258        /// which version of the package do we need to install
259        target: String,
260        /// is this a major version
261        is_major: bool,
262    },
263    /// update a package
264    #[serde(rename_all = "camelCase")]
265    Update {
266        /// which advisories will this action resolve
267        resolves: Vec<Resolves>,
268        /// which package do we need to update
269        module: String,
270        /// how deep in our dependency tree is this package
271        depth: Option<u32>,
272        /// which version of the package do we need to update to
273        target: String,
274    },
275    /// review code using a package
276    #[serde(rename_all = "camelCase")]
277    Review {
278        /// which advisories will this action resolve
279        resolves: Vec<Resolves>,
280        /// which package do we need to review
281        module: String,
282        /// how deep in our dependency tree is this package
283        depth: Option<u32>,
284    },
285}
286
287/// Which advisories are resolved by an action
288#[derive(Debug, Eq, PartialEq, Ord, PartialOrd, serde::Serialize, serde::Deserialize)]
289#[serde(rename_all = "camelCase")]
290pub struct Resolves {
291    /// advisory id
292    pub id: u64,
293    /// path of depedencies from current module to affected module
294    #[serde(
295        serialize_with = "serialize_module_path",
296        deserialize_with = "deserialize_module_path"
297    )]
298    pub path: Vec<String>,
299    /// is this due to a dev dependency of the current package
300    pub dev: bool,
301    /// is this due to an optional dependency of the current package
302    pub optional: bool,
303    /// is this due to a bundled dependency of the current package
304    pub bundled: bool,
305}
306
307/// Severity of vulnerabilities
308#[derive(Debug, Eq, PartialEq, Ord, PartialOrd, serde::Serialize, serde::Deserialize)]
309#[serde(rename_all = "camelCase")]
310pub enum Severity {
311    /// no need to take action
312    None,
313    /// just informational
314    Info,
315    /// low severity
316    Low,
317    /// moderate severity
318    Moderate,
319    /// high severity
320    High,
321    /// critical severity
322    Critical,
323}
324
325/// The details for a single vulnerable package
326#[derive(Debug, serde::Serialize, serde::Deserialize)]
327#[serde(rename_all = "camelCase")]
328pub struct VulnerablePackage {
329    /// Package name
330    pub name: String,
331    /// The severity of the vulnerabilities
332    pub severity: Severity,
333    /// is this a direct dependency
334    pub is_direct: bool,
335    /// the vulnerabilities that make this a vulnerable package
336    pub via: Vec<Vulnerability>,
337    /// not sure what htis means
338    pub effects: Vec<String>,
339    /// affected version range
340    pub range: String,
341    /// not sure what this means
342    pub nodes: Vec<String>,
343    /// is there a fix available
344    pub fix_available: Fix,
345}
346
347/// a single vulnerability
348#[derive(Debug, serde::Serialize, serde::Deserialize)]
349#[serde(rename_all = "camelCase", untagged)]
350pub enum Vulnerability {
351    /// some vulnerabilities in the via list are only a name
352    NameOnly(String),
353    /// and some contain full details
354    Full {
355        /// numeric id, not sure what it means
356        source: u64,
357        /// the name of the vulnerability, or if none exists the vulnerable package
358        name: String,
359        /// the name of the dependency which is vulnerable
360        dependency: String,
361        /// the human readable title of the vulnerability
362        title: String,
363        /// an URL explaining the vulnerability
364        url: String,
365        /// the severity of this vulnerability
366        severity: Severity,
367        /// the affected version range
368        range: String,
369    },
370}
371
372/// a single fix
373#[derive(Debug, serde::Serialize, serde::Deserialize)]
374#[serde(untagged)]
375pub enum Fix {
376    /// some packages only indicate whether a fix is available or not
377    BoolOnly(bool),
378    /// others provide more details
379    #[serde(rename_all = "camelCase")]
380    Full {
381        /// the fixed package name
382        name: String,
383        /// the fixed package version
384        version: String,
385        /// is this a semver major update
386        is_sem_ver_major: bool,
387    },
388}
389
390/// The vulnerability and dependency counts returned by npm-audit in report
391/// version 1
392#[derive(Debug, serde::Serialize, serde::Deserialize)]
393#[serde(rename_all = "camelCase")]
394pub struct MetadataV1 {
395    /// Vulnerability counts (without total)
396    pub vulnerabilities: VulnerabilityCountsV1,
397    /// Number of production dependencies
398    pub dependencies: u32,
399    /// Number of development dependencies
400    pub dev_dependencies: u32,
401    /// Number of optional dependencies
402    pub optional_dependencies: u32,
403    /// Total number of dependencies
404    pub total_dependencies: u32,
405}
406
407/// The vulnerability and dependency counts returned by npm-audit in report
408/// version 2
409#[derive(Debug, serde::Serialize, serde::Deserialize)]
410#[serde(rename_all = "camelCase")]
411pub struct MetadataV2 {
412    /// Vulnerability counts
413    pub vulnerabilities: VulnerabilityCountsV2,
414    /// Dependency counts
415    pub dependencies: DependencyCounts,
416}
417
418/// The vulnerability and dependency counts returned by npm-audit in report
419/// version 1
420#[derive(Debug, serde::Serialize, serde::Deserialize)]
421pub struct VulnerabilityCountsV1 {
422    /// Number of info level vulnerabilities
423    pub info: u32,
424    /// Number of low level vulnerabilities
425    pub low: u32,
426    /// Number of moderate level vulnerabilities
427    pub moderate: u32,
428    /// Number of high level vulnerabilities
429    pub high: u32,
430    /// Number of critical level vulnerabilities
431    pub critical: u32,
432}
433
434/// The vulnerability and dependency counts returned by npm-audit in report
435/// version 2
436#[derive(Debug, serde::Serialize, serde::Deserialize)]
437pub struct VulnerabilityCountsV2 {
438    /// Number of total vulnerabilities
439    pub total: u32,
440    /// Number of info level vulnerabilities
441    pub info: u32,
442    /// Number of low level vulnerabilities
443    pub low: u32,
444    /// Number of moderate level vulnerabilities
445    pub moderate: u32,
446    /// Number of high level vulnerabilities
447    pub high: u32,
448    /// Number of critical level vulnerabilities
449    pub critical: u32,
450}
451
452/// The vulnerability and dependency counts returned by npm-audit
453#[derive(Debug, serde::Serialize, serde::Deserialize)]
454#[serde(rename_all = "camelCase")]
455pub struct DependencyCounts {
456    /// Total number of dependencies
457    pub total: u32,
458    /// Number of production dependencies
459    pub prod: u32,
460    /// Number of development dependencies
461    pub dev: u32,
462    /// Number of optional dependencies
463    pub optional: u32,
464    /// Number of peer dependencies
465    ///
466    /// see <https://nodejs.org/es/blog/npm/peer-dependencies/>
467    pub peer: u32,
468    /// Number of optional peer dependencies
469    pub peer_optional: u32,
470}
471
472/// What the exit code indicated about required updates
473#[derive(Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
474pub enum IndicatedUpdateRequirement {
475    /// No update is required
476    UpToDate,
477    /// An update is required
478    UpdateRequired,
479}
480
481impl std::fmt::Display for IndicatedUpdateRequirement {
482    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
483        match self {
484            IndicatedUpdateRequirement::UpToDate => {
485                write!(f, "up-to-date")
486            }
487            IndicatedUpdateRequirement::UpdateRequired => {
488                write!(f, "update-required")
489            }
490        }
491    }
492}
493
494/// main entry point for the npm-audit call
495pub fn audit() -> Result<(IndicatedUpdateRequirement, NpmAuditData), crate::Error> {
496    let mut version_cmd = Command::new("npm");
497
498    version_cmd.args(["--version"]);
499
500    let version_output = version_cmd.output()?;
501
502    let version = from_utf8(&version_output.stdout)?.trim();
503
504    debug!("Got version string {} from npm --version", version);
505
506    let report_format = match versions::Versioning::new(version) {
507        Some(version) => {
508            debug!("Got version {} from npm --version", version);
509            let audit_report_change = versions::Versioning::new("7.0.0").unwrap();
510            if version < audit_report_change {
511                debug!(
512                    "Dealing with npm before version {}, using report format 1",
513                    audit_report_change
514                );
515                1
516            } else {
517                debug!(
518                    "Dealing with npm version {} or above, using report format 2",
519                    audit_report_change
520                );
521                2
522            }
523        }
524        None => {
525            // if --version already fails I do not have high hopes for
526            // parsing anything but we might as well assume we are dealing with a
527            // newer version since audit only appeared in npm version 6
528            debug!("Could not parse npm version, defaulting to report format 2");
529            2
530        }
531    };
532    debug!("Using report format {}", report_format);
533
534    let mut cmd = Command::new("npm");
535
536    cmd.args(["audit", "--json"]);
537
538    let output = cmd.output()?;
539
540    if !output.status.success() {
541        warn!(
542            "npm audit did not return with a successful exit code: {}",
543            output.status
544        );
545        debug!("stdout:\n{}", from_utf8(&output.stdout)?);
546        if !output.stderr.is_empty() {
547            warn!("stderr:\n{}", from_utf8(&output.stderr)?);
548        }
549    }
550
551    let update_requirement = if output.status.success() {
552        IndicatedUpdateRequirement::UpToDate
553    } else {
554        IndicatedUpdateRequirement::UpdateRequired
555    };
556
557    let json_str = from_utf8(&output.stdout)?;
558    let jd = &mut serde_json::Deserializer::from_str(json_str);
559    let data: NpmAuditData = match report_format {
560        1 => NpmAuditData::Version1(serde_path_to_error::deserialize::<_, NpmAuditDataV1>(jd)?),
561        2 => NpmAuditData::Version2(serde_path_to_error::deserialize::<_, NpmAuditDataV2>(jd)?),
562        _ => {
563            panic!("Unknown report version")
564        }
565    };
566    Ok((update_requirement, data))
567}
568
569#[cfg(test)]
570mod test {
571    use super::*;
572    use crate::Error;
573    use tracing_test::traced_test;
574
575    /// this test requires a package.json and package-lock.json in the main crate
576    /// directory (working dir of the tests)
577    #[traced_test]
578    #[test]
579    fn test_run_npm_audit() -> Result<(), Error> {
580        audit()?;
581        Ok(())
582    }
583}