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 cwe: Option<Vec<String>>,
403 cvss: Option<Cvss>,
405 },
406}
407
408#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
410#[serde(rename_all = "camelCase")]
411pub struct Cvss {
412 pub score: Option<f64>,
414 pub vector_string: Option<String>,
416}
417
418#[derive(Debug, serde::Serialize, serde::Deserialize)]
420#[serde(untagged)]
421pub enum Fix {
422 BoolOnly(bool),
424 #[serde(rename_all = "camelCase")]
426 Simple {
427 is_sem_ver_major: bool,
429 },
430 #[serde(rename_all = "camelCase")]
432 Full {
433 name: String,
435 version: String,
437 is_sem_ver_major: bool,
439 },
440}
441
442#[derive(Debug, serde::Serialize, serde::Deserialize)]
445#[serde(rename_all = "camelCase")]
446pub struct MetadataV1 {
447 pub vulnerabilities: VulnerabilityCountsV1,
449 pub dependencies: u32,
451 pub dev_dependencies: u32,
453 pub optional_dependencies: u32,
455 pub total_dependencies: u32,
457}
458
459#[derive(Debug, serde::Serialize, serde::Deserialize)]
462#[serde(rename_all = "camelCase")]
463pub struct MetadataV2 {
464 pub vulnerabilities: VulnerabilityCountsV2,
466 pub dependencies: DependencyCounts,
468}
469
470#[derive(Debug, serde::Serialize, serde::Deserialize)]
473pub struct VulnerabilityCountsV1 {
474 pub info: u32,
476 pub low: u32,
478 pub moderate: u32,
480 pub high: u32,
482 pub critical: u32,
484}
485
486#[derive(Debug, serde::Serialize, serde::Deserialize)]
489pub struct VulnerabilityCountsV2 {
490 pub total: u32,
492 pub info: u32,
494 pub low: u32,
496 pub moderate: u32,
498 pub high: u32,
500 pub critical: u32,
502}
503
504#[derive(Debug, serde::Serialize, serde::Deserialize)]
506#[serde(rename_all = "camelCase")]
507pub struct DependencyCounts {
508 pub total: u32,
510 pub prod: u32,
512 pub dev: u32,
514 pub optional: u32,
516 pub peer: u32,
520 pub peer_optional: u32,
522}
523
524#[derive(Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
526pub enum IndicatedUpdateRequirement {
527 UpToDate,
529 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
546pub 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 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 tracing_test::traced_test;
639
640 #[traced_test]
643 #[test]
644 fn test_run_npm_audit() -> Result<(), Error> {
645 audit()?;
646 Ok(())
647 }
648}