cyclonedx_bom/models/
external_reference.rs

1/*
2 * This file is part of CycloneDX Rust Cargo.
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *     http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 *
16 * SPDX-License-Identifier: Apache-2.0
17 */
18
19use once_cell::sync::Lazy;
20use regex::Regex;
21
22use crate::external_models::uri::{validate_uri as validate_url, Uri as Url};
23use crate::models::hash::Hashes;
24use crate::validation::{Validate, ValidationContext, ValidationError, ValidationResult};
25
26use super::bom::SpecVersion;
27
28/// Represents a way to document systems, sites, and information that may be relevant but which are not included with the BOM.
29///
30/// Please see the [CycloneDX use case](https://cyclonedx.org/use-cases/#external-references) for more information and examples.
31#[derive(Clone, Debug, PartialEq, Eq, Hash)]
32pub struct ExternalReference {
33    pub external_reference_type: ExternalReferenceType,
34    pub url: Uri,
35    pub comment: Option<String>,
36    pub hashes: Option<Hashes>,
37}
38
39impl ExternalReference {
40    /// Constructs a new `ExternalReference` with the reference type and url
41    /// ```
42    /// use cyclonedx_bom::models::external_reference::{ExternalReference, ExternalReferenceType};
43    /// use cyclonedx_bom::external_models::uri::Uri;
44    ///
45    /// let url = Uri::new("https://example.org/support/sbom/portal-server/1.0.0");
46    /// let external_reference = ExternalReference::new(ExternalReferenceType::Bom, url);
47    /// ```
48    pub fn new(external_reference_type: ExternalReferenceType, url: impl Into<Uri>) -> Self {
49        Self {
50            external_reference_type,
51            url: url.into(),
52            comment: None,
53            hashes: None,
54        }
55    }
56}
57
58impl Validate for ExternalReference {
59    fn validate_version(&self, version: SpecVersion) -> ValidationResult {
60        ValidationContext::new()
61            .add_field(
62                "external_reference_type",
63                &self.external_reference_type,
64                validate_external_reference_type,
65            )
66            .add_field("url", &self.url, |uri| validate_reference_uri(uri, version))
67            .add_list("hashes", &self.hashes, |hash| {
68                hash.validate_version(version)
69            })
70            .into()
71    }
72}
73
74#[derive(Clone, Debug, PartialEq, Eq, Hash)]
75pub struct ExternalReferences(pub Vec<ExternalReference>);
76
77impl Validate for ExternalReferences {
78    fn validate_version(&self, version: SpecVersion) -> ValidationResult {
79        ValidationContext::new()
80            .add_list("inner", &self.0, |reference| {
81                reference.validate_version(version)
82            })
83            .into()
84    }
85}
86
87pub fn validate_external_reference_type(
88    reference_type: &ExternalReferenceType,
89) -> Result<(), ValidationError> {
90    if matches!(
91        reference_type,
92        ExternalReferenceType::UnknownExternalReferenceType(_)
93    ) {
94        return Err("Unknown external reference type".into());
95    }
96    Ok(())
97}
98
99/// Defined via the [CycloneDX XML schema](https://cyclonedx.org/docs/1.3/xml/#type_externalReferenceType).
100#[derive(Clone, Debug, PartialEq, Eq, Hash, strum::Display)]
101#[strum(serialize_all = "kebab-case")]
102pub enum ExternalReferenceType {
103    Vcs,
104    IssueTracker,
105    Website,
106    Advisories,
107    Bom,
108    MailingList,
109    Social,
110    Chat,
111    Documentation,
112    Support,
113    Distribution,
114    DistributionIntake,
115    License,
116    BuildMeta,
117    BuildSystem,
118    Other,
119    #[doc(hidden)]
120    #[strum(default)]
121    UnknownExternalReferenceType(String),
122    ReleaseNotes,
123    SecurityContact,
124    ModelCard,
125    Log,
126    Configuration,
127    Evidence,
128    Formulation,
129    Attestation,
130    ThreatModel,
131    AdversaryModel,
132    RiskAssessment,
133    VulnerabilityAssertion,
134    ExploitabilityStatement,
135    PentestReport,
136    StaticAnalysisReport,
137    DynamicAnalysisReport,
138    RuntimeAnalysisReport,
139    ComponentAnalysisReport,
140    MaturityReport,
141    CertificationReport,
142    CondifiedInfrastructure,
143    QualityMetrics,
144    Poam,
145}
146
147impl ExternalReferenceType {
148    pub fn new_unchecked<A: AsRef<str>>(value: A) -> Self {
149        match value.as_ref() {
150            "vcs" => Self::Vcs,
151            "issue-tracker" => Self::IssueTracker,
152            "website" => Self::Website,
153            "advisories" => Self::Advisories,
154            "bom" => Self::Bom,
155            "mailing-list" => Self::MailingList,
156            "social" => Self::Social,
157            "chat" => Self::Chat,
158            "documentation" => Self::Documentation,
159            "support" => Self::Support,
160            "distribution" => Self::Distribution,
161            "distribution-intake" => Self::DistributionIntake,
162            "license" => Self::License,
163            "build-meta" => Self::BuildMeta,
164            "build-system" => Self::BuildSystem,
165            "release-notes" => Self::ReleaseNotes,
166            "security-contact" => Self::SecurityContact,
167            "model-card" => Self::ModelCard,
168            "log" => Self::Log,
169            "configuration" => Self::Configuration,
170            "evidence" => Self::Evidence,
171            "formulation" => Self::Formulation,
172            "attestation" => Self::Attestation,
173            "threat-model" => Self::ThreatModel,
174            "adversary-model" => Self::AdversaryModel,
175            "risk-assessment" => Self::RiskAssessment,
176            "vulnerability-assertion" => Self::VulnerabilityAssertion,
177            "exploitability-statement" => Self::ExploitabilityStatement,
178            "pentest-report" => Self::PentestReport,
179            "static-analysis-report" => Self::StaticAnalysisReport,
180            "dynamic-analysis-report" => Self::DynamicAnalysisReport,
181            "runtime-analysis-report" => Self::RuntimeAnalysisReport,
182            "component-analysis-report" => Self::ComponentAnalysisReport,
183            "maturity-report" => Self::MaturityReport,
184            "certification-report" => Self::CertificationReport,
185            "codified-infrastructure" => Self::CondifiedInfrastructure,
186            "quality-metrics" => Self::QualityMetrics,
187            "poam" => Self::Poam,
188            "other" => Self::Other,
189            unknown => Self::UnknownExternalReferenceType(unknown.to_string()),
190        }
191    }
192}
193
194#[derive(Clone, Debug, PartialEq, Eq, Hash)]
195pub enum Uri {
196    Url(Url),
197    BomLink(BomLink),
198}
199
200/// Validates an [`Uri`], the [`Uri::BomLink`] variant was added in 1.5 only.
201fn validate_reference_uri(uri: &Uri, version: SpecVersion) -> Result<(), ValidationError> {
202    match uri {
203        Uri::Url(url) => validate_url(url),
204        Uri::BomLink(bom_link) => validate_bom_link(bom_link, version),
205    }
206}
207
208impl From<Url> for Uri {
209    fn from(url: Url) -> Self {
210        if url.is_bomlink() {
211            Self::BomLink(BomLink(url.to_string()))
212        } else {
213            Self::Url(url)
214        }
215    }
216}
217
218impl std::fmt::Display for Uri {
219    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
220        let s = match self {
221            Uri::Url(uri) => uri.to_string(),
222            Uri::BomLink(link) => link.0.to_string(),
223        };
224        write!(f, "{s}")
225    }
226}
227
228#[derive(Clone, Debug, PartialEq, Eq, Hash)]
229pub struct BomLink(pub String);
230
231fn validate_bom_link(bom_link: &BomLink, version: SpecVersion) -> Result<(), ValidationError> {
232    if version < SpecVersion::V1_5 {
233        return Err("BOM-Link not supported before version 1.5".into());
234    }
235
236    static BOM_LINK_REGEX: Lazy<Regex> = Lazy::new(|| {
237        Regex::new(r"^urn:cdx:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/[1-9][0-9]*(#.+)?$").unwrap()
238    });
239
240    if !BOM_LINK_REGEX.is_match(&bom_link.0) {
241        return Err(ValidationError::new("Invalid BOM-Link"));
242    }
243
244    Ok(())
245}
246
247#[cfg(test)]
248mod test {
249    use crate::{
250        models::hash::{Hash, HashValue},
251        validation,
252    };
253
254    use super::*;
255    use pretty_assertions::assert_eq;
256
257    #[test]
258    fn it_should_convert_url_into_uri() {
259        let url = Url("https://example.com".to_string());
260        assert_eq!(Uri::Url(Url("https://example.com".to_string())), url.into());
261    }
262
263    #[test]
264    fn it_should_convert_url_into_bomlink() {
265        let url = Url("urn:cdx:f08a6ccd-4dce-4759-bd84-c626675d60a7/1".to_string());
266        assert_eq!(
267            Uri::BomLink(BomLink(
268                "urn:cdx:f08a6ccd-4dce-4759-bd84-c626675d60a7/1".to_string()
269            )),
270            url.into()
271        );
272    }
273
274    #[test]
275    fn it_should_validate_external_reference_with_bomlink_correctly() {
276        let url = Uri::BomLink(BomLink(
277            "urn:cdx:f08a6ccd-4dce-4759-bd84-c626675d60a7/1".to_string(),
278        ));
279
280        let external_reference = ExternalReference {
281            external_reference_type: ExternalReferenceType::Bom,
282            url,
283            comment: Some("Comment".to_string()),
284            hashes: Some(Hashes(vec![])),
285        };
286
287        assert!(external_reference
288            .validate_version(SpecVersion::V1_5)
289            .passed());
290        assert!(external_reference
291            .validate_version(SpecVersion::V1_4)
292            .has_errors());
293        assert!(external_reference
294            .validate_version(SpecVersion::V1_3)
295            .has_errors());
296    }
297
298    #[test]
299    fn it_should_pass_validation() {
300        let validation_result = ExternalReferences(vec![
301            ExternalReference {
302                external_reference_type: ExternalReferenceType::Bom,
303                url: Uri::Url(Url("https://example.com".to_string())),
304                comment: Some("Comment".to_string()),
305                hashes: Some(Hashes(vec![])),
306            },
307            ExternalReference {
308                external_reference_type: ExternalReferenceType::Bom,
309                url: Uri::BomLink(BomLink(
310                    "urn:cdx:f08a6ccd-4dce-4759-bd84-c626675d60a7/1".to_string(),
311                )),
312                comment: Some("Comment".to_string()),
313                hashes: Some(Hashes(vec![])),
314            },
315            ExternalReference {
316                external_reference_type: ExternalReferenceType::Bom,
317                url: Uri::BomLink(BomLink(
318                    "urn:cdx:f08a6ccd-4dce-4759-bd84-c626675d60a7/1#componentA".to_string(),
319                )),
320                comment: Some("Comment".to_string()),
321                hashes: Some(Hashes(vec![])),
322            },
323        ])
324        .validate_version(SpecVersion::V1_5);
325
326        assert!(validation_result.passed());
327    }
328
329    #[test]
330    fn it_should_fail_validation() {
331        let validation_result = ExternalReferences(vec![
332            ExternalReference {
333                external_reference_type: ExternalReferenceType::UnknownExternalReferenceType(
334                    "unknown reference type".to_string(),
335                ),
336                url: Uri::Url(Url("invalid uri".to_string())),
337                comment: Some("Comment".to_string()),
338                hashes: Some(Hashes(vec![Hash {
339                    alg: crate::models::hash::HashAlgorithm::MD5,
340                    content: HashValue("invalid hash".to_string()),
341                }])),
342            },
343            ExternalReference {
344                external_reference_type: ExternalReferenceType::UnknownExternalReferenceType(
345                    "unknown reference type".to_string(),
346                ),
347                url: Uri::BomLink(BomLink("invalid bom-link".to_string())),
348                comment: Some("Comment".to_string()),
349                hashes: Some(Hashes(vec![Hash {
350                    alg: crate::models::hash::HashAlgorithm::MD5,
351                    content: HashValue("invalid hash".to_string()),
352                }])),
353            },
354        ])
355        .validate_version(SpecVersion::V1_5);
356
357        assert_eq!(
358            validation_result,
359            validation::list(
360                "inner",
361                [
362                    (
363                        0,
364                        vec![
365                            validation::field(
366                                "external_reference_type",
367                                "Unknown external reference type"
368                            ),
369                            validation::field("url", "Uri does not conform to RFC 3986"),
370                            validation::list(
371                                "hashes",
372                                [(
373                                    0,
374                                    validation::list(
375                                        "inner",
376                                        [(
377                                            0,
378                                            validation::field(
379                                                "content",
380                                                "HashValue does not match regular expression"
381                                            )
382                                        )]
383                                    )
384                                )]
385                            )
386                        ]
387                    ),
388                    (
389                        1,
390                        vec![
391                            validation::field(
392                                "external_reference_type",
393                                "Unknown external reference type"
394                            ),
395                            validation::field("url", "Invalid BOM-Link"),
396                            validation::list(
397                                "hashes",
398                                [(
399                                    0,
400                                    validation::list(
401                                        "inner",
402                                        [(
403                                            0,
404                                            validation::field(
405                                                "content",
406                                                "HashValue does not match regular expression"
407                                            )
408                                        )]
409                                    )
410                                )]
411                            )
412                        ]
413                    )
414                ]
415            )
416        );
417    }
418}