1use std::collections::{BTreeMap, BTreeSet};
32
33use semver::Version;
34use serde_json::{json, Map, Value};
35
36use crate::package_json::spec::Range;
37use crate::sbom::Component;
38
39pub mod npm;
40pub mod osv;
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
45pub enum Severity {
46 Low,
47 Moderate,
48 High,
49 Critical,
50}
51
52impl Severity {
53 pub fn from_str_loose(s: &str) -> Option<Severity> {
58 match s.trim().to_ascii_lowercase().as_str() {
59 "low" => Some(Severity::Low),
60 "moderate" | "medium" => Some(Severity::Moderate),
61 "high" => Some(Severity::High),
62 "critical" => Some(Severity::Critical),
63 _ => None,
64 }
65 }
66
67 pub fn as_str(self) -> &'static str {
69 match self {
70 Severity::Low => "low",
71 Severity::Moderate => "moderate",
72 Severity::High => "high",
73 Severity::Critical => "critical",
74 }
75 }
76}
77
78pub fn severity_from_cvss(score: f64) -> Option<Severity> {
82 match score {
83 s if s <= 0.0 => None,
84 s if s < 4.0 => Some(Severity::Low),
85 s if s < 7.0 => Some(Severity::Moderate),
86 s if s < 9.0 => Some(Severity::High),
87 _ => Some(Severity::Critical),
88 }
89}
90
91#[derive(Debug, Clone)]
93pub struct Advisory {
94 pub source: &'static str,
96 pub id: String,
99 pub aliases: Vec<String>,
102 pub package: String,
104 pub vulnerable_range: String,
107 pub severity: Option<Severity>,
109 pub title: String,
111 pub url: Option<String>,
113 pub cwe: Vec<String>,
115 pub cvss_score: Option<f64>,
117 pub cvss_vector: Option<String>,
119 pub matched_version: String,
122}
123
124#[derive(Debug, Clone, Default, PartialEq, Eq)]
126pub struct Vulnerabilities {
127 pub info: u64,
128 pub low: u64,
129 pub moderate: u64,
130 pub high: u64,
131 pub critical: u64,
132 pub total: u64,
133}
134
135#[derive(Debug, Clone)]
137pub struct ComponentAdvisories {
138 pub component: Component,
139 pub advisories: Vec<Advisory>,
140}
141
142#[derive(Debug, Clone)]
147pub struct AuditReport {
148 pub findings: Vec<ComponentAdvisories>,
149 pub vulnerabilities: Vulnerabilities,
150 pub failed_sources: Vec<String>,
152 pub omissions: Vec<crate::registry::Omission>,
158}
159
160impl AuditReport {
161 pub fn max_severity(&self) -> Option<Severity> {
163 self.advisories().filter_map(|a| a.severity).max()
164 }
165
166 pub fn is_complete(&self) -> bool {
170 self.failed_sources.is_empty() && self.omissions.is_empty()
171 }
172
173 pub fn exceeds(&self, level: Severity) -> bool {
178 self.advisories()
179 .any(|a| a.severity.is_none_or(|s| s >= level))
180 }
181
182 fn advisories(&self) -> impl Iterator<Item = &Advisory> {
183 self.findings.iter().flat_map(|f| f.advisories.iter())
184 }
185}
186
187pub trait AdvisorySource {
195 fn name(&self) -> &'static str;
197 fn query(&self, components: &[Component]) -> crate::Result<Vec<Advisory>>;
200}
201
202pub fn run_audit(components: &[Component], sources: &[Box<dyn AdvisorySource>]) -> AuditReport {
208 run_audit_observed(components, sources, |_| {})
209}
210
211pub(crate) enum SourceEvent<'a> {
219 Begin {
220 #[cfg_attr(not(feature = "cli"), allow(dead_code))]
221 name: &'a str,
222 },
223 Done {
224 #[cfg_attr(not(feature = "cli"), allow(dead_code))]
225 advisories: usize,
226 },
227 Failed,
228}
229
230pub(crate) fn run_audit_observed(
235 components: &[Component],
236 sources: &[Box<dyn AdvisorySource>],
237 mut on_event: impl FnMut(SourceEvent<'_>),
238) -> AuditReport {
239 let mut all = Vec::new();
240 let mut failed_sources = Vec::new();
241 for source in sources {
242 on_event(SourceEvent::Begin {
243 name: source.name(),
244 });
245 match source.query(components) {
246 Ok(advisories) => {
247 on_event(SourceEvent::Done {
248 advisories: advisories.len(),
249 });
250 all.extend(advisories);
251 }
252 Err(e) => {
253 on_event(SourceEvent::Failed);
254 crate::warn::warn(&format!(
255 "{} advisory source failed: {e}; audit results may be incomplete",
256 source.name()
257 ));
258 failed_sources.push(source.name().to_string());
259 }
260 }
261 }
262 let deduped = dedup_advisories(all);
263 let mut report = build_report(&deduped, components);
264 report.failed_sources = failed_sources;
265 report
266}
267
268fn advisory_keys(adv: &Advisory) -> Vec<String> {
271 std::iter::once(&adv.id)
272 .chain(adv.aliases.iter())
273 .map(|s| s.trim().to_ascii_uppercase())
274 .filter(|k| k.starts_with("GHSA-") || k.starts_with("CVE-"))
275 .collect()
276}
277
278pub fn dedup_advisories(advisories: Vec<Advisory>) -> Vec<Advisory> {
283 let mut out: Vec<Advisory> = Vec::new();
284 for adv in advisories {
285 let keys = advisory_keys(&adv);
286 let existing = out.iter_mut().find(|e| {
287 e.package == adv.package && advisory_keys(e).iter().any(|k| keys.contains(k))
288 });
289 match existing {
290 Some(into) => merge_advisory(into, adv),
291 None => out.push(adv),
292 }
293 }
294 out
295}
296
297fn merge_advisory(into: &mut Advisory, from: Advisory) {
300 let add_alias = |into: &mut Advisory, alias: String| {
301 let k = alias.trim().to_ascii_uppercase();
302 if !into
303 .aliases
304 .iter()
305 .any(|x| x.trim().to_ascii_uppercase() == k)
306 && into.id != alias
307 {
308 into.aliases.push(alias);
309 }
310 };
311 let from_id_upper = from.id.trim().to_ascii_uppercase();
312 if from_id_upper.starts_with("GHSA-") || from_id_upper.starts_with("CVE-") {
313 add_alias(into, from.id);
314 }
315 for a in from.aliases {
316 add_alias(into, a);
317 }
318 if into.url.is_none() {
319 into.url = from.url;
320 }
321 if into.cwe.is_empty() {
322 into.cwe = from.cwe;
323 }
324 if into.cvss_score.is_none() {
325 into.cvss_score = from.cvss_score;
326 }
327 if into.cvss_vector.is_none() {
328 into.cvss_vector = from.cvss_vector;
329 }
330 if into.severity.is_none() {
331 into.severity = from.severity;
332 }
333}
334
335pub fn build_report(advisories: &[Advisory], components: &[Component]) -> AuditReport {
345 let mut by_name: BTreeMap<&str, Vec<&Advisory>> = BTreeMap::new();
346 for a in advisories {
347 by_name.entry(a.package.as_str()).or_default().push(a);
348 }
349
350 let mut seen: BTreeSet<(&str, &str)> = BTreeSet::new();
351 let mut findings = Vec::new();
352 let mut counts = Vulnerabilities::default();
353 for c in components {
354 if !seen.insert((c.name.as_str(), c.version.as_str())) {
355 continue; }
357 let Ok(version) = Version::parse(&c.version) else {
358 continue;
359 };
360 let Some(candidates) = by_name.get(c.name.as_str()) else {
361 continue;
362 };
363 let mut hits = Vec::new();
364 for adv in candidates {
365 if adv.vulnerable_range.trim().is_empty() {
368 continue;
369 }
370 let Ok(range) = Range::parse(&adv.vulnerable_range) else {
371 continue;
372 };
373 if range.matches(&version) {
374 let mut hit = (*adv).clone();
375 hit.matched_version = c.version.clone();
376 count_one(&mut counts, hit.severity);
377 hits.push(hit);
378 }
379 }
380 if !hits.is_empty() {
381 findings.push(ComponentAdvisories {
382 component: c.clone(),
383 advisories: hits,
384 });
385 }
386 }
387 AuditReport {
388 findings,
389 vulnerabilities: counts,
390 failed_sources: Vec::new(),
391 omissions: Vec::new(),
392 }
393}
394
395fn count_one(v: &mut Vulnerabilities, severity: Option<Severity>) {
396 match severity {
397 Some(Severity::Low) => v.low += 1,
398 Some(Severity::Moderate) => v.moderate += 1,
399 Some(Severity::High) => v.high += 1,
400 Some(Severity::Critical) => v.critical += 1,
401 None => v.info += 1,
402 }
403 v.total += 1;
404}
405
406fn incomplete_clause(report: &AuditReport) -> Option<String> {
409 let mut parts = Vec::new();
410 if !report.failed_sources.is_empty() {
411 parts.push(format!(
412 "{} source(s) failed ({})",
413 report.failed_sources.len(),
414 report.failed_sources.join(", "),
415 ));
416 }
417 if !report.omissions.is_empty() {
418 let n = report.omissions.len();
419 parts.push(format!(
420 "{n} dependenc{} not audited",
421 if n == 1 { "y" } else { "ies" },
422 ));
423 }
424 (!parts.is_empty()).then(|| parts.join(", "))
425}
426
427fn omissions_note(report: &AuditReport) -> Option<String> {
430 if report.omissions.is_empty() {
431 return None;
432 }
433 let n = report.omissions.len();
434 let items: Vec<String> = report.omissions.iter().map(|o| o.to_string()).collect();
435 Some(format!(
436 "note: {n} dependenc{} not audited: {}",
437 if n == 1 { "y" } else { "ies" },
438 items.join(", "),
439 ))
440}
441
442pub fn render_summary(report: &AuditReport) -> String {
446 use std::fmt::Write as _;
447 let v = &report.vulnerabilities;
448 let mut s = String::new();
449 if let Some(note) = omissions_note(report) {
450 let _ = writeln!(s, "{note}");
451 }
452 if v.total == 0 {
453 match incomplete_clause(report) {
454 None => s.push_str("found 0 vulnerabilities\n"),
455 Some(clause) => {
456 let _ = writeln!(s, "found 0 vulnerabilities — AUDIT INCOMPLETE: {clause}");
457 }
458 }
459 return s;
460 }
461 let mut info = String::new();
462 if v.info > 0 {
463 let _ = write!(info, ", {} info", v.info);
464 }
465 let _ = writeln!(
466 s,
467 "found {} vulnerabilit{} ({} critical, {} high, {} moderate, {} low{info}) in {} package(s)",
468 v.total,
469 if v.total == 1 { "y" } else { "ies" },
470 v.critical,
471 v.high,
472 v.moderate,
473 v.low,
474 report.findings.len(),
475 );
476 for f in &report.findings {
477 let _ = write!(s, "\n{}@{}\n", f.component.name, f.component.version);
478 for a in &f.advisories {
479 let severity = a.severity.map(Severity::as_str).unwrap_or("unknown");
480 let _ = writeln!(s, " {:<8} {} {}", severity.to_uppercase(), a.id, a.title);
481 let mut detail = format!("range {}", a.vulnerable_range);
482 if !a.cwe.is_empty() {
483 let _ = write!(detail, " · {}", a.cwe.join(", "));
484 }
485 if let Some(url) = &a.url {
486 let _ = write!(detail, " · {url}");
487 }
488 let _ = writeln!(s, " {detail}");
489 }
490 }
491 if let Some(clause) = incomplete_clause(report) {
492 let _ = writeln!(s, "\nAUDIT INCOMPLETE: {clause} — findings may be missing");
493 }
494 s
495}
496
497pub fn render_json(report: &AuditReport) -> String {
501 let mut by_name: BTreeMap<&str, Vec<&ComponentAdvisories>> = BTreeMap::new();
503 for f in &report.findings {
504 by_name
505 .entry(f.component.name.as_str())
506 .or_default()
507 .push(f);
508 }
509
510 let mut vulns = Map::new();
511 for (name, group) in &by_name {
512 let mut versions: Vec<&str> = group.iter().map(|f| f.component.version.as_str()).collect();
513 versions.sort_unstable();
514 versions.dedup();
515 let max = group
516 .iter()
517 .flat_map(|f| f.advisories.iter())
518 .filter_map(|a| a.severity)
519 .max();
520 let via: Vec<Value> = group
521 .iter()
522 .flat_map(|f| f.advisories.iter())
523 .map(advisory_json)
524 .collect();
525 vulns.insert(
526 (*name).to_string(),
527 json!({
528 "name": name,
529 "severity": max.map(Severity::as_str).unwrap_or("unknown"),
530 "versions": versions,
531 "via": via,
532 }),
533 );
534 }
535
536 let v = &report.vulnerabilities;
537 let doc = json!({
538 "vulnerabilities": Value::Object(vulns),
539 "metadata": {
540 "vulnerabilities": {
541 "info": v.info,
542 "low": v.low,
543 "moderate": v.moderate,
544 "high": v.high,
545 "critical": v.critical,
546 "total": v.total,
547 },
548 },
549 "incomplete": !report.is_complete(),
553 "failed_sources": report.failed_sources,
554 "omissions": report.omissions.iter().map(|o| json!({
555 "name": o.name,
556 "spec": o.spec,
557 "reason": o.reason,
558 })).collect::<Vec<Value>>(),
559 });
560 let mut s = serde_json::to_string_pretty(&doc).expect("serialize audit report");
561 s.push('\n');
562 s
563}
564
565fn advisory_json(a: &Advisory) -> Value {
566 let mut m = Map::new();
567 m.insert("source".into(), json!(a.source));
568 m.insert("id".into(), json!(a.id));
569 if !a.aliases.is_empty() {
570 m.insert("aliases".into(), json!(a.aliases));
571 }
572 m.insert("title".into(), json!(a.title));
573 m.insert("vulnerable_range".into(), json!(a.vulnerable_range));
574 if !a.matched_version.is_empty() {
575 m.insert("matched_version".into(), json!(a.matched_version));
576 }
577 if let Some(s) = a.severity {
578 m.insert("severity".into(), json!(s.as_str()));
579 }
580 if let Some(u) = &a.url {
581 m.insert("url".into(), json!(u));
582 }
583 if !a.cwe.is_empty() {
584 m.insert("cwe".into(), json!(a.cwe));
585 }
586 if let Some(score) = a.cvss_score {
587 m.insert("cvss_score".into(), json!(score));
588 }
589 if let Some(vector) = &a.cvss_vector {
590 m.insert("cvss_vector".into(), json!(vector));
591 }
592 Value::Object(m)
593}
594
595#[cfg(test)]
596mod tests {
597 use super::*;
598
599 fn advisory(
600 source: &'static str,
601 id: &str,
602 aliases: &[&str],
603 range: &str,
604 sev: Option<Severity>,
605 ) -> Advisory {
606 Advisory {
607 source,
608 id: id.into(),
609 aliases: aliases.iter().map(|s| s.to_string()).collect(),
610 package: "lodash".into(),
611 vulnerable_range: range.into(),
612 severity: sev,
613 title: format!("advisory {id}"),
614 url: None,
615 cwe: vec![],
616 cvss_score: None,
617 cvss_vector: None,
618 matched_version: String::new(),
619 }
620 }
621
622 fn component(name: &str, version: &str) -> Component {
623 Component {
624 name: name.into(),
625 version: version.into(),
626 purl: format!("pkg:npm/{name}@{version}"),
627 license: None,
628 resolved: None,
629 integrity: None,
630 }
631 }
632
633 struct OkSource(&'static str, usize);
635 impl AdvisorySource for OkSource {
636 fn name(&self) -> &'static str {
637 self.0
638 }
639 fn query(&self, _: &[Component]) -> crate::Result<Vec<Advisory>> {
640 Ok((0..self.1)
641 .map(|i| advisory("fake", &format!("GHSA-ok-{i}"), &[], "<9", None))
642 .collect())
643 }
644 }
645 struct BadSource;
646 impl AdvisorySource for BadSource {
647 fn name(&self) -> &'static str {
648 "bad"
649 }
650 fn query(&self, _: &[Component]) -> crate::Result<Vec<Advisory>> {
651 Err("simulated outage".into())
652 }
653 }
654
655 #[test]
656 fn run_audit_observed_emits_begin_done_failed_in_order() {
657 let sources: Vec<Box<dyn AdvisorySource>> =
658 vec![Box::new(OkSource("ok", 2)), Box::new(BadSource)];
659 let components = [component("lodash", "4.17.20")];
660
661 let mut events: Vec<String> = Vec::new();
662 let observed = run_audit_observed(&components, &sources, |e| {
663 events.push(match e {
664 SourceEvent::Begin { name } => format!("begin {name}"),
665 SourceEvent::Done { advisories } => format!("done {advisories}"),
666 SourceEvent::Failed => "failed".to_string(),
667 });
668 });
669
670 assert_eq!(events, ["begin ok", "done 2", "begin bad", "failed"]);
672 assert_eq!(observed.failed_sources, ["bad".to_string()]);
673
674 let plain = run_audit(&components, &sources);
676 assert_eq!(observed.vulnerabilities.total, plain.vulnerabilities.total);
677 assert_eq!(observed.failed_sources, plain.failed_sources);
678 }
679
680 #[test]
681 fn severity_buckets_from_cvss() {
682 assert_eq!(severity_from_cvss(0.0), None);
683 assert_eq!(severity_from_cvss(3.9), Some(Severity::Low));
684 assert_eq!(severity_from_cvss(6.9), Some(Severity::Moderate));
685 assert_eq!(severity_from_cvss(7.2), Some(Severity::High));
686 assert_eq!(severity_from_cvss(9.8), Some(Severity::Critical));
687 assert!(Severity::Low < Severity::Critical);
688 assert_eq!(
689 Severity::from_str_loose("CRITICAL"),
690 Some(Severity::Critical)
691 );
692 assert_eq!(Severity::from_str_loose("medium"), Some(Severity::Moderate));
693 assert_eq!(Severity::from_str_loose("info"), None);
694 }
695
696 #[test]
697 fn dedup_joins_on_shared_alias_and_enriches() {
698 let npm = advisory(
699 "npm",
700 "GHSA-aaaa-bbbb-cccc",
701 &["GHSA-aaaa-bbbb-cccc"],
702 "<2.0.0",
703 Some(Severity::High),
704 );
705 let mut osv = advisory(
706 "osv",
707 "GHSA-aaaa-bbbb-cccc",
708 &["GHSA-aaaa-bbbb-cccc", "CVE-2021-1"],
709 "<2.0.0",
710 Some(Severity::High),
711 );
712 osv.url = Some("https://osv.dev/x".into());
713 osv.cwe = vec!["CWE-79".into()];
714
715 let out = dedup_advisories(vec![npm, osv]);
716 assert_eq!(out.len(), 1, "the two sources collapse to one advisory");
717 assert_eq!(out[0].source, "npm", "first occurrence wins");
718 assert!(
719 out[0].aliases.iter().any(|a| a == "CVE-2021-1"),
720 "CVE alias merged in from osv"
721 );
722 assert_eq!(
723 out[0].url.as_deref(),
724 Some("https://osv.dev/x"),
725 "url enriched"
726 );
727 assert_eq!(out[0].cwe, vec!["CWE-79"], "cwe enriched");
728
729 let a = advisory("npm", "GHSA-1", &["CVE-1"], "<2.0.0", Some(Severity::Low));
731 let b = advisory("osv", "GHSA-2", &["CVE-2"], "<2.0.0", Some(Severity::Low));
732 assert_eq!(dedup_advisories(vec![a, b]).len(), 2);
733 }
734
735 #[test]
736 fn build_report_keeps_only_in_range_installs_and_counts() {
737 let advisories = vec![
738 advisory(
739 "npm",
740 "GHSA-old",
741 &["GHSA-old"],
742 "<4.17.21",
743 Some(Severity::High),
744 ),
745 advisory(
746 "npm",
747 "GHSA-wide",
748 &["GHSA-wide"],
749 "<=4.17.23",
750 Some(Severity::Moderate),
751 ),
752 ];
753 let r = build_report(&advisories, &[component("lodash", "4.17.20")]);
755 assert_eq!(r.vulnerabilities.total, 2);
756 assert_eq!(r.vulnerabilities.high, 1);
757 assert_eq!(r.vulnerabilities.moderate, 1);
758 assert_eq!(r.findings[0].advisories[0].matched_version, "4.17.20");
759
760 let r = build_report(&advisories, &[component("lodash", "4.17.22")]);
762 assert_eq!(r.vulnerabilities.total, 1);
763 assert!(r.findings[0].advisories.iter().all(|a| a.id == "GHSA-wide"));
764
765 assert_eq!(
767 build_report(&advisories, &[component("left-pad", "1.3.0")])
768 .vulnerabilities
769 .total,
770 0
771 );
772 assert_eq!(
773 build_report(&advisories, &[component("lodash", "not-semver")])
774 .vulnerabilities
775 .total,
776 0
777 );
778 }
779
780 #[test]
781 fn build_report_dedups_same_name_version_across_tree_paths() {
782 let advisories = vec![advisory(
783 "npm",
784 "GHSA-x",
785 &["GHSA-x"],
786 "<2.0.0",
787 Some(Severity::Low),
788 )];
789 let r = build_report(
791 &advisories,
792 &[component("lodash", "1.0.0"), component("lodash", "1.0.0")],
793 );
794 assert_eq!(r.vulnerabilities.total, 1);
795 assert_eq!(r.findings.len(), 1);
796 }
797
798 #[test]
799 fn exceeds_respects_threshold_and_renders() {
800 let advisories = vec![advisory(
801 "npm",
802 "GHSA-h",
803 &["GHSA-h"],
804 "<2.0.0",
805 Some(Severity::High),
806 )];
807 let report = build_report(&advisories, &[component("lodash", "1.0.0")]);
808 assert!(report.exceeds(Severity::Low));
809 assert!(report.exceeds(Severity::High));
810 assert!(!report.exceeds(Severity::Critical));
811 assert_eq!(report.max_severity(), Some(Severity::High));
812
813 assert!(render_summary(&report).contains("found 1 vulnerability"));
814 assert!(render_summary(&report).contains("lodash@1.0.0"));
815 let empty = AuditReport {
816 findings: vec![],
817 vulnerabilities: Vulnerabilities::default(),
818 failed_sources: vec![],
819 omissions: vec![],
820 };
821 assert_eq!(render_summary(&empty), "found 0 vulnerabilities\n");
822
823 let doc: Value = serde_json::from_str(&render_json(&report)).unwrap();
825 assert_eq!(doc["vulnerabilities"]["lodash"]["severity"], "high");
826 assert_eq!(doc["metadata"]["vulnerabilities"]["high"], 1);
827 assert_eq!(doc["metadata"]["vulnerabilities"]["total"], 1);
828 assert_eq!(doc["incomplete"], false);
829 assert_eq!(
830 doc["omissions"],
831 json!([]),
832 "always present, empty by default"
833 );
834 }
835
836 #[test]
837 fn omissions_qualify_a_clean_summary_and_mark_it_incomplete() {
838 let report = AuditReport {
839 findings: vec![],
840 vulnerabilities: Vulnerabilities::default(),
841 failed_sources: vec![],
842 omissions: vec![crate::registry::Omission::new(
843 "g",
844 "git+ssh://x/y",
845 "git dependency",
846 )],
847 };
848 assert!(!report.is_complete());
849 let s = render_summary(&report);
850 assert_eq!(
851 s,
852 "note: 1 dependency not audited: g (git+ssh://x/y: git dependency)\n\
853 found 0 vulnerabilities — AUDIT INCOMPLETE: 1 dependency not audited\n"
854 );
855 }
856
857 #[test]
858 fn omissions_and_failed_sources_compose_in_summary_and_json() {
859 let advisories = vec![advisory(
860 "npm",
861 "GHSA-h",
862 &["GHSA-h"],
863 "<2.0.0",
864 Some(Severity::High),
865 )];
866 let mut report = build_report(&advisories, &[component("lodash", "1.0.0")]);
867 report.failed_sources = vec!["osv".into()];
868 report.omissions = vec![
869 crate::registry::Omission::new("w", "file:../w", "local path"),
870 crate::registry::Omission::new("workspaces", "", "not traversed"),
871 ];
872
873 let s = render_summary(&report);
874 let note_pos = s.find("note: 2 dependencies not audited:").unwrap();
876 let found_pos = s.find("found 1 vulnerability").unwrap();
877 assert!(note_pos < found_pos, "{s}");
878 assert!(
879 s.contains(
880 "AUDIT INCOMPLETE: 1 source(s) failed (osv), 2 dependencies not audited \
881 — findings may be missing"
882 ),
883 "{s}"
884 );
885
886 let doc: Value = serde_json::from_str(&render_json(&report)).unwrap();
887 assert_eq!(doc["incomplete"], true);
888 assert_eq!(doc["omissions"][0]["name"], "w");
889 assert_eq!(doc["omissions"][0]["reason"], "local path");
890 assert_eq!(doc["omissions"][1]["spec"], "");
891 }
892}