1use serde::{Deserialize, Serialize as _};
6use std::collections::BTreeMap;
7use std::process::Command;
8use std::str::from_utf8;
9use tracing::{debug, warn};
10
11#[derive(Debug, serde::Serialize, serde::Deserialize)]
16#[serde(rename_all = "camelCase", untagged)]
17pub enum NpmAuditData {
18 Version1(NpmAuditDataV1),
20 Version2(NpmAuditDataV2),
22}
23
24#[derive(Debug, serde::Serialize, serde::Deserialize)]
26#[serde(rename_all = "camelCase")]
27pub struct NpmAuditDataV1 {
28 pub run_id: Option<String>,
32 pub actions: Vec<Action>,
34 pub advisories: BTreeMap<String, Advisory>,
36 pub muted: Option<Vec<String>>,
40 pub metadata: MetadataV1,
42}
43
44pub 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
58pub 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
72pub 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
89pub 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
103pub 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
119pub 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
136pub 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
160pub 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#[derive(Debug, serde::Serialize, serde::Deserialize)]
190#[serde(rename_all = "camelCase")]
191pub struct Advisory {
192 pub id: u64,
194 pub title: String,
196 pub findings: Vec<Finding>,
199 pub vulnerable_versions: Option<String>,
201 pub module_name: Option<String>,
203 pub severity: Severity,
205 pub github_advisory_id: Option<String>,
207 pub cves: Option<Vec<String>>,
209 pub access: String,
211 pub patched_versions: Option<String>,
213 pub recommendation: String,
215 pub cwe: Option<Vec<String>>,
217 pub found_by: Option<String>,
219 pub reported_by: Option<String>,
221 #[serde(
223 serialize_with = "serialize_rfc3339",
224 deserialize_with = "deserialize_rfc3339"
225 )]
226 pub created: time::OffsetDateTime,
227 #[serde(
229 serialize_with = "serialize_optional_rfc3339",
230 deserialize_with = "deserialize_optional_rfc3339"
231 )]
232 pub updated: Option<time::OffsetDateTime>,
233 #[serde(
235 serialize_with = "serialize_optional_rfc3339",
236 deserialize_with = "deserialize_optional_rfc3339"
237 )]
238 pub deleted: Option<time::OffsetDateTime>,
239 pub references: Option<String>,
241 pub npm_advisory_id: Option<String>,
243 pub overview: String,
245 pub url: String,
247}
248
249#[derive(Debug, serde::Serialize, serde::Deserialize)]
251#[serde(rename_all = "camelCase")]
252pub struct Finding {
253 version: String,
255 #[serde(
257 serialize_with = "serialize_module_path_vec",
258 deserialize_with = "deserialize_module_path_vec"
259 )]
260 paths: Vec<Vec<String>>,
261}
262
263#[derive(Debug, serde::Serialize, serde::Deserialize)]
265#[serde(rename_all = "camelCase")]
266pub struct NpmAuditDataV2 {
267 pub audit_report_version: Option<u32>,
271 pub vulnerabilities: BTreeMap<String, VulnerablePackage>,
273 pub metadata: MetadataV2,
275}
276
277#[derive(Debug, Eq, PartialEq, Ord, PartialOrd, serde::Serialize, serde::Deserialize)]
279#[serde(rename_all = "camelCase", tag = "action")]
280pub enum Action {
281 #[serde(rename_all = "camelCase")]
283 Install {
284 resolves: Vec<Resolves>,
286 module: String,
288 depth: Option<u32>,
290 target: String,
292 is_major: bool,
294 },
295 #[serde(rename_all = "camelCase")]
297 Update {
298 resolves: Vec<Resolves>,
300 module: String,
302 depth: Option<u32>,
304 target: String,
306 },
307 #[serde(rename_all = "camelCase")]
309 Review {
310 resolves: Vec<Resolves>,
312 module: String,
314 depth: Option<u32>,
316 },
317}
318
319#[derive(Debug, Eq, PartialEq, Ord, PartialOrd, serde::Serialize, serde::Deserialize)]
321#[serde(rename_all = "camelCase")]
322pub struct Resolves {
323 pub id: u64,
325 #[serde(
327 serialize_with = "serialize_module_path",
328 deserialize_with = "deserialize_module_path"
329 )]
330 pub path: Vec<String>,
331 pub dev: bool,
333 pub optional: bool,
335 pub bundled: bool,
337}
338
339#[derive(Debug, Eq, PartialEq, Ord, PartialOrd, serde::Serialize, serde::Deserialize)]
341#[serde(rename_all = "camelCase")]
342pub enum Severity {
343 None,
345 Info,
347 Low,
349 Moderate,
351 High,
353 Critical,
355}
356
357#[derive(Debug, serde::Serialize, serde::Deserialize)]
359#[serde(rename_all = "camelCase")]
360pub struct VulnerablePackage {
361 pub name: String,
363 pub severity: Severity,
365 pub is_direct: bool,
367 pub via: Vec<Vulnerability>,
369 pub effects: Vec<String>,
371 pub range: String,
373 pub nodes: Vec<String>,
375 pub fix_available: Fix,
377}
378
379#[derive(Debug, serde::Serialize, serde::Deserialize)]
381#[serde(rename_all = "camelCase", untagged)]
382pub enum Vulnerability {
383 NameOnly(String),
385 Full {
387 source: u64,
389 name: String,
391 dependency: String,
393 title: String,
395 url: String,
397 severity: Severity,
399 range: String,
401 },
402}
403
404#[derive(Debug, serde::Serialize, serde::Deserialize)]
406#[serde(untagged)]
407pub enum Fix {
408 BoolOnly(bool),
410 #[serde(rename_all = "camelCase")]
412 Full {
413 name: String,
415 version: String,
417 is_sem_ver_major: bool,
419 },
420}
421
422#[derive(Debug, serde::Serialize, serde::Deserialize)]
425#[serde(rename_all = "camelCase")]
426pub struct MetadataV1 {
427 pub vulnerabilities: VulnerabilityCountsV1,
429 pub dependencies: u32,
431 pub dev_dependencies: u32,
433 pub optional_dependencies: u32,
435 pub total_dependencies: u32,
437}
438
439#[derive(Debug, serde::Serialize, serde::Deserialize)]
442#[serde(rename_all = "camelCase")]
443pub struct MetadataV2 {
444 pub vulnerabilities: VulnerabilityCountsV2,
446 pub dependencies: DependencyCounts,
448}
449
450#[derive(Debug, serde::Serialize, serde::Deserialize)]
453pub struct VulnerabilityCountsV1 {
454 pub info: u32,
456 pub low: u32,
458 pub moderate: u32,
460 pub high: u32,
462 pub critical: u32,
464}
465
466#[derive(Debug, serde::Serialize, serde::Deserialize)]
469pub struct VulnerabilityCountsV2 {
470 pub total: u32,
472 pub info: u32,
474 pub low: u32,
476 pub moderate: u32,
478 pub high: u32,
480 pub critical: u32,
482}
483
484#[derive(Debug, serde::Serialize, serde::Deserialize)]
486#[serde(rename_all = "camelCase")]
487pub struct DependencyCounts {
488 pub total: u32,
490 pub prod: u32,
492 pub dev: u32,
494 pub optional: u32,
496 pub peer: u32,
500 pub peer_optional: u32,
502}
503
504#[derive(Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
506pub enum IndicatedUpdateRequirement {
507 UpToDate,
509 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
526pub 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 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 #[traced_test]
623 #[test]
624 fn test_run_npm_audit() -> Result<(), Error> {
625 audit()?;
626 Ok(())
627 }
628}