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