use serde::{Deserialize, Serialize as _};
use std::collections::BTreeMap;
use std::process::Command;
use std::str::from_utf8;
use tracing::{debug, warn};
#[derive(Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase", untagged)]
pub enum NpmAuditData {
Version1(NpmAuditDataV1),
Version2(NpmAuditDataV2),
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NpmAuditDataV1 {
pub run_id: Option<String>,
pub actions: Vec<Action>,
pub advisories: BTreeMap<String, Advisory>,
pub muted: Option<Vec<String>>,
pub metadata: MetadataV1,
}
pub fn deserialize_module_path<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
Ok(s.split('>').map(|s| s.to_string()).collect())
}
pub fn serialize_module_path<S>(xs: &[String], serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let s = xs.join(">");
s.serialize(serializer)
}
pub fn deserialize_module_path_vec<'de, D>(deserializer: D) -> Result<Vec<Vec<String>>, D::Error>
where
D: serde::Deserializer<'de>,
{
let xs = <Vec<String>>::deserialize(deserializer)?;
Ok(xs
.into_iter()
.map(|x| x.split('>').map(|s| s.to_string()).collect())
.collect())
}
pub fn serialize_module_path_vec<S>(xxs: &[Vec<String>], serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let v: Vec<String> = xxs.iter().map(|xs| xs.join(">")).collect();
v.serialize(serializer)
}
pub fn deserialize_rfc3339<'de, D>(deserializer: D) -> Result<time::OffsetDateTime, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
time::OffsetDateTime::parse(&s, &time::format_description::well_known::Rfc3339)
.map_err(serde::de::Error::custom)
}
pub fn serialize_rfc3339<S>(t: &time::OffsetDateTime, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let s = t
.format(&time::format_description::well_known::Rfc3339)
.map_err(serde::ser::Error::custom)?;
s.serialize(serializer)
}
pub fn deserialize_optional_rfc3339<'de, D>(
deserializer: D,
) -> Result<Option<time::OffsetDateTime>, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = <Option<String> as Deserialize<'de>>::deserialize(deserializer)?;
if let Some(s) = s {
Ok(Some(
time::OffsetDateTime::parse(&s, &time::format_description::well_known::Rfc3339)
.map_err(serde::de::Error::custom)?,
))
} else {
Ok(None)
}
}
pub fn serialize_optional_rfc3339<S>(
t: &Option<time::OffsetDateTime>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
if let Some(t) = t {
let s = t
.format(&time::format_description::well_known::Rfc3339)
.map_err(serde::ser::Error::custom)?;
s.serialize(serializer)
} else {
let n: Option<String> = None;
n.serialize(serializer)
}
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Advisory {
pub id: u64,
pub title: String,
pub findings: Vec<Finding>,
pub vulnerable_versions: Option<String>,
pub module_name: Option<String>,
pub severity: Severity,
pub github_advisory_id: Option<String>,
pub cves: Option<Vec<String>>,
pub access: String,
pub patched_versions: Option<String>,
pub recommendation: String,
pub cwe: Option<Vec<String>>,
pub found_by: Option<String>,
pub reported_by: Option<String>,
#[serde(
serialize_with = "serialize_rfc3339",
deserialize_with = "deserialize_rfc3339"
)]
pub created: time::OffsetDateTime,
#[serde(
serialize_with = "serialize_optional_rfc3339",
deserialize_with = "deserialize_optional_rfc3339"
)]
pub updated: Option<time::OffsetDateTime>,
#[serde(
serialize_with = "serialize_optional_rfc3339",
deserialize_with = "deserialize_optional_rfc3339"
)]
pub deleted: Option<time::OffsetDateTime>,
pub references: Option<String>,
pub npm_advisory_id: Option<String>,
pub overview: String,
pub url: String,
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Finding {
version: String,
#[serde(
serialize_with = "serialize_module_path_vec",
deserialize_with = "deserialize_module_path_vec"
)]
paths: Vec<Vec<String>>,
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NpmAuditDataV2 {
pub audit_report_version: Option<u32>,
pub vulnerabilities: BTreeMap<String, VulnerablePackage>,
pub metadata: MetadataV2,
}
#[derive(Debug, Eq, PartialEq, Ord, PartialOrd, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase", tag = "action")]
pub enum Action {
#[serde(rename_all = "camelCase")]
Install {
resolves: Vec<Resolves>,
module: String,
depth: Option<u32>,
target: String,
is_major: bool,
},
#[serde(rename_all = "camelCase")]
Update {
resolves: Vec<Resolves>,
module: String,
depth: Option<u32>,
target: String,
},
#[serde(rename_all = "camelCase")]
Review {
resolves: Vec<Resolves>,
module: String,
depth: Option<u32>,
},
}
#[derive(Debug, Eq, PartialEq, Ord, PartialOrd, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Resolves {
pub id: u64,
#[serde(
serialize_with = "serialize_module_path",
deserialize_with = "deserialize_module_path"
)]
pub path: Vec<String>,
pub dev: bool,
pub optional: bool,
pub bundled: bool,
}
#[derive(Debug, Eq, PartialEq, Ord, PartialOrd, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Severity {
None,
Info,
Low,
Moderate,
High,
Critical,
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VulnerablePackage {
pub name: String,
pub severity: Severity,
pub is_direct: bool,
pub via: Vec<Vulnerability>,
pub effects: Vec<String>,
pub range: String,
pub nodes: Vec<String>,
pub fix_available: Fix,
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase", untagged)]
pub enum Vulnerability {
NameOnly(String),
Full {
source: u64,
name: String,
dependency: String,
title: String,
url: String,
severity: Severity,
range: String,
cwe: Option<Vec<String>>,
cvss: Option<Cvss>,
},
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Cvss {
pub score: Option<f64>,
pub vector_string: Option<String>,
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
#[serde(untagged)]
pub enum Fix {
BoolOnly(bool),
#[serde(rename_all = "camelCase")]
Full {
name: String,
version: String,
is_sem_ver_major: bool,
},
#[serde(rename_all = "camelCase")]
Simple {
is_sem_ver_major: bool,
},
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MetadataV1 {
pub vulnerabilities: VulnerabilityCountsV1,
pub dependencies: u32,
pub dev_dependencies: u32,
pub optional_dependencies: u32,
pub total_dependencies: u32,
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MetadataV2 {
pub vulnerabilities: VulnerabilityCountsV2,
pub dependencies: DependencyCounts,
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct VulnerabilityCountsV1 {
pub info: u32,
pub low: u32,
pub moderate: u32,
pub high: u32,
pub critical: u32,
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct VulnerabilityCountsV2 {
pub total: u32,
pub info: u32,
pub low: u32,
pub moderate: u32,
pub high: u32,
pub critical: u32,
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DependencyCounts {
pub total: u32,
pub prod: u32,
pub dev: u32,
pub optional: u32,
pub peer: u32,
pub peer_optional: u32,
}
#[derive(Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum IndicatedUpdateRequirement {
UpToDate,
UpdateRequired,
}
impl std::fmt::Display for IndicatedUpdateRequirement {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::UpToDate => {
write!(f, "up-to-date")
}
Self::UpdateRequired => {
write!(f, "update-required")
}
}
}
}
pub fn audit() -> Result<(IndicatedUpdateRequirement, NpmAuditData), crate::Error> {
let mut version_cmd = Command::new("npm");
version_cmd.args(["--version"]);
let version_output = version_cmd.output()?;
let version = from_utf8(&version_output.stdout)?.trim();
debug!("Got version string {} from npm --version", version);
let report_format = match versions::Versioning::new(version) {
Some(version) => {
debug!("Got version {} from npm --version", version);
#[expect(clippy::unwrap_used, reason = "parsing a literal should not fail")]
let audit_report_change = versions::Versioning::new("7.0.0").unwrap();
if version < audit_report_change {
debug!(
"Dealing with npm before version {}, using report format 1",
audit_report_change
);
1
} else {
debug!(
"Dealing with npm version {} or above, using report format 2",
audit_report_change
);
2
}
}
None => {
debug!("Could not parse npm version, defaulting to report format 2");
2
}
};
debug!("Using report format {}", report_format);
let mut cmd = Command::new("npm");
cmd.args(["audit", "--json"]);
let output = cmd.output()?;
if !output.status.success() {
warn!(
"npm audit did not return with a successful exit code: {}",
output.status
);
debug!("stdout:\n{}", from_utf8(&output.stdout)?);
if !output.stderr.is_empty() {
warn!("stderr:\n{}", from_utf8(&output.stderr)?);
}
}
let update_requirement = if output.status.success() {
IndicatedUpdateRequirement::UpToDate
} else {
IndicatedUpdateRequirement::UpdateRequired
};
let json_str = from_utf8(&output.stdout)?;
let jd = &mut serde_json::Deserializer::from_str(json_str);
#[expect(
clippy::panic,
reason = "This can only happen with new npm major versions previously unsupported in this crate"
)]
let data: NpmAuditData = match report_format {
1 => NpmAuditData::Version1(serde_path_to_error::deserialize::<_, NpmAuditDataV1>(jd)?),
2 => NpmAuditData::Version2(serde_path_to_error::deserialize::<_, NpmAuditDataV2>(jd)?),
_ => {
panic!("Unknown report version")
}
};
Ok((update_requirement, data))
}
#[cfg(test)]
mod test {
use super::*;
use crate::Error;
use pretty_assertions::assert_matches;
use serde_json::json;
use tracing_test::traced_test;
#[traced_test]
#[test]
fn test_run_npm_audit() -> Result<(), Error> {
audit()?;
Ok(())
}
#[test]
fn test_fix() -> Result<(), Error> {
let json = json!([
true,
{"isSemVerMajor": true},
{"isSemVerMajor": true, "name": "foo", "version": "1.0"}
]);
let fixes: Vec<Fix> = serde_json::from_value(json)?;
assert_matches!(
fixes.as_slice(),
[Fix::BoolOnly(true), Fix::Simple { .. }, Fix::Full { .. }]
);
Ok(())
}
}