Skip to main content

socket_patch_core/vex/
schema.rs

1//! OpenVEX 0.2.0 schema types.
2//!
3//! Hand-rolled from the OpenVEX 0.2.0 spec
4//! (<https://github.com/openvex/spec/blob/main/OPENVEX-SPEC.md>) and
5//! cross-checked against the Go reference implementation
6//! (<https://github.com/openvex/go-vex/tree/main/pkg/vex>). The serde
7//! representation must match the spec verbatim; the `vexctl merge`
8//! step in our e2e suite is what catches drift.
9//!
10//! Field-level notes:
11//! * `@context` / `@id` use serde renames because JSON-LD requires the
12//!   literal `@`-prefixed keys.
13//! * Optional fields use `Option<T>` + `skip_serializing_if = "Option::is_none"`
14//!   so the emitted JSON omits them rather than emitting `null`. Matches
15//!   the Go implementation's `omitempty` behavior.
16//! * `version` is the OpenVEX document revision counter (integer,
17//!   starts at 1). NOT the schema version.
18//! * `Vec<Statement>` is always present (the spec allows it to be empty
19//!   in principle, but our generator errors out before that state).
20//! * `Product.identifiers` / `Product.hashes` (and same on
21//!   `Subcomponent`) use `BTreeMap` instead of `HashMap` for
22//!   deterministic key ordering — easier diffing across runs.
23
24use serde::{Deserialize, Serialize};
25use std::collections::BTreeMap;
26
27pub const OPENVEX_CONTEXT_V0_2_0: &str = "https://openvex.dev/ns/v0.2.0";
28
29/// Top-level OpenVEX document.
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
31pub struct Document {
32    #[serde(rename = "@context")]
33    pub context: String,
34    #[serde(rename = "@id")]
35    pub id: String,
36    pub author: String,
37    /// Optional role declaration for `author`. Free-form per spec.
38    #[serde(skip_serializing_if = "Option::is_none", default)]
39    pub role: Option<String>,
40    pub timestamp: String,
41    /// RFC 3339 timestamp of the most recent revision of this doc.
42    /// Optional; absent in newly-issued documents.
43    #[serde(skip_serializing_if = "Option::is_none", default)]
44    pub last_updated: Option<String>,
45    pub version: u32,
46    #[serde(skip_serializing_if = "Option::is_none", default)]
47    pub tooling: Option<String>,
48    pub statements: Vec<Statement>,
49}
50
51/// One VEX statement — the unit of "I am asserting that vulnerability X
52/// has status S relative to product P".
53#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
54pub struct Statement {
55    /// Optional per-statement identifier. When present, must be unique
56    /// within the document. Spec says it's used to track revisions.
57    #[serde(rename = "@id", skip_serializing_if = "Option::is_none", default)]
58    pub id: Option<String>,
59    pub vulnerability: Vulnerability,
60    pub timestamp: String,
61    /// RFC 3339 timestamp of the most recent revision of this statement.
62    #[serde(skip_serializing_if = "Option::is_none", default)]
63    pub last_updated: Option<String>,
64    pub products: Vec<Product>,
65    pub status: Status,
66    /// Optional supplier IRI overriding the document-level author for
67    /// this statement.
68    #[serde(skip_serializing_if = "Option::is_none", default)]
69    pub supplier: Option<String>,
70    /// Required when `status == not_affected` (per spec; we don't
71    /// enforce at the type level — see `vex::conformance_tests`).
72    #[serde(skip_serializing_if = "Option::is_none", default)]
73    pub justification: Option<Justification>,
74    /// Free-form explanation paired with `not_affected`.
75    #[serde(skip_serializing_if = "Option::is_none", default)]
76    pub impact_statement: Option<String>,
77    /// Canonical companion to `status == affected` (per spec).
78    /// We never emit `affected` today, but the field exists so the type
79    /// round-trips a richer doc through our parser.
80    #[serde(skip_serializing_if = "Option::is_none", default)]
81    pub action_statement: Option<String>,
82}
83
84/// Vulnerability identifier. `name` is the primary ID (we use the GHSA),
85/// `aliases` holds the CVE list.
86#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
87pub struct Vulnerability {
88    pub name: String,
89    #[serde(skip_serializing_if = "Vec::is_empty", default)]
90    pub aliases: Vec<String>,
91}
92
93/// A product the statement applies to. `@id` is a PURL or any URI; the
94/// subcomponent list pinpoints the vulnerable transitive dep.
95#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
96pub struct Product {
97    #[serde(rename = "@id")]
98    pub id: String,
99    /// Optional auxiliary identifiers (PURL, CPE 2.2, CPE 2.3, etc.).
100    /// Keys are the identifier type (e.g. `"purl"`, `"cpe23"`),
101    /// values are the literal identifier strings.
102    #[serde(skip_serializing_if = "Option::is_none", default)]
103    pub identifiers: Option<BTreeMap<String, String>>,
104    /// Optional content hashes that pin the product to specific bytes.
105    /// Keys are hash algorithms (e.g. `"sha256"`), values are hex.
106    #[serde(skip_serializing_if = "Option::is_none", default)]
107    pub hashes: Option<BTreeMap<String, String>>,
108    #[serde(skip_serializing_if = "Vec::is_empty", default)]
109    pub subcomponents: Vec<Subcomponent>,
110}
111
112/// A subcomponent of the product — i.e. the actual vulnerable dependency
113/// the patch covers.
114#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
115pub struct Subcomponent {
116    #[serde(rename = "@id")]
117    pub id: String,
118    #[serde(skip_serializing_if = "Option::is_none", default)]
119    pub identifiers: Option<BTreeMap<String, String>>,
120    #[serde(skip_serializing_if = "Option::is_none", default)]
121    pub hashes: Option<BTreeMap<String, String>>,
122}
123
124/// VEX status. Spec defines exactly these four values.
125#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
126#[serde(rename_all = "snake_case")]
127pub enum Status {
128    NotAffected,
129    Affected,
130    Fixed,
131    UnderInvestigation,
132}
133
134/// VEX `justification` enum — only required when `status = not_affected`.
135/// Spec lists five canonical values; we expose them all even though
136/// `socket-patch` only emits `InlineMitigationsAlreadyExist` today.
137#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
138#[serde(rename_all = "snake_case")]
139pub enum Justification {
140    ComponentNotPresent,
141    VulnerableCodeNotPresent,
142    VulnerableCodeNotInExecutePath,
143    VulnerableCodeCannotBeControlledByAdversary,
144    InlineMitigationsAlreadyExist,
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    // ── Status enum: every variant round-trips ─────────────────────
152
153    /// Spec strings for `Status`. The list IS the contract — keep it
154    /// matched against the OpenVEX 0.2.0 spec section "Statement
155    /// Properties → status".
156    const STATUS_LITERALS: &[(Status, &str)] = &[
157        (Status::NotAffected, "not_affected"),
158        (Status::Affected, "affected"),
159        (Status::Fixed, "fixed"),
160        (Status::UnderInvestigation, "under_investigation"),
161    ];
162
163    #[test]
164    fn every_status_variant_serializes_to_spec_literal() {
165        for (variant, literal) in STATUS_LITERALS {
166            let json = serde_json::to_string(variant).unwrap();
167            assert_eq!(json, format!("\"{literal}\""), "variant {variant:?}");
168        }
169    }
170
171    #[test]
172    fn every_status_variant_deserializes_from_spec_literal() {
173        for (variant, literal) in STATUS_LITERALS {
174            let parsed: Status =
175                serde_json::from_str(&format!("\"{literal}\"")).unwrap();
176            assert_eq!(parsed, *variant, "literal {literal:?}");
177        }
178    }
179
180    #[test]
181    fn status_rejects_unknown_literal() {
182        let r: Result<Status, _> = serde_json::from_str("\"pending\"");
183        assert!(r.is_err(), "unknown status literal must fail to parse");
184    }
185
186    // ── Justification enum: every variant round-trips ──────────────
187
188    const JUSTIFICATION_LITERALS: &[(Justification, &str)] = &[
189        (Justification::ComponentNotPresent, "component_not_present"),
190        (
191            Justification::VulnerableCodeNotPresent,
192            "vulnerable_code_not_present",
193        ),
194        (
195            Justification::VulnerableCodeNotInExecutePath,
196            "vulnerable_code_not_in_execute_path",
197        ),
198        (
199            Justification::VulnerableCodeCannotBeControlledByAdversary,
200            "vulnerable_code_cannot_be_controlled_by_adversary",
201        ),
202        (
203            Justification::InlineMitigationsAlreadyExist,
204            "inline_mitigations_already_exist",
205        ),
206    ];
207
208    #[test]
209    fn every_justification_variant_serializes_to_spec_literal() {
210        for (variant, literal) in JUSTIFICATION_LITERALS {
211            let json = serde_json::to_string(variant).unwrap();
212            assert_eq!(json, format!("\"{literal}\""), "variant {variant:?}");
213        }
214    }
215
216    #[test]
217    fn every_justification_variant_deserializes_from_spec_literal() {
218        for (variant, literal) in JUSTIFICATION_LITERALS {
219            let parsed: Justification =
220                serde_json::from_str(&format!("\"{literal}\"")).unwrap();
221            assert_eq!(parsed, *variant, "literal {literal:?}");
222        }
223    }
224
225    #[test]
226    fn justification_rejects_unknown_literal() {
227        let r: Result<Justification, _> =
228            serde_json::from_str("\"hand_waving\"");
229        assert!(r.is_err());
230    }
231
232    // ── Document field shape ──────────────────────────────────────
233
234    fn empty_doc() -> Document {
235        Document {
236            context: OPENVEX_CONTEXT_V0_2_0.to_string(),
237            id: "urn:uuid:1111".to_string(),
238            author: "Socket".to_string(),
239            role: None,
240            timestamp: "2024-01-01T00:00:00Z".to_string(),
241            last_updated: None,
242            version: 1,
243            tooling: None,
244            statements: Vec::new(),
245        }
246    }
247
248    #[test]
249    fn document_renames_context_and_id() {
250        let v = serde_json::to_value(empty_doc()).unwrap();
251        assert_eq!(v["@context"], OPENVEX_CONTEXT_V0_2_0);
252        assert_eq!(v["@id"], "urn:uuid:1111");
253        let obj = v.as_object().unwrap();
254        assert!(obj.get("context").is_none(), "raw `context` must not leak");
255        assert!(obj.get("id").is_none(), "raw `id` must not leak");
256    }
257
258    #[test]
259    fn document_omits_all_optional_fields_when_none() {
260        let v = serde_json::to_value(empty_doc()).unwrap();
261        let obj = v.as_object().unwrap();
262        for key in ["role", "last_updated", "tooling"] {
263            assert!(
264                !obj.contains_key(key),
265                "key {key:?} must be omitted when None"
266            );
267        }
268    }
269
270    #[test]
271    fn document_emits_optional_fields_when_some() {
272        let mut doc = empty_doc();
273        doc.role = Some("publisher".to_string());
274        doc.last_updated = Some("2024-02-01T00:00:00Z".to_string());
275        doc.tooling = Some("socket-patch 3.0.0".to_string());
276
277        let v = serde_json::to_value(&doc).unwrap();
278        assert_eq!(v["role"], "publisher");
279        assert_eq!(v["last_updated"], "2024-02-01T00:00:00Z");
280        assert_eq!(v["tooling"], "socket-patch 3.0.0");
281    }
282
283    #[test]
284    fn document_version_round_trips_arbitrary_u32() {
285        for v in [1u32, 2, 7, 42, u32::MAX] {
286            let mut doc = empty_doc();
287            doc.version = v;
288            let json = serde_json::to_string(&doc).unwrap();
289            let parsed: Document = serde_json::from_str(&json).unwrap();
290            assert_eq!(parsed.version, v);
291        }
292    }
293
294    #[test]
295    fn document_rejects_missing_required_fields() {
296        // Drop the `@context` key — required field, parser must error.
297        let bad = r#"{
298            "@id": "urn:uuid:1",
299            "author": "Socket",
300            "timestamp": "2024-01-01T00:00:00Z",
301            "version": 1,
302            "statements": []
303        }"#;
304        let r: Result<Document, _> = serde_json::from_str(bad);
305        assert!(r.is_err());
306    }
307
308    // ── Statement field shape ─────────────────────────────────────
309
310    fn minimal_statement() -> Statement {
311        Statement {
312            id: None,
313            vulnerability: Vulnerability {
314                name: "GHSA-xxxx".to_string(),
315                aliases: Vec::new(),
316            },
317            timestamp: "2024-01-01T00:00:00Z".to_string(),
318            last_updated: None,
319            products: vec![Product {
320                id: "pkg:npm/app@1.0.0".to_string(),
321                identifiers: None,
322                hashes: None,
323                subcomponents: Vec::new(),
324            }],
325            status: Status::NotAffected,
326            supplier: None,
327            justification: None,
328            impact_statement: None,
329            action_statement: None,
330        }
331    }
332
333    #[test]
334    fn statement_omits_all_optional_fields_when_none() {
335        let v = serde_json::to_value(minimal_statement()).unwrap();
336        let obj = v.as_object().unwrap();
337        for key in [
338            "@id",
339            "last_updated",
340            "supplier",
341            "justification",
342            "impact_statement",
343            "action_statement",
344        ] {
345            assert!(
346                !obj.contains_key(key),
347                "key {key:?} must be omitted when None"
348            );
349        }
350        // The `aliases` key on the inner vulnerability also omits-empty.
351        assert!(
352            v["vulnerability"]
353                .as_object()
354                .unwrap()
355                .get("aliases")
356                .is_none(),
357            "empty aliases must omit the key"
358        );
359    }
360
361    #[test]
362    fn statement_emits_id_under_at_prefix_and_other_optional_fields() {
363        let mut s = minimal_statement();
364        s.id = Some("urn:uuid:stmt-1".to_string());
365        s.last_updated = Some("2024-02-01T00:00:00Z".to_string());
366        s.supplier = Some("https://example.com/supplier".to_string());
367        s.justification = Some(Justification::InlineMitigationsAlreadyExist);
368        s.impact_statement = Some("Patched via Socket".to_string());
369        s.action_statement = Some("Apply socket-patch <uuid>".to_string());
370
371        let v = serde_json::to_value(&s).unwrap();
372        // `@id` not raw `id`.
373        assert_eq!(v["@id"], "urn:uuid:stmt-1");
374        assert!(v.as_object().unwrap().get("id").is_none());
375
376        assert_eq!(v["last_updated"], "2024-02-01T00:00:00Z");
377        assert_eq!(v["supplier"], "https://example.com/supplier");
378        assert_eq!(v["justification"], "inline_mitigations_already_exist");
379        assert_eq!(v["impact_statement"], "Patched via Socket");
380        assert_eq!(v["action_statement"], "Apply socket-patch <uuid>");
381    }
382
383    #[test]
384    fn statement_with_both_justification_and_impact_emits_both_keys() {
385        let mut s = minimal_statement();
386        s.justification = Some(Justification::ComponentNotPresent);
387        s.impact_statement = Some("Component is not bundled".to_string());
388        let v = serde_json::to_value(&s).unwrap();
389        assert_eq!(v["justification"], "component_not_present");
390        assert_eq!(v["impact_statement"], "Component is not bundled");
391    }
392
393    // ── Vulnerability shape ───────────────────────────────────────
394
395    #[test]
396    fn vulnerability_with_zero_aliases_omits_key() {
397        let v = serde_json::to_value(Vulnerability {
398            name: "GHSA-x".to_string(),
399            aliases: Vec::new(),
400        })
401        .unwrap();
402        assert!(v.as_object().unwrap().get("aliases").is_none());
403        assert_eq!(v["name"], "GHSA-x");
404    }
405
406    #[test]
407    fn vulnerability_with_one_alias() {
408        let v = serde_json::to_value(Vulnerability {
409            name: "GHSA-x".to_string(),
410            aliases: vec!["CVE-2024-1".to_string()],
411        })
412        .unwrap();
413        let arr = v["aliases"].as_array().unwrap();
414        assert_eq!(arr.len(), 1);
415        assert_eq!(arr[0], "CVE-2024-1");
416    }
417
418    #[test]
419    fn vulnerability_with_many_aliases_preserves_order() {
420        // Builder sorts aliases, but the type itself preserves input
421        // order — important so callers can rely on Vec semantics.
422        let aliases = vec![
423            "CVE-Z".to_string(),
424            "CVE-A".to_string(),
425            "CVE-M".to_string(),
426        ];
427        let v = serde_json::to_value(Vulnerability {
428            name: "GHSA-x".to_string(),
429            aliases: aliases.clone(),
430        })
431        .unwrap();
432        let arr = v["aliases"].as_array().unwrap();
433        assert_eq!(arr.len(), 3);
434        for (i, want) in aliases.iter().enumerate() {
435            assert_eq!(arr[i], *want);
436        }
437    }
438
439    // ── Product / Subcomponent shape ──────────────────────────────
440
441    #[test]
442    fn product_renames_id_and_omits_empty_subcomponents() {
443        let p = Product {
444            id: "pkg:npm/app@1.0.0".to_string(),
445            identifiers: None,
446            hashes: None,
447            subcomponents: Vec::new(),
448        };
449        let v = serde_json::to_value(&p).unwrap();
450        assert_eq!(v["@id"], "pkg:npm/app@1.0.0");
451        let obj = v.as_object().unwrap();
452        assert!(obj.get("subcomponents").is_none());
453        assert!(obj.get("identifiers").is_none());
454        assert!(obj.get("hashes").is_none());
455    }
456
457    #[test]
458    fn product_serializes_identifiers_and_hashes_when_set() {
459        let mut idents = BTreeMap::new();
460        idents.insert("purl".to_string(), "pkg:npm/app@1.0.0".to_string());
461        idents.insert("cpe23".to_string(), "cpe:2.3:a:foo:bar:1.0".to_string());
462
463        let mut hashes = BTreeMap::new();
464        hashes.insert("sha256".to_string(), "deadbeef".to_string());
465
466        let p = Product {
467            id: "pkg:npm/app@1.0.0".to_string(),
468            identifiers: Some(idents),
469            hashes: Some(hashes),
470            subcomponents: Vec::new(),
471        };
472        let v = serde_json::to_value(&p).unwrap();
473        // BTreeMap → keys appear in sorted order in the JSON.
474        assert_eq!(v["identifiers"]["cpe23"], "cpe:2.3:a:foo:bar:1.0");
475        assert_eq!(v["identifiers"]["purl"], "pkg:npm/app@1.0.0");
476        assert_eq!(v["hashes"]["sha256"], "deadbeef");
477    }
478
479    #[test]
480    fn product_serializes_subcomponents_in_input_order() {
481        let p = Product {
482            id: "pkg:npm/app@1.0.0".to_string(),
483            identifiers: None,
484            hashes: None,
485            subcomponents: vec![
486                Subcomponent {
487                    id: "pkg:npm/z@1.0".to_string(),
488                    identifiers: None,
489                    hashes: None,
490                },
491                Subcomponent {
492                    id: "pkg:npm/a@1.0".to_string(),
493                    identifiers: None,
494                    hashes: None,
495                },
496            ],
497        };
498        let v = serde_json::to_value(&p).unwrap();
499        let arr = v["subcomponents"].as_array().unwrap();
500        assert_eq!(arr.len(), 2);
501        assert_eq!(arr[0]["@id"], "pkg:npm/z@1.0");
502        assert_eq!(arr[1]["@id"], "pkg:npm/a@1.0");
503    }
504
505    #[test]
506    fn subcomponent_with_identifiers_and_hashes_round_trips() {
507        let mut idents = BTreeMap::new();
508        idents.insert("purl".to_string(), "pkg:npm/lodash@4.17.21".to_string());
509        let mut hashes = BTreeMap::new();
510        hashes.insert("sha256".to_string(), "abc123".to_string());
511
512        let sub = Subcomponent {
513            id: "pkg:npm/lodash@4.17.21".to_string(),
514            identifiers: Some(idents),
515            hashes: Some(hashes),
516        };
517        let json = serde_json::to_string(&sub).unwrap();
518        let parsed: Subcomponent = serde_json::from_str(&json).unwrap();
519        assert_eq!(sub, parsed);
520    }
521
522    // ── Full-document round-trips ─────────────────────────────────
523
524    #[test]
525    fn document_roundtrips_minimal() {
526        let doc = empty_doc();
527        let json = serde_json::to_string(&doc).unwrap();
528        let parsed: Document = serde_json::from_str(&json).unwrap();
529        assert_eq!(doc, parsed);
530    }
531
532    #[test]
533    fn document_roundtrips_with_all_fields_populated() {
534        let mut idents = BTreeMap::new();
535        idents.insert("purl".to_string(), "pkg:npm/app@1.0.0".to_string());
536        let mut hashes = BTreeMap::new();
537        hashes.insert("sha256".to_string(), "deadbeef".to_string());
538
539        let doc = Document {
540            context: OPENVEX_CONTEXT_V0_2_0.to_string(),
541            id: "urn:uuid:abc".to_string(),
542            author: "Socket".to_string(),
543            role: Some("publisher".to_string()),
544            timestamp: "2024-01-01T00:00:00Z".to_string(),
545            last_updated: Some("2024-06-01T00:00:00Z".to_string()),
546            version: 3,
547            tooling: Some("socket-patch 3.0.0".to_string()),
548            statements: vec![Statement {
549                id: Some("urn:uuid:stmt-1".to_string()),
550                vulnerability: Vulnerability {
551                    name: "GHSA-xxx".to_string(),
552                    aliases: vec!["CVE-2024-0001".to_string()],
553                },
554                timestamp: "2024-01-01T00:00:00Z".to_string(),
555                last_updated: Some("2024-06-01T00:00:00Z".to_string()),
556                products: vec![Product {
557                    id: "pkg:npm/app@1.0.0".to_string(),
558                    identifiers: Some(idents.clone()),
559                    hashes: Some(hashes.clone()),
560                    subcomponents: vec![Subcomponent {
561                        id: "pkg:npm/lodash@4.17.21".to_string(),
562                        identifiers: Some(idents.clone()),
563                        hashes: Some(hashes.clone()),
564                    }],
565                }],
566                status: Status::NotAffected,
567                supplier: Some("https://example.com/supplier".to_string()),
568                justification: Some(Justification::InlineMitigationsAlreadyExist),
569                impact_statement: Some("Patched via Socket".to_string()),
570                action_statement: Some("Apply socket-patch <uuid>".to_string()),
571            }],
572        };
573        let json = serde_json::to_string_pretty(&doc).unwrap();
574        let parsed: Document = serde_json::from_str(&json).unwrap();
575        assert_eq!(doc, parsed);
576    }
577
578    #[test]
579    fn parsing_a_doc_without_optional_fields_succeeds_via_default() {
580        // Spec consumers will hand us docs that omit our new optional
581        // fields. Defaulting must work end-to-end.
582        let minimal = r#"{
583            "@context": "https://openvex.dev/ns/v0.2.0",
584            "@id": "urn:uuid:1",
585            "author": "Socket",
586            "timestamp": "2024-01-01T00:00:00Z",
587            "version": 1,
588            "statements": [
589              {
590                "vulnerability": {"name": "GHSA-x"},
591                "timestamp": "2024-01-01T00:00:00Z",
592                "products": [{"@id": "pkg:npm/app@1.0.0"}],
593                "status": "not_affected"
594              }
595            ]
596        }"#;
597        let doc: Document = serde_json::from_str(minimal).unwrap();
598        assert!(doc.role.is_none());
599        assert!(doc.last_updated.is_none());
600        assert!(doc.tooling.is_none());
601        let st = &doc.statements[0];
602        assert!(st.id.is_none());
603        assert!(st.last_updated.is_none());
604        assert!(st.supplier.is_none());
605        assert!(st.action_statement.is_none());
606    }
607}