module CycloneDX::VersionGate

Overview

Spec-version field and enum gating.

CycloneDX grew over successive spec versions in two different ways, and both have to be gated:

VersionGate is the single source of truth for "what was introduced when". It drives:

The last point matters: .violations does not re-implement the rules, it runs the JSON filter and collects what it did. The validator therefore cannot fall out of sync with the serializers.

What the gate does about an out-of-range value

Too-new fields are stripped. Too-new enum values are downgraded to the enum's designated catch-all where the spec defines one (other for externalReferenceType, not_specified for aggregateType). Where there is no catch-all the value is left alone, because inventing a substitute would misrepresent the component — Validator reports it instead, and the CLI validates before writing so it can never emit such a document. The one exception is hash/@alg, which is required and has no catch-all: there the whole hash entry is dropped, since an optional hash is better lost than invalid.

The field map is keyed by context (the kind of object a field lives in) rather than by bare field name, because some names are ambiguous (e.g. tags exists on component only from 1.6 but on releaseNotes since 1.4, and bom-ref is core on a component but 1.5-only on a license).

Defined in:

cyclonedx/version_gate.cr

Constant Summary

AGGREGATE_VERSIONS = {"incomplete_first_party_proprietary_only" => "1.5", "incomplete_first_party_opensource_only" => "1.5", "incomplete_third_party_proprietary_only" => "1.5", "incomplete_third_party_opensource_only" => "1.5"}

aggregateType (composition/aggregate).

CHILD_CONTEXT = { {:bom, "metadata"} => :metadata, {:bom, "components"} => :component, {:bom, "services"} => :service, {:bom, "compositions"} => :composition, {:bom, "vulnerabilities"} => :vulnerability, {:bom, "dependencies"} => :dependency, {:metadata, "component"} => :component, {:metadata, "licenses"} => :licenses_array, {:metadata, "tools"} => :tools, {:tools, "components"} => :component, {:tools, "services"} => :service, {:component, "components"} => :component, {:component, "licenses"} => :licenses_array, {:component, "pedigree"} => :pedigree, {:component, "evidence"} => :evidence, {:pedigree, "ancestors"} => :component, {:pedigree, "descendants"} => :component, {:pedigree, "variants"} => :component, {:evidence, "licenses"} => :licenses_array, {:service, "services"} => :service, {:service, "licenses"} => :licenses_array, {:vulnerability, "analysis"} => :vulnerability_analysis, {:licenses_array, "license"} => :license}

{parent context, json key} => the context to filter the value under.

externalReferences and hashes are matched by key alone (see child_context) because they hang off many different parents and the key name is unambiguous across the whole schema.

COMPONENT_TYPE_VERSIONS = {"platform" => "1.5", "device-driver" => "1.5", "machine-learning-model" => "1.5", "data" => "1.5", "cryptographic-asset" => "1.6"}

classification (component/@type).

ENUM_FALLBACK = {external_reference: {"type" => "other"}, composition: {"aggregate" => "not_specified"}}

The catch-all each enum designates for "a type this document cannot express". Enums with no catch-all are absent, which is what makes a value non-downgradeable.

EXTERNAL_REFERENCE_TYPE_VERSIONS = {"distribution-intake" => "1.5", "security-contact" => "1.5", "model-card" => "1.5", "log" => "1.5", "configuration" => "1.5", "evidence" => "1.5", "formulation" => "1.5", "attestation" => "1.5", "threat-model" => "1.5", "adversary-model" => "1.5", "risk-assessment" => "1.5", "vulnerability-assertion" => "1.5", "exploitability-statement" => "1.5", "pentest-report" => "1.5", "static-analysis-report" => "1.5", "dynamic-analysis-report" => "1.5", "runtime-analysis-report" => "1.5", "component-analysis-report" => "1.5", "maturity-report" => "1.5", "certification-report" => "1.5", "quality-metrics" => "1.5", "codified-infrastructure" => "1.5", "poam" => "1.5", "source-distribution" => "1.6", "electronic-signature" => "1.6", "digital-signature" => "1.6", "rfc-9116" => "1.6", "patent" => "1.7", "patent-family" => "1.7", "patent-assertion" => "1.7", "citation" => "1.7"}

externalReferenceType (externalReference/@type).

GATED = {bom: {"annotations" => "1.5", "formulation" => "1.5", "definitions" => "1.6", "declarations" => "1.6"}, metadata: {"lifecycles" => "1.5", "manufacturer" => "1.6"}, component: {"modelCard" => "1.5", "data" => "1.5", "tags" => "1.6", "omniborId" => "1.6", "swhid" => "1.6", "cryptoProperties" => "1.6", "manufacturer" => "1.6", "authors" => "1.6"}, license: {"bom-ref" => "1.5", "acknowledgement" => "1.6"}, licenses_array: {"bom-ref" => "1.5", "acknowledgement" => "1.6"}, service: {"trustZone" => "1.5", "tags" => "1.6"}, composition: {"bom-ref" => "1.5", "vulnerabilities" => "1.5"}, vulnerability: {"workaround" => "1.5", "proofOfConcept" => "1.5", "rejected" => "1.5"}, vulnerability_analysis: {"firstIssued" => "1.5", "lastUpdated" => "1.5"}, evidence: {"identity" => "1.5", "occurrences" => "1.5", "callstack" => "1.5"}, dependency: {"provides" => "1.6"}, tools: {"components" => "1.5", "services" => "1.5"}}

Context => { json_key => minimum_spec_version }.

The contexts identify which kind of object the gated keys live in; see CHILD_CONTEXT for how the JSON filter descends into them and XML_CONTEXT for the XML equivalent.

Only fields the object model can actually produce are listed. 1.7 added bom.citations, metadata.distributionConstraints, component.isExternal/patentAssertions/versionRange, service.patentAssertions and externalReference.properties; none are modelled yet, so none appear here.

GATED_ENUMS = {component: {"type" => COMPONENT_TYPE_VERSIONS}, external_reference: {"type" => EXTERNAL_REFERENCE_TYPE_VERSIONS}, composition: {"aggregate" => AGGREGATE_VERSIONS}, hash: {"alg" => HASH_ALG_VERSIONS}}

Context => { json_key => value-version table }.

GATED_JSON_ONLY = {bom: {"properties" => "1.5"}}

Fields the JSON schema gained later than the XSD did, so they must be stripped from JSON but kept in XML. bom.properties is the only one: the 1.4 XSD lists properties among the bom element's children, but the 1.4 JSON schema does not allow it at the document root.

HASH_ALG_VERSIONS = {"Streebog-256" => "1.7", "Streebog-512" => "1.7"}

hashAlg (hash/@alg).

VERSION_ORDER = {"1.4" => 0, "1.5" => 1, "1.6" => 2, "1.7" => 3}

Ordering of the supported spec versions, oldest first.

XML_CONTEXT = {"bom" => :bom, "metadata" => :metadata, "component" => :component, "service" => :service, "composition" => :composition, "vulnerability" => :vulnerability, "analysis" => :vulnerability_analysis, "evidence" => :evidence, "dependency" => :dependency, "tools" => :tools}

The XML filter reuses GATED: every gated JSON key maps to an XML element of the same name, and the gating context is identified by the parent element's name. GATED_JSON_ONLY is deliberately not consulted here.

XML_ENUM_ATTR_FALLBACK = {"reference" => {"type" => "other"}}

Catch-alls for XML_ENUM_ATTRS, mirroring ENUM_FALLBACK.

XML_ENUM_ATTRS = {"component" => {"type" => COMPONENT_TYPE_VERSIONS}, "reference" => {"type" => EXTERNAL_REFERENCE_TYPE_VERSIONS}, "hash" => {"alg" => HASH_ALG_VERSIONS}}

Enum-valued attributes, by element name.

XML_ENUM_TEXT = {"aggregate" => AGGREGATE_VERSIONS}

Elements whose text content is an enum value, with their catch-all.

XML_ENUM_TEXT_FALLBACK = {"aggregate" => "not_specified"}
XML_GATED_ATTRS = {"license" => {"bom-ref" => "1.5", "acknowledgement" => "1.6"}, "expression" => {"bom-ref" => "1.5", "acknowledgement" => "1.6"}, "composition" => {"bom-ref" => "1.5"}}

Fields that are attributes in XML rather than elements, by element name.

Class Method Summary

Class Method Detail

def self.each_violation(bom, & : Violation -> ) : Nil #

Yields a Violation for everything the gate has to strip, drop or rewrite to make bom valid at its declared specVersion. Used by Validator.


[View source]
def self.filter_json(json : String, spec_version : String) : String #

String-in/string-out convenience wrapper around .filter_json_any.


[View source]
def self.filter_json_any(json : String, spec_version : String, violations : Array(Violation) | Nil = nil) : JSON::Any #

Returns a copy of json (a serialized CycloneDX BOM document) filtered down to what spec_version permits. When .violations is given, every edit the filter makes is appended to it.


[View source]
def self.filter_xml(xml : String, spec_version : String) : String #

Returns a copy of xml with elements, attributes and enum values newer than spec_version removed or downgraded.


[View source]
def self.newer?(field_version : String, spec_version : String) : Bool #

Returns true when field_version is newer than the declared spec_version (i.e. the field must be stripped / flagged).


[View source]
def self.violations(bom) : Array(Violation) #

Collects all spec-version violations on bom.

This runs the JSON filter over the pre-gate document and reports what it changed, rather than re-deriving the rules. Anything the filter learns to handle is therefore reported automatically, and the two can never disagree.


[View source]