Skip to main content

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        /// CWE numbers
402        cwe: Option<Vec<String>>,
403        /// CVSS
404        cvss: Option<Cvss>,
405    },
406}
407
408/// The CVSS value for a full vulnerability
409#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
410#[serde(rename_all = "camelCase")]
411pub struct Cvss {
412    /// Score
413    pub score: Option<f64>,
414    /// Vector string
415    pub vector_string: Option<String>,
416}
417
418/// a single fix
419#[derive(Debug, serde::Serialize, serde::Deserialize)]
420#[serde(untagged)]
421pub enum Fix {
422    /// some packages only indicate whether a fix is available or not
423    BoolOnly(bool),
424    /// others provide more details
425    #[serde(rename_all = "camelCase")]
426    Full {
427        /// the fixed package name
428        name: String,
429        /// the fixed package version
430        version: String,
431        /// is this a semver major update
432        is_sem_ver_major: bool,
433    },
434    /// semver major, without detail
435    #[serde(rename_all = "camelCase")]
436    Simple {
437        /// is this a semver major update
438        is_sem_ver_major: bool,
439    },
440}
441
442/// The vulnerability and dependency counts returned by npm-audit in report
443/// version 1
444#[derive(Debug, serde::Serialize, serde::Deserialize)]
445#[serde(rename_all = "camelCase")]
446pub struct MetadataV1 {
447    /// Vulnerability counts (without total)
448    pub vulnerabilities: VulnerabilityCountsV1,
449    /// Number of production dependencies
450    pub dependencies: u32,
451    /// Number of development dependencies
452    pub dev_dependencies: u32,
453    /// Number of optional dependencies
454    pub optional_dependencies: u32,
455    /// Total number of dependencies
456    pub total_dependencies: u32,
457}
458
459/// The vulnerability and dependency counts returned by npm-audit in report
460/// version 2
461#[derive(Debug, serde::Serialize, serde::Deserialize)]
462#[serde(rename_all = "camelCase")]
463pub struct MetadataV2 {
464    /// Vulnerability counts
465    pub vulnerabilities: VulnerabilityCountsV2,
466    /// Dependency counts
467    pub dependencies: DependencyCounts,
468}
469
470/// The vulnerability and dependency counts returned by npm-audit in report
471/// version 1
472#[derive(Debug, serde::Serialize, serde::Deserialize)]
473pub struct VulnerabilityCountsV1 {
474    /// Number of info level vulnerabilities
475    pub info: u32,
476    /// Number of low level vulnerabilities
477    pub low: u32,
478    /// Number of moderate level vulnerabilities
479    pub moderate: u32,
480    /// Number of high level vulnerabilities
481    pub high: u32,
482    /// Number of critical level vulnerabilities
483    pub critical: u32,
484}
485
486/// The vulnerability and dependency counts returned by npm-audit in report
487/// version 2
488#[derive(Debug, serde::Serialize, serde::Deserialize)]
489pub struct VulnerabilityCountsV2 {
490    /// Number of total vulnerabilities
491    pub total: u32,
492    /// Number of info level vulnerabilities
493    pub info: u32,
494    /// Number of low level vulnerabilities
495    pub low: u32,
496    /// Number of moderate level vulnerabilities
497    pub moderate: u32,
498    /// Number of high level vulnerabilities
499    pub high: u32,
500    /// Number of critical level vulnerabilities
501    pub critical: u32,
502}
503
504/// The vulnerability and dependency counts returned by npm-audit
505#[derive(Debug, serde::Serialize, serde::Deserialize)]
506#[serde(rename_all = "camelCase")]
507pub struct DependencyCounts {
508    /// Total number of dependencies
509    pub total: u32,
510    /// Number of production dependencies
511    pub prod: u32,
512    /// Number of development dependencies
513    pub dev: u32,
514    /// Number of optional dependencies
515    pub optional: u32,
516    /// Number of peer dependencies
517    ///
518    /// see <https://nodejs.org/es/blog/npm/peer-dependencies/>
519    pub peer: u32,
520    /// Number of optional peer dependencies
521    pub peer_optional: u32,
522}
523
524/// What the exit code indicated about required updates
525#[derive(Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
526pub enum IndicatedUpdateRequirement {
527    /// No update is required
528    UpToDate,
529    /// An update is required
530    UpdateRequired,
531}
532
533impl std::fmt::Display for IndicatedUpdateRequirement {
534    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
535        match self {
536            Self::UpToDate => {
537                write!(f, "up-to-date")
538            }
539            Self::UpdateRequired => {
540                write!(f, "update-required")
541            }
542        }
543    }
544}
545
546/// main entry point for the npm-audit call
547///
548/// # Errors
549///
550/// fails if the call to npm --version or to npm audit fails or if the result could not be parsed
551///
552/// # Panics
553///
554/// panics if a new, unimplemented report format is encountered
555pub fn audit() -> Result<(IndicatedUpdateRequirement, NpmAuditData), crate::Error> {
556    let mut version_cmd = Command::new("npm");
557
558    version_cmd.args(["--version"]);
559
560    let version_output = version_cmd.output()?;
561
562    let version = from_utf8(&version_output.stdout)?.trim();
563
564    debug!("Got version string {} from npm --version", version);
565
566    let report_format = match versions::Versioning::new(version) {
567        Some(version) => {
568            debug!("Got version {} from npm --version", version);
569            #[expect(clippy::unwrap_used, reason = "parsing a literal should not fail")]
570            let audit_report_change = versions::Versioning::new("7.0.0").unwrap();
571            if version < audit_report_change {
572                debug!(
573                    "Dealing with npm before version {}, using report format 1",
574                    audit_report_change
575                );
576                1
577            } else {
578                debug!(
579                    "Dealing with npm version {} or above, using report format 2",
580                    audit_report_change
581                );
582                2
583            }
584        }
585        None => {
586            // if --version already fails I do not have high hopes for
587            // parsing anything but we might as well assume we are dealing with a
588            // newer version since audit only appeared in npm version 6
589            debug!("Could not parse npm version, defaulting to report format 2");
590            2
591        }
592    };
593    debug!("Using report format {}", report_format);
594
595    let mut cmd = Command::new("npm");
596
597    cmd.args(["audit", "--json"]);
598
599    let output = cmd.output()?;
600
601    if !output.status.success() {
602        warn!(
603            "npm audit did not return with a successful exit code: {}",
604            output.status
605        );
606        debug!("stdout:\n{}", from_utf8(&output.stdout)?);
607        if !output.stderr.is_empty() {
608            warn!("stderr:\n{}", from_utf8(&output.stderr)?);
609        }
610    }
611
612    let update_requirement = if output.status.success() {
613        IndicatedUpdateRequirement::UpToDate
614    } else {
615        IndicatedUpdateRequirement::UpdateRequired
616    };
617
618    let json_str = from_utf8(&output.stdout)?;
619    let jd = &mut serde_json::Deserializer::from_str(json_str);
620    #[expect(
621        clippy::panic,
622        reason = "This can only happen with new npm major versions previously unsupported in this crate"
623    )]
624    let data: NpmAuditData = match report_format {
625        1 => NpmAuditData::Version1(serde_path_to_error::deserialize::<_, NpmAuditDataV1>(jd)?),
626        2 => NpmAuditData::Version2(serde_path_to_error::deserialize::<_, NpmAuditDataV2>(jd)?),
627        _ => {
628            panic!("Unknown report version")
629        }
630    };
631    Ok((update_requirement, data))
632}
633
634#[cfg(test)]
635mod test {
636    use super::*;
637    use crate::Error;
638    use pretty_assertions::assert_matches;
639    use serde_json::json;
640    use tracing_test::traced_test;
641
642    /// this test requires a package.json and package-lock.json in the main crate
643    /// directory (working dir of the tests)
644    #[traced_test]
645    #[test]
646    fn test_run_npm_audit() -> Result<(), Error> {
647        audit()?;
648        Ok(())
649    }
650
651    #[test]
652    fn test_fix() -> Result<(), Error> {
653        let json = json!([
654            true,
655            {"isSemVerMajor": true},
656            {"isSemVerMajor": true, "name": "foo", "version": "1.0"}
657        ]);
658
659        let fixes: Vec<Fix> = serde_json::from_value(json)?;
660
661        assert_matches!(
662            fixes.as_slice(),
663            [Fix::BoolOnly(true), Fix::Simple { .. }, Fix::Full { .. }]
664        );
665
666        Ok(())
667    }
668}