From e7e3d797c9d9926f0fecbe8c179cb46179c0bcad Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Tue, 15 Jul 2025 14:20:10 -0400 Subject: [PATCH 01/99] refactor VersionField into an annotation for use in other objects also make CVSS decision point groups conform --- src/ssvc/_mixins.py | 15 ++++++++-- src/ssvc/dp_groups/cvss/collections.py | 30 +++++++++---------- src/test/decision_points/test_cvss_helpers.py | 2 +- 3 files changed, 29 insertions(+), 18 deletions(-) diff --git a/src/ssvc/_mixins.py b/src/ssvc/_mixins.py index 67576910..a3647b7b 100644 --- a/src/ssvc/_mixins.py +++ b/src/ssvc/_mixins.py @@ -21,7 +21,7 @@ # subject to its own license. # DM24-0278 -from typing import Optional +from typing import Annotated, Optional from pydantic import BaseModel, ConfigDict, Field, field_validator from semver import Version @@ -29,13 +29,24 @@ from ssvc import _schemaVersion from ssvc.namespaces import NS_PATTERN, NameSpace +VersionField = Annotated[ + str, + Field( + default="0.0.0", + description="The version of the SSVC object. This should be a valid semantic version string.", + examples=["1.0.0", "2.1.3"], + pattern=r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$", + min_length=5, + ), +] + class _Versioned(BaseModel): """ Mixin class for versioned SSVC objects. """ - version: str = "0.0.0" + version: VersionField @field_validator("version") @classmethod diff --git a/src/ssvc/dp_groups/cvss/collections.py b/src/ssvc/dp_groups/cvss/collections.py index 4beca498..c2b61c2c 100644 --- a/src/ssvc/dp_groups/cvss/collections.py +++ b/src/ssvc/dp_groups/cvss/collections.py @@ -153,21 +153,21 @@ CVSSv1_B = DecisionPointGroup( name="CVSS", - version="1.0", + version="1.0.0", description="CVSS v1 decision points", decision_points=tuple(_BASE_1), ) CVSSv1_BT = DecisionPointGroup( name="CVSS", - version="1.0", + version="1.0.0", description="CVSS v1 decision points", decision_points=tuple(_BASE_1 + _TEMPORAL_1), ) CVSSv1_BTE = DecisionPointGroup( name="CVSS", - version="1.0", + version="1.0.0", description="CVSS v1 decision points", decision_points=tuple(_BASE_1 + _TEMPORAL_1 + _ENVIRONMENTAL_1), ) @@ -200,21 +200,21 @@ CVSSv2_B = DecisionPointGroup( name="CVSS Version 2 Base Metrics", description="Base metrics for CVSS v2", - version="2.0", + version="2.0.0", decision_points=tuple(_BASE_2), ) CVSSv2_BT = DecisionPointGroup( name="CVSS Version 2 Base and Temporal Metrics", description="Base and Temporal metrics for CVSS v2", - version="2.0", + version="2.0.0", decision_points=tuple(_BASE_2 + _TEMPORAL_2), ) CVSSv2_BTE = DecisionPointGroup( name="CVSS Version 2 Base, Temporal, and Environmental Metrics", description="Base, Temporal, and Environmental metrics for CVSS v2", - version="2.0", + version="2.0.0", decision_points=tuple(_BASE_2 + _TEMPORAL_2 + _ENVIRONMENTAL_2), ) @@ -249,21 +249,21 @@ CVSSv3_B = DecisionPointGroup( name="CVSS Version 3 Base Metrics", description="Base metrics for CVSS v3", - version="3.0", + version="3.0.0", decision_points=tuple(_BASE_3), ) CVSSv3_BT = DecisionPointGroup( name="CVSS Version 3 Base and Temporal Metrics", description="Base and Temporal metrics for CVSS v3", - version="3.0", + version="3.0.0", decision_points=tuple(_BASE_3 + _TEMPORAL_3), ) CVSSv3_BTE = DecisionPointGroup( name="CVSS Version 3 Base, Temporal, and Environmental Metrics", description="Base, Temporal, and Environmental metrics for CVSS v3", - version="3.0", + version="3.0.0", decision_points=tuple(_BASE_3 + _TEMPORAL_3 + _ENVIRONMENTAL_3), ) @@ -313,7 +313,7 @@ CVSSv4_B = DecisionPointGroup( name="CVSSv4 Base Metrics", description="Base metrics for CVSS v4", - version="1.0.0", + version="4.0.0", decision_points=tuple(_BASE_4), ) @@ -321,7 +321,7 @@ CVSSv4_BE = DecisionPointGroup( name="CVSSv4 Base and Environmental Metrics", description="Base and Environmental metrics for CVSS v4", - version="1.0.0", + version="4.0.0", decision_points=tuple(_BASE_4 + _ENVIRONMENTAL_4), ) @@ -329,7 +329,7 @@ CVSSv4_BT = DecisionPointGroup( name="CVSSv4 Base and Threat Metrics", description="Base and Threat metrics for CVSS v4", - version="1.0.0", + version="4.0.0", decision_points=tuple(_BASE_4 + _THREAT_4), ) @@ -337,21 +337,21 @@ CVSSv4_BTE = DecisionPointGroup( name="CVSSv4 Base, Threat, and Environmental Metrics", description="Base, Threat, and Environmental metrics for CVSS v4", - version="1.0.0", + version="4.0.0", decision_points=tuple(_BASE_4 + _THREAT_4 + _ENVIRONMENTAL_4), ) CVSSv4 = DecisionPointGroup( name="CVSSv4", description="All decision points for CVSS v4 (including supplemental metrics)", - version="1.0.0", + version="4.0.0", decision_points=tuple(_BASE_4 + _THREAT_4 + _ENVIRONMENTAL_4 + _SUPPLEMENTAL_4), ) CVSSv4_Equivalence_Sets = DecisionPointGroup( name="CVSSv4 EQ Sets", description="Equivalence Sets for CVSS v4", - version="1.0.0", + version="4.0.0", decision_points=( EQ1, EQ2, diff --git a/src/test/decision_points/test_cvss_helpers.py b/src/test/decision_points/test_cvss_helpers.py index dc662dee..73dcad86 100644 --- a/src/test/decision_points/test_cvss_helpers.py +++ b/src/test/decision_points/test_cvss_helpers.py @@ -65,7 +65,7 @@ def setUp(self) -> None: dp = CvssDecisionPoint( name=f"test_{i}", description=f"test_{i}", - version="1.0", + version="1.0.0", key=f"TDP{i}", values=( DecisionPointValue( From de1a38bca85e650f078eb8a8dcddc89999388f29 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Tue, 15 Jul 2025 14:21:26 -0400 Subject: [PATCH 02/99] add id property to decision points namespace:key:version is sufficient to uniquely identify any Decision Point --- src/ssvc/decision_points/base.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/ssvc/decision_points/base.py b/src/ssvc/decision_points/base.py index 69e42d96..57b989f6 100644 --- a/src/ssvc/decision_points/base.py +++ b/src/ssvc/decision_points/base.py @@ -187,6 +187,10 @@ class DecisionPoint( def __str__(self): return FIELD_DELIMITER.join([self.namespace, self.key, self.version]) + @property + def id(self): + return ":".join([self.namespace, self.key, self.version]) + @property def str(self) -> str: """ From 0d7b1c2b890a6fa6553500ab162136cfabcbd66f Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Tue, 15 Jul 2025 14:21:45 -0400 Subject: [PATCH 03/99] add minimal selection object for data exchange --- src/ssvc/selection.py | 132 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 src/ssvc/selection.py diff --git a/src/ssvc/selection.py b/src/ssvc/selection.py new file mode 100644 index 00000000..40979510 --- /dev/null +++ b/src/ssvc/selection.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python +""" +Provides an SSVC selection object and functions to faciliate transition from an SSVC decision point to a selection. +""" +# Copyright (c) 2025 Carnegie Mellon University. +# NO WARRANTY. THIS CARNEGIE MELLON UNIVERSITY AND SOFTWARE +# ENGINEERING INSTITUTE MATERIAL IS FURNISHED ON AN "AS-IS" BASIS. +# CARNEGIE MELLON UNIVERSITY MAKES NO WARRANTIES OF ANY KIND, +# EITHER EXPRESSED OR IMPLIED, AS TO ANY MATTER INCLUDING, BUT +# NOT LIMITED TO, WARRANTY OF FITNESS FOR PURPOSE OR +# MERCHANTABILITY, EXCLUSIVITY, OR RESULTS OBTAINED FROM USE +# OF THE MATERIAL. CARNEGIE MELLON UNIVERSITY DOES NOT MAKE +# ANY WARRANTY OF ANY KIND WITH RESPECT TO FREEDOM FROM +# PATENT, TRADEMARK, OR COPYRIGHT INFRINGEMENT. +# Licensed under a MIT (SEI)-style license, please see LICENSE or contact +# permission@sei.cmu.edu for full terms. +# [DISTRIBUTION STATEMENT A] This material has been approved for +# public release and unlimited distribution. Please see Copyright notice +# for non-US Government use and distribution. +# This Software includes and/or makes use of Third-Party Software each +# subject to its own license. +# DM24-0278 + +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, Field + +from ssvc._mixins import VersionField +from ssvc.decision_points.base import DecisionPoint + + +class MinimalSelection(BaseModel): + """ + A minimal selection object that contains the decision point ID and the selected options. + This is used to transition from an SSVC decision point to a selection. + """ + + decision_point_id: str = Field( + ..., + description="The ID (namespace:key:version) of the decision point from which the selection was made.", + ) + namespace: str = Field( + ..., + description="The namespace of the decision point.", + examples="ssvc, cisa, x_private, etc.", + min_length=3, + ) + key: str = Field( + ..., + description="The decision point key.", + examples="E, A, MI, PSI, etc.", + min_length=1, + ) + version: VersionField + + selection: list[str] = Field( + ..., + description="A list of selected values keys from the decision point values.", + min_length=1, + ) + + +class MinimalSelectionList(BaseModel): + """ + A list of minimal selection objects. + This is used to hold multiple selections made from different decision points. + """ + + vulnerability_id: Optional[str] = Field( + default=None, + description="Optional vulnerability ID associated with the selections.", + examples="CVE-2025-0000, VU#999999, GHSA-0123-4567-89ab, etc.", + ) + selections: list[MinimalSelection] = Field( + default_factory=list, + description="List of minimal selections made from decision points.", + ) + timestamp: Optional[datetime] = Field( + default=None, description="Timestamp of when the selections were made." + ) + + def add_selection(self, selection: MinimalSelection) -> None: + """ + Adds a minimal selection to the list. + + Args: + selection (MinimalSelection): The minimal selection to add. + """ + self.selections.append(selection) + + +def selection_from_decision_point(decision_point: DecisionPoint) -> MinimalSelection: + """ + Converts a decision point to a minimal selection object. + + Args: + decision_point (DecisionPoint): The decision point to convert. + + Returns: + MinimalSelection: The resulting minimal selection object. + """ + data = { + "decision_point_id": decision_point.id, + "namespace": decision_point.namespace, + "key": decision_point.key, + "version": decision_point.version, + "selection": [val.key for val in decision_point.values], + } + + return MinimalSelection(**data) + + +def main(): + from ssvc.decision_points.ssvc.automatable import LATEST as dp1 + from ssvc.decision_points.ssvc.safety_impact import LATEST as dp2 + import json + + selections = MinimalSelectionList() + selections.add_selection(selection_from_decision_point(dp1)) + selections.add_selection(selection_from_decision_point(dp2)) + selections.timestamp = datetime.now() + + print(selections.model_dump_json(indent=2, exclude_none=True)) + + print("# Schema for MinimalSelectionList") + schema = MinimalSelection.model_json_schema() + print(json.dumps(schema, indent=2)) + + +if __name__ == "__main__": + main() From 1a4bd124dc971879ccd6ed35c8bcd516aaf22ef0 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Tue, 15 Jul 2025 14:54:10 -0400 Subject: [PATCH 04/99] add new version of decision point value selection schema --- ...on_Point_Value_Selection-2-0-0.schema.json | 111 ++++++++++++++++++ src/ssvc/selection.py | 29 ++++- 2 files changed, 135 insertions(+), 5 deletions(-) create mode 100644 data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json diff --git a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json new file mode 100644 index 00000000..69bbda43 --- /dev/null +++ b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json @@ -0,0 +1,111 @@ +{ + "$defs": { + "MinimalSelection": { + "description": "A minimal selection object that contains the decision point ID and the selected options.\nThis is used to transition from an SSVC decision point to a selection.", + "properties": { + "decision_point_id": { + "description": "The ID (namespace:key:version) of the decision point from which the selection was made.", + "title": "Decision Point Id", + "type": "string" + }, + "namespace": { + "description": "The namespace of the decision point.", + "examples": "ssvc, cisa, x_private, etc.", + "minLength": 3, + "title": "Namespace", + "type": "string" + }, + "key": { + "description": "The decision point key.", + "examples": "E, A, MI, PSI, etc.", + "minLength": 1, + "title": "Key", + "type": "string" + }, + "version": { + "default": "0.0.0", + "description": "The version of the SSVC object. This should be a valid semantic version string.", + "examples": [ + "1.0.0", + "2.1.3" + ], + "minLength": 5, + "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$", + "title": "Version", + "type": "string" + }, + "values": { + "description": "A list of selected values keys from the decision point values.", + "items": { + "type": "string" + }, + "minItems": 1, + "title": "Values", + "type": "array" + } + }, + "required": [ + "decision_point_id", + "namespace", + "key", + "values" + ], + "title": "MinimalSelection", + "type": "object" + } + }, + "description": [ + "This schema defines the structure for selecting SSVC Decision Points and their evaluated values for a given vulnerability. Each vulnerability can have multiple Decision Points, and each Decision Point can have multiple selected values when full certainty is not available." + ], + "properties": { + "schemaVersion": { + "default": "2.0.0", + "description": "The schema version of this selection list.", + "title": "Schemaversion", + "type": "string" + }, + "vulnerability_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional vulnerability ID associated with the selections.", + "examples": "CVE-2025-0000, VU#999999, GHSA-0123-4567-89ab, etc.", + "title": "Vulnerability Id" + }, + "selections": { + "description": "List of minimal selections made from decision points.", + "items": { + "$ref": "#/$defs/MinimalSelection" + }, + "title": "Selections", + "type": "array" + }, + "timestamp": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Timestamp of when the selections were made.", + "title": "Timestamp" + } + }, + "type": "object", + "$schema": [ + "https://json-schema.org/draft/2020-12/schema" + ], + "$id": [ + "https://certcc.github.io/SSVC/data/schema/v1/Decision_Point_Value_Selection-1-0-1.schema.json" + ] +} \ No newline at end of file diff --git a/src/ssvc/selection.py b/src/ssvc/selection.py index 40979510..28ef98a8 100644 --- a/src/ssvc/selection.py +++ b/src/ssvc/selection.py @@ -54,7 +54,7 @@ class MinimalSelection(BaseModel): ) version: VersionField - selection: list[str] = Field( + values: list[str] = Field( ..., description="A list of selected values keys from the decision point values.", min_length=1, @@ -63,10 +63,13 @@ class MinimalSelection(BaseModel): class MinimalSelectionList(BaseModel): """ - A list of minimal selection objects. - This is used to hold multiple selections made from different decision points. + A down-selection of SSVC Decision Points that represent an evaluation at a specific time of a Vulnerability evaluation. """ + schemaVersion: str = Field( + "2.0.0", description="The schema version of this selection list." + ) + vulnerability_id: Optional[str] = Field( default=None, description="Optional vulnerability ID associated with the selections.", @@ -105,7 +108,7 @@ def selection_from_decision_point(decision_point: DecisionPoint) -> MinimalSelec "namespace": decision_point.namespace, "key": decision_point.key, "version": decision_point.version, - "selection": [val.key for val in decision_point.values], + "values": [val.key for val in decision_point.values], } return MinimalSelection(**data) @@ -124,9 +127,25 @@ def main(): print(selections.model_dump_json(indent=2, exclude_none=True)) print("# Schema for MinimalSelectionList") - schema = MinimalSelection.model_json_schema() + schema = MinimalSelectionList.model_json_schema() + + # add schema extras + schema.pop("title") + schema["$schema"] = ("https://json-schema.org/draft/2020-12/schema",) + schema["$id"] = ( + "https://certcc.github.io/SSVC/data/schema/v1/Decision_Point_Value_Selection-1-0-1.schema.json", + ) + schema["description"] = ( + "This schema defines the structure for selecting SSVC Decision Points and their evaluated values for a given vulnerability. Each vulnerability can have multiple Decision Points, and each Decision Point can have multiple selected values when full certainty is not available.", + ) + print(json.dumps(schema, indent=2)) + with open( + "../../data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json", "w" + ) as f: + json.dump(schema, f, indent=2) + if __name__ == "__main__": main() From 6813909490058bd55b18eb7759e90f5d2bee836a Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Tue, 15 Jul 2025 14:55:15 -0400 Subject: [PATCH 05/99] add new version of decision point value selection schema --- data/schema/current/Decision_Point_Value_Selection.schema.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/schema/current/Decision_Point_Value_Selection.schema.json b/data/schema/current/Decision_Point_Value_Selection.schema.json index b708b5d7..4aed9da4 120000 --- a/data/schema/current/Decision_Point_Value_Selection.schema.json +++ b/data/schema/current/Decision_Point_Value_Selection.schema.json @@ -1 +1 @@ -../v1/Decision_Point_Value_Selection-1-0-1.schema.json \ No newline at end of file +../v2/Decision_Point_Value_Selection-2-0-0.schema.json \ No newline at end of file From cc9874a125213fcb15462a17a7dae72725322652 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Tue, 15 Jul 2025 16:10:00 -0400 Subject: [PATCH 06/99] make examples into lists --- ...on_Point_Value_Selection-2-0-0.schema.json | 20 +++++++++++++++---- src/ssvc/selection.py | 6 +++--- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json index 69bbda43..b419b050 100644 --- a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json +++ b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json @@ -10,20 +10,28 @@ }, "namespace": { "description": "The namespace of the decision point.", - "examples": "ssvc, cisa, x_private, etc.", + "examples": [ + "ssvc", + "cisa", + "certcc" + ], "minLength": 3, "title": "Namespace", "type": "string" }, "key": { "description": "The decision point key.", - "examples": "E, A, MI, PSI, etc.", + "examples": [ + "E", + "A", + "MI", + "PSI" + ], "minLength": 1, "title": "Key", "type": "string" }, "version": { - "default": "0.0.0", "description": "The version of the SSVC object. This should be a valid semantic version string.", "examples": [ "1.0.0", @@ -75,7 +83,11 @@ ], "default": null, "description": "Optional vulnerability ID associated with the selections.", - "examples": "CVE-2025-0000, VU#999999, GHSA-0123-4567-89ab, etc.", + "examples": [ + "CVE-2025-0000", + "VU#999999", + "GHSA-0123-4567-89ab" + ], "title": "Vulnerability Id" }, "selections": { diff --git a/src/ssvc/selection.py b/src/ssvc/selection.py index 28ef98a8..b33d5464 100644 --- a/src/ssvc/selection.py +++ b/src/ssvc/selection.py @@ -43,13 +43,13 @@ class MinimalSelection(BaseModel): namespace: str = Field( ..., description="The namespace of the decision point.", - examples="ssvc, cisa, x_private, etc.", + examples=["ssvc", "cisa", "certcc"], min_length=3, ) key: str = Field( ..., description="The decision point key.", - examples="E, A, MI, PSI, etc.", + examples=["E", "A", "MI", "PSI"], min_length=1, ) version: VersionField @@ -73,7 +73,7 @@ class MinimalSelectionList(BaseModel): vulnerability_id: Optional[str] = Field( default=None, description="Optional vulnerability ID associated with the selections.", - examples="CVE-2025-0000, VU#999999, GHSA-0123-4567-89ab, etc.", + examples=["CVE-2025-0000", "VU#999999", "GHSA-0123-4567-89ab"], ) selections: list[MinimalSelection] = Field( default_factory=list, From 3842cf78cbcfb57ecd398d8dc1dfe2fb10e8a44d Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Tue, 15 Jul 2025 16:11:15 -0400 Subject: [PATCH 07/99] fix some formatting --- ...on_Point_Value_Selection-2-0-0.schema.json | 12 +++------ src/ssvc/selection.py | 25 +++++++++++++------ 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json index b419b050..28a274ef 100644 --- a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json +++ b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json @@ -62,9 +62,7 @@ "type": "object" } }, - "description": [ - "This schema defines the structure for selecting SSVC Decision Points and their evaluated values for a given vulnerability. Each vulnerability can have multiple Decision Points, and each Decision Point can have multiple selected values when full certainty is not available." - ], + "description": "This schema defines the structure for selecting SSVC Decision Points and their evaluated values for a given vulnerability. Each vulnerability can have multiple Decision Points, and each Decision Point can have multiple selected values when full certainty is not available.", "properties": { "schemaVersion": { "default": "2.0.0", @@ -114,10 +112,6 @@ } }, "type": "object", - "$schema": [ - "https://json-schema.org/draft/2020-12/schema" - ], - "$id": [ - "https://certcc.github.io/SSVC/data/schema/v1/Decision_Point_Value_Selection-1-0-1.schema.json" - ] + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://certcc.github.io/SSVC/data/schema/v1/Decision_Point_Value_Selection-1-0-1.schema.json" } \ No newline at end of file diff --git a/src/ssvc/selection.py b/src/ssvc/selection.py index b33d5464..49be1dfa 100644 --- a/src/ssvc/selection.py +++ b/src/ssvc/selection.py @@ -1,6 +1,6 @@ #!/usr/bin/env python """ -Provides an SSVC selection object and functions to faciliate transition from an SSVC decision point to a selection. +Provides an SSVC selection object and functions to facilitate transition from an SSVC decision point to a selection. """ # Copyright (c) 2025 Carnegie Mellon University. # NO WARRANTY. THIS CARNEGIE MELLON UNIVERSITY AND SOFTWARE @@ -114,7 +114,13 @@ def selection_from_decision_point(decision_point: DecisionPoint) -> MinimalSelec return MinimalSelection(**data) -def main(): +def main() -> None: + """ + Prints example selections and their schema in JSON format. + + Returns: + None + """ from ssvc.decision_points.ssvc.automatable import LATEST as dp1 from ssvc.decision_points.ssvc.safety_impact import LATEST as dp2 import json @@ -131,19 +137,22 @@ def main(): # add schema extras schema.pop("title") - schema["$schema"] = ("https://json-schema.org/draft/2020-12/schema",) + schema["$schema"] = "https://json-schema.org/draft/2020-12/schema" schema["$id"] = ( - "https://certcc.github.io/SSVC/data/schema/v1/Decision_Point_Value_Selection-1-0-1.schema.json", + "https://certcc.github.io/SSVC/data/schema/v1/Decision_Point_Value_Selection-1-0-1.schema.json" ) schema["description"] = ( - "This schema defines the structure for selecting SSVC Decision Points and their evaluated values for a given vulnerability. Each vulnerability can have multiple Decision Points, and each Decision Point can have multiple selected values when full certainty is not available.", + "This schema defines the structure for selecting SSVC Decision Points and their evaluated values for a given vulnerability. Each vulnerability can have multiple Decision Points, and each Decision Point can have multiple selected values when full certainty is not available." ) print(json.dumps(schema, indent=2)) - with open( - "../../data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json", "w" - ) as f: + schema_path = ( + "../../data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json" + ) + + with open(schema_path, "w") as f: + print(f"Writing schema to {schema_path}") json.dump(schema, f, indent=2) From 0031210e52ced6e3a14148baee82ac400a66a97d Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Tue, 15 Jul 2025 16:13:36 -0400 Subject: [PATCH 08/99] remove default from VersionField annotation --- .../schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json | 1 + src/ssvc/_mixins.py | 3 +-- src/ssvc/selection.py | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json index 28a274ef..53fb63eb 100644 --- a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json +++ b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json @@ -56,6 +56,7 @@ "decision_point_id", "namespace", "key", + "version", "values" ], "title": "MinimalSelection", diff --git a/src/ssvc/_mixins.py b/src/ssvc/_mixins.py index a3647b7b..30c82528 100644 --- a/src/ssvc/_mixins.py +++ b/src/ssvc/_mixins.py @@ -32,7 +32,6 @@ VersionField = Annotated[ str, Field( - default="0.0.0", description="The version of the SSVC object. This should be a valid semantic version string.", examples=["1.0.0", "2.1.3"], pattern=r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$", @@ -46,7 +45,7 @@ class _Versioned(BaseModel): Mixin class for versioned SSVC objects. """ - version: VersionField + version: VersionField = Field(default="0.0.0") @field_validator("version") @classmethod diff --git a/src/ssvc/selection.py b/src/ssvc/selection.py index 49be1dfa..b6a08121 100644 --- a/src/ssvc/selection.py +++ b/src/ssvc/selection.py @@ -53,7 +53,6 @@ class MinimalSelection(BaseModel): min_length=1, ) version: VersionField - values: list[str] = Field( ..., description="A list of selected values keys from the decision point values.", From be0839844cd1d0374fff167b8c27551075fd99fa Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Tue, 15 Jul 2025 16:15:15 -0400 Subject: [PATCH 09/99] we don't need a decision point id field separately, it's derivable from the other key-value pairs --- .../v2/Decision_Point_Value_Selection-2-0-0.schema.json | 6 ------ src/ssvc/selection.py | 5 ----- 2 files changed, 11 deletions(-) diff --git a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json index 53fb63eb..e0edc0ed 100644 --- a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json +++ b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json @@ -3,11 +3,6 @@ "MinimalSelection": { "description": "A minimal selection object that contains the decision point ID and the selected options.\nThis is used to transition from an SSVC decision point to a selection.", "properties": { - "decision_point_id": { - "description": "The ID (namespace:key:version) of the decision point from which the selection was made.", - "title": "Decision Point Id", - "type": "string" - }, "namespace": { "description": "The namespace of the decision point.", "examples": [ @@ -53,7 +48,6 @@ } }, "required": [ - "decision_point_id", "namespace", "key", "version", diff --git a/src/ssvc/selection.py b/src/ssvc/selection.py index b6a08121..c91da537 100644 --- a/src/ssvc/selection.py +++ b/src/ssvc/selection.py @@ -36,10 +36,6 @@ class MinimalSelection(BaseModel): This is used to transition from an SSVC decision point to a selection. """ - decision_point_id: str = Field( - ..., - description="The ID (namespace:key:version) of the decision point from which the selection was made.", - ) namespace: str = Field( ..., description="The namespace of the decision point.", @@ -103,7 +99,6 @@ def selection_from_decision_point(decision_point: DecisionPoint) -> MinimalSelec MinimalSelection: The resulting minimal selection object. """ data = { - "decision_point_id": decision_point.id, "namespace": decision_point.namespace, "key": decision_point.key, "version": decision_point.version, From 4f4cde0409fcb3173bb0d0a5a6a788daa7906aac Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Tue, 15 Jul 2025 16:34:09 -0400 Subject: [PATCH 10/99] more clean up --- ...on_Point_Value_Selection-2-0-0.schema.json | 31 ++++++++++++++++--- src/ssvc/selection.py | 31 +++++++++++++------ 2 files changed, 47 insertions(+), 15 deletions(-) diff --git a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json index e0edc0ed..ada58a1f 100644 --- a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json +++ b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json @@ -38,7 +38,18 @@ "type": "string" }, "values": { - "description": "A list of selected values keys from the decision point values.", + "description": "A list of selected value keys from the decision point values.", + "examples": [ + [ + "N", + "Y" + ], + [ + "A", + "B", + "C" + ] + ], "items": { "type": "string" }, @@ -60,7 +71,7 @@ "description": "This schema defines the structure for selecting SSVC Decision Points and their evaluated values for a given vulnerability. Each vulnerability can have multiple Decision Points, and each Decision Point can have multiple selected values when full certainty is not available.", "properties": { "schemaVersion": { - "default": "2.0.0", + "const": "2.0.0", "description": "The schema version of this selection list.", "title": "Schemaversion", "type": "string" @@ -68,6 +79,7 @@ "vulnerability_id": { "anyOf": [ { + "minLength": 1, "type": "string" }, { @@ -88,6 +100,7 @@ "items": { "$ref": "#/$defs/MinimalSelection" }, + "minItems": 1, "title": "Selections", "type": "array" }, @@ -101,12 +114,20 @@ "type": "null" } ], - "default": null, - "description": "Timestamp of when the selections were made.", + "description": "Timestamp of when the selections were made, in ISO 8601 format.", + "examples": [ + "2025-01-01T12:00:00Z", + "2025-01-02T15:30:45Z" + ], "title": "Timestamp" } }, + "required": [ + "schemaVersion", + "selections", + "timestamp" + ], "type": "object", "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://certcc.github.io/SSVC/data/schema/v1/Decision_Point_Value_Selection-1-0-1.schema.json" + "$id": "https://certcc.github.io/SSVC/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json" } \ No newline at end of file diff --git a/src/ssvc/selection.py b/src/ssvc/selection.py index c91da537..469c973e 100644 --- a/src/ssvc/selection.py +++ b/src/ssvc/selection.py @@ -22,7 +22,7 @@ # DM24-0278 from datetime import datetime -from typing import Optional +from typing import Literal, Optional from pydantic import BaseModel, Field @@ -51,8 +51,12 @@ class MinimalSelection(BaseModel): version: VersionField values: list[str] = Field( ..., - description="A list of selected values keys from the decision point values.", + description="A list of selected value keys from the decision point values.", min_length=1, + examples=[ + ["N", "Y"], + ["A", "B", "C"], + ], # Example values ) @@ -61,21 +65,26 @@ class MinimalSelectionList(BaseModel): A down-selection of SSVC Decision Points that represent an evaluation at a specific time of a Vulnerability evaluation. """ - schemaVersion: str = Field( - "2.0.0", description="The schema version of this selection list." + schemaVersion: Literal["2.0.0"] = Field( + ..., + description="The schema version of this selection list.", ) vulnerability_id: Optional[str] = Field( default=None, description="Optional vulnerability ID associated with the selections.", examples=["CVE-2025-0000", "VU#999999", "GHSA-0123-4567-89ab"], + min_length=1, ) selections: list[MinimalSelection] = Field( - default_factory=list, + ..., description="List of minimal selections made from decision points.", + min_length=1, ) timestamp: Optional[datetime] = Field( - default=None, description="Timestamp of when the selections were made." + ..., + description="Timestamp of when the selections were made, in ISO 8601 format.", + examples=["2025-01-01T12:00:00Z", "2025-01-02T15:30:45Z"], ) def add_selection(self, selection: MinimalSelection) -> None: @@ -119,9 +128,11 @@ def main() -> None: from ssvc.decision_points.ssvc.safety_impact import LATEST as dp2 import json - selections = MinimalSelectionList() - selections.add_selection(selection_from_decision_point(dp1)) - selections.add_selection(selection_from_decision_point(dp2)) + a1 = selection_from_decision_point(dp1) + a2 = selection_from_decision_point(dp2) + selections = MinimalSelectionList( + schemaVersion="2.0.0", selections=[a1, a2], timestamp=datetime.now() + ) selections.timestamp = datetime.now() print(selections.model_dump_json(indent=2, exclude_none=True)) @@ -133,7 +144,7 @@ def main() -> None: schema.pop("title") schema["$schema"] = "https://json-schema.org/draft/2020-12/schema" schema["$id"] = ( - "https://certcc.github.io/SSVC/data/schema/v1/Decision_Point_Value_Selection-1-0-1.schema.json" + "https://certcc.github.io/SSVC/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json" ) schema["description"] = ( "This schema defines the structure for selecting SSVC Decision Points and their evaluated values for a given vulnerability. Each vulnerability can have multiple Decision Points, and each Decision Point can have multiple selected values when full certainty is not available." From b48d444998de1c3f30fea39ed6ad976bf8ec9d2a Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Tue, 15 Jul 2025 16:34:09 -0400 Subject: [PATCH 11/99] more clean up --- ...on_Point_Value_Selection-2-0-0.schema.json | 30 +++++++++++++--- src/ssvc/selection.py | 34 +++++++++++++------ 2 files changed, 50 insertions(+), 14 deletions(-) diff --git a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json index e0edc0ed..ef75dd52 100644 --- a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json +++ b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json @@ -38,7 +38,18 @@ "type": "string" }, "values": { - "description": "A list of selected values keys from the decision point values.", + "description": "A list of selected value keys from the decision point values.", + "examples": [ + [ + "N", + "Y" + ], + [ + "A", + "B", + "C" + ] + ], "items": { "type": "string" }, @@ -60,6 +71,7 @@ "description": "This schema defines the structure for selecting SSVC Decision Points and their evaluated values for a given vulnerability. Each vulnerability can have multiple Decision Points, and each Decision Point can have multiple selected values when full certainty is not available.", "properties": { "schemaVersion": { + "const": "2.0.0", "default": "2.0.0", "description": "The schema version of this selection list.", "title": "Schemaversion", @@ -68,6 +80,7 @@ "vulnerability_id": { "anyOf": [ { + "minLength": 1, "type": "string" }, { @@ -88,6 +101,7 @@ "items": { "$ref": "#/$defs/MinimalSelection" }, + "minItems": 1, "title": "Selections", "type": "array" }, @@ -101,12 +115,20 @@ "type": "null" } ], - "default": null, - "description": "Timestamp of when the selections were made.", + "description": "Timestamp of when the selections were made, in ISO 8601 format.", + "examples": [ + "2025-01-01T12:00:00Z", + "2025-01-02T15:30:45Z" + ], "title": "Timestamp" } }, + "required": [ + "schemaVersion", + "selections", + "timestamp" + ], "type": "object", "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://certcc.github.io/SSVC/data/schema/v1/Decision_Point_Value_Selection-1-0-1.schema.json" + "$id": "https://certcc.github.io/SSVC/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json" } \ No newline at end of file diff --git a/src/ssvc/selection.py b/src/ssvc/selection.py index c91da537..2ea3f6ef 100644 --- a/src/ssvc/selection.py +++ b/src/ssvc/selection.py @@ -22,7 +22,7 @@ # DM24-0278 from datetime import datetime -from typing import Optional +from typing import Literal, Optional from pydantic import BaseModel, Field @@ -51,8 +51,12 @@ class MinimalSelection(BaseModel): version: VersionField values: list[str] = Field( ..., - description="A list of selected values keys from the decision point values.", + description="A list of selected value keys from the decision point values.", min_length=1, + examples=[ + ["N", "Y"], + ["A", "B", "C"], + ], # Example values ) @@ -61,21 +65,26 @@ class MinimalSelectionList(BaseModel): A down-selection of SSVC Decision Points that represent an evaluation at a specific time of a Vulnerability evaluation. """ - schemaVersion: str = Field( - "2.0.0", description="The schema version of this selection list." + schemaVersion: Literal["2.0.0"] = Field( + default="2.0.0", + description="The schema version of this selection list.", ) vulnerability_id: Optional[str] = Field( default=None, description="Optional vulnerability ID associated with the selections.", examples=["CVE-2025-0000", "VU#999999", "GHSA-0123-4567-89ab"], + min_length=1, ) selections: list[MinimalSelection] = Field( - default_factory=list, + ..., description="List of minimal selections made from decision points.", + min_length=1, ) timestamp: Optional[datetime] = Field( - default=None, description="Timestamp of when the selections were made." + ..., + description="Timestamp of when the selections were made, in ISO 8601 format.", + examples=["2025-01-01T12:00:00Z", "2025-01-02T15:30:45Z"], ) def add_selection(self, selection: MinimalSelection) -> None: @@ -119,9 +128,11 @@ def main() -> None: from ssvc.decision_points.ssvc.safety_impact import LATEST as dp2 import json - selections = MinimalSelectionList() - selections.add_selection(selection_from_decision_point(dp1)) - selections.add_selection(selection_from_decision_point(dp2)) + a1 = selection_from_decision_point(dp1) + a2 = selection_from_decision_point(dp2) + selections = MinimalSelectionList( + schemaVersion="2.0.0", selections=[a1, a2], timestamp=datetime.now() + ) selections.timestamp = datetime.now() print(selections.model_dump_json(indent=2, exclude_none=True)) @@ -133,11 +144,14 @@ def main() -> None: schema.pop("title") schema["$schema"] = "https://json-schema.org/draft/2020-12/schema" schema["$id"] = ( - "https://certcc.github.io/SSVC/data/schema/v1/Decision_Point_Value_Selection-1-0-1.schema.json" + "https://certcc.github.io/SSVC/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json" ) schema["description"] = ( "This schema defines the structure for selecting SSVC Decision Points and their evaluated values for a given vulnerability. Each vulnerability can have multiple Decision Points, and each Decision Point can have multiple selected values when full certainty is not available." ) + # force the schema version to be included in the required fields + # even though we set a default value + schema["required"].insert(0, "schemaVersion") print(json.dumps(schema, indent=2)) From 82947e2dad79d6cc6af33e2d3fed5c4dab7a5aa8 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Tue, 15 Jul 2025 16:45:30 -0400 Subject: [PATCH 12/99] we don't need decision point id --- src/ssvc/decision_points/base.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/ssvc/decision_points/base.py b/src/ssvc/decision_points/base.py index 57b989f6..69e42d96 100644 --- a/src/ssvc/decision_points/base.py +++ b/src/ssvc/decision_points/base.py @@ -187,10 +187,6 @@ class DecisionPoint( def __str__(self): return FIELD_DELIMITER.join([self.namespace, self.key, self.version]) - @property - def id(self): - return ":".join([self.namespace, self.key, self.version]) - @property def str(self) -> str: """ From 4b64e6b97c323e1c3fe7bd33adb83f9a9c1fc3e2 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Wed, 16 Jul 2025 10:17:06 -0400 Subject: [PATCH 13/99] refactor schema version to variable --- src/ssvc/selection.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/ssvc/selection.py b/src/ssvc/selection.py index 2ea3f6ef..07c51a82 100644 --- a/src/ssvc/selection.py +++ b/src/ssvc/selection.py @@ -29,6 +29,8 @@ from ssvc._mixins import VersionField from ssvc.decision_points.base import DecisionPoint +SCHEMA_VERSION = "2.0.0" + class MinimalSelection(BaseModel): """ @@ -65,8 +67,8 @@ class MinimalSelectionList(BaseModel): A down-selection of SSVC Decision Points that represent an evaluation at a specific time of a Vulnerability evaluation. """ - schemaVersion: Literal["2.0.0"] = Field( - default="2.0.0", + schemaVersion: Literal[SCHEMA_VERSION] = Field( + default=SCHEMA_VERSION, description="The schema version of this selection list.", ) From 7c6c2a17c5b1b43fb3f6fca04e217a13357ad32b Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Wed, 16 Jul 2025 10:19:43 -0400 Subject: [PATCH 14/99] compute path relative to module location --- src/ssvc/selection.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/ssvc/selection.py b/src/ssvc/selection.py index 07c51a82..7ae3dfb7 100644 --- a/src/ssvc/selection.py +++ b/src/ssvc/selection.py @@ -157,9 +157,15 @@ def main() -> None: print(json.dumps(schema, indent=2)) + # find local path to this file + import os + + current_dir = os.path.dirname(os.path.abspath(__file__)) + # construct the path to the schema file schema_path = ( "../../data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json" ) + schema_path = os.path.abspath(os.path.join(current_dir, schema_path)) with open(schema_path, "w") as f: print(f"Writing schema to {schema_path}") From 3b73b3d559abec430bff9edeed53fa2b7d6a40f3 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Wed, 16 Jul 2025 10:25:51 -0400 Subject: [PATCH 15/99] fix some redundancy based on copilot PR review --- .../v2/Decision_Point_Value_Selection-2-0-0.schema.json | 2 +- src/ssvc/selection.py | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json index ef75dd52..ff898f56 100644 --- a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json +++ b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json @@ -118,7 +118,7 @@ "description": "Timestamp of when the selections were made, in ISO 8601 format.", "examples": [ "2025-01-01T12:00:00Z", - "2025-01-02T15:30:45Z" + "2025-01-02T15:30:45-04:00" ], "title": "Timestamp" } diff --git a/src/ssvc/selection.py b/src/ssvc/selection.py index 7ae3dfb7..dbebba7b 100644 --- a/src/ssvc/selection.py +++ b/src/ssvc/selection.py @@ -86,7 +86,7 @@ class MinimalSelectionList(BaseModel): timestamp: Optional[datetime] = Field( ..., description="Timestamp of when the selections were made, in ISO 8601 format.", - examples=["2025-01-01T12:00:00Z", "2025-01-02T15:30:45Z"], + examples=["2025-01-01T12:00:00Z", "2025-01-02T15:30:45-04:00"], ) def add_selection(self, selection: MinimalSelection) -> None: @@ -133,9 +133,8 @@ def main() -> None: a1 = selection_from_decision_point(dp1) a2 = selection_from_decision_point(dp2) selections = MinimalSelectionList( - schemaVersion="2.0.0", selections=[a1, a2], timestamp=datetime.now() + schemaVersion=SCHEMA_VERSION, selections=[a1, a2], timestamp=datetime.now() ) - selections.timestamp = datetime.now() print(selections.model_dump_json(indent=2, exclude_none=True)) From 36240545528612941ca4d001a6ec6b1502738933 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Wed, 16 Jul 2025 11:49:33 -0400 Subject: [PATCH 16/99] refactor namespace and version specifications for modularity --- src/ssvc/_mixins.py | 9 ++++--- src/ssvc/namespaces.py | 60 +++++++++++++++++++++++++++++------------ src/test/test_mixins.py | 6 ++--- 3 files changed, 52 insertions(+), 23 deletions(-) diff --git a/src/ssvc/_mixins.py b/src/ssvc/_mixins.py index 30c82528..b8c69d94 100644 --- a/src/ssvc/_mixins.py +++ b/src/ssvc/_mixins.py @@ -27,14 +27,17 @@ from semver import Version from ssvc import _schemaVersion -from ssvc.namespaces import NS_PATTERN, NameSpace +from ssvc.namespaces import NameSpace, NamespaceString + +VERSION_PATTERN = r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$" + VersionField = Annotated[ str, Field( description="The version of the SSVC object. This should be a valid semantic version string.", examples=["1.0.0", "2.1.3"], - pattern=r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$", + pattern=VERSION_PATTERN, min_length=5, ), ] @@ -80,7 +83,7 @@ class _Namespaced(BaseModel): # the field definition enforces the pattern for namespaces # additional validation is performed in the field_validator immediately after the pattern check - namespace: str = Field(pattern=NS_PATTERN, min_length=3, max_length=100) + namespace: NamespaceString @field_validator("namespace", mode="before") @classmethod diff --git a/src/ssvc/namespaces.py b/src/ssvc/namespaces.py index 56cb3ee4..6ae3fc59 100644 --- a/src/ssvc/namespaces.py +++ b/src/ssvc/namespaces.py @@ -25,41 +25,67 @@ import re from enum import StrEnum, auto +from typing import Annotated + +from pydantic import Field X_PFX = "x_" """The prefix for extension namespaces. Extension namespaces must start with this prefix.""" +MIN_NS_LENGTH = 3 +MAX_NS_LENGTH = 1000 +NS_LENGTH_INTERVAL = MAX_NS_LENGTH - MIN_NS_LENGTH + +LENGTH_CHECK_PATTERN = rf"(?=.{{{MIN_NS_LENGTH},{MAX_NS_LENGTH}}}$)" +"""Ensures the string is between MIN_NS_LENGTH and MAX_NS_LENGTH characters long.""" + +PREFIX_CHECK_PATTERN = rf"(x_)?[a-z0-9]{{{MIN_NS_LENGTH}}}" +"""Ensures the string starts with an optional prefix followed by at least 3 alphanumeric characters.""" + +REMAINDER_CHECK_PATTERN = rf"([/.-]?[a-z0-9]+){{0,{NS_LENGTH_INTERVAL}}}$" +"""Ensures that the string contains only lowercase alphanumeric characters and limited punctuation characters (`/`, `.`, `-`),""" + + # pattern to match -# `(?=.{3,100}$)`: 3-25 characters long -# `^(x_)`: `x_` prefix is optional -# `[a-z0-9]{3,4}`: must start with 3-4 alphanumeric characters -# `[/.-]?`: only one punctuation character is allowed between alphanumeric characters -# `[a-z0-9]+`: at least one alphanumeric character is required after the punctuation character -# `([/.-]?[a-z0-9]+){0,22}`: zero to 22 occurrences of the punctuation character followed by at least one alphanumeric character -# (note that the total limit will kick in at or before this point) -# `$`: end of the string -NS_PATTERN = re.compile(r"^(?=.{3,100}$)(x_)?[a-z0-9]{3}([/.-]?[a-z0-9]+){0,97}$") -"""The regular expression pattern for validating namespaces. - -Note: +# NOTE: be careful with this regex. We're using f-strings to insert the min and max lengths, so we need to ensure that +# literal { and } characters are escaped properly (doubled up) so they appear in as single braces in the final regex. +NS_PATTERN = re.compile( + rf"^{LENGTH_CHECK_PATTERN}{PREFIX_CHECK_PATTERN}{REMAINDER_CHECK_PATTERN}$" +) +f"""The regular expression pattern for validating namespaces. + +!!! note "Namespace Validation Rules" + Namespace values must - - be 3-25 characters long + - be {MIN_NS_LENGTH}-{MAX_NS_LENGTH} characters long - contain only lowercase alphanumeric characters and limited punctuation characters (`/`,`.` and `-`) - have only one punctuation character in a row - - start with 3-4 alphanumeric characters after the optional extension prefix + - start with 3 alphanumeric characters after the optional extension prefix - end with an alphanumeric character - See examples in the `NameSpace` enum. """ +NamespaceString = Annotated[ + str, + Field( + description="The namespace of the SSVC object.", + examples=["ssvc", "cisa", "x_private-test", "ssvc/de-DE/reference-arch-1"], + pattern=NS_PATTERN, + min_length=MIN_NS_LENGTH, + max_length=MAX_NS_LENGTH, + ), +] +"""A string datatype for namespace values, for use in Pydantic models.""" + class NameSpace(StrEnum): - """ + f""" Defines the official namespaces for SSVC. The namespace value must be one of the members of this enum or start with the prefix specified in X_PFX. - Namespaces must be 3-25 lowercase characters long and must start with 3-4 alphanumeric characters after the optional prefix. + Namespaces must be {MIN_NS_LENGTH}-{MAX_NS_LENGTH} lowercase characters long and must start with 3-4 + alphanumeric characters after the optional prefix. Limited punctuation characters (/.-) are allowed between alphanumeric characters, but only one at a time. Example: diff --git a/src/test/test_mixins.py b/src/test/test_mixins.py index c4724c1c..5f7b42a3 100644 --- a/src/test/test_mixins.py +++ b/src/test/test_mixins.py @@ -23,7 +23,7 @@ from pydantic import BaseModel, ValidationError from ssvc._mixins import _Base, _Keyed, _Namespaced, _Valued, _Versioned -from ssvc.namespaces import NameSpace +from ssvc.namespaces import MAX_NS_LENGTH, NameSpace class TestMixins(unittest.TestCase): @@ -92,12 +92,12 @@ def test_namespaced_create_errors(self): _Namespaced(namespace="x_") # error if namespace starts with x_ but is too long - for i in range(150): + for i in range(MAX_NS_LENGTH + 50): shortest = "x_aaa" ns = shortest + "a" * i with self.subTest(ns=ns): # length limit set in the NS_PATTERN regex - if len(ns) <= 100: + if len(ns) <= MAX_NS_LENGTH: # expect success on shorter than limit _Namespaced(namespace=ns) else: From f690df46560eb687cbaad134bfda07856c6d5825 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Wed, 16 Jul 2025 11:49:48 -0400 Subject: [PATCH 17/99] add unit tests --- src/ssvc/selection.py | 5 +- src/test/test_selections.py | 113 ++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 3 deletions(-) create mode 100644 src/test/test_selections.py diff --git a/src/ssvc/selection.py b/src/ssvc/selection.py index dbebba7b..3495bb48 100644 --- a/src/ssvc/selection.py +++ b/src/ssvc/selection.py @@ -28,6 +28,7 @@ from ssvc._mixins import VersionField from ssvc.decision_points.base import DecisionPoint +from ssvc.namespaces import NamespaceString SCHEMA_VERSION = "2.0.0" @@ -38,11 +39,9 @@ class MinimalSelection(BaseModel): This is used to transition from an SSVC decision point to a selection. """ - namespace: str = Field( + namespace: NamespaceString = Field( ..., description="The namespace of the decision point.", - examples=["ssvc", "cisa", "certcc"], - min_length=3, ) key: str = Field( ..., diff --git a/src/test/test_selections.py b/src/test/test_selections.py new file mode 100644 index 00000000..d4d0bf59 --- /dev/null +++ b/src/test/test_selections.py @@ -0,0 +1,113 @@ +# Copyright (c) 2025 Carnegie Mellon University. +# NO WARRANTY. THIS CARNEGIE MELLON UNIVERSITY AND SOFTWARE +# ENGINEERING INSTITUTE MATERIAL IS FURNISHED ON AN "AS-IS" BASIS. +# CARNEGIE MELLON UNIVERSITY MAKES NO WARRANTIES OF ANY KIND, +# EITHER EXPRESSED OR IMPLIED, AS TO ANY MATTER INCLUDING, BUT +# NOT LIMITED TO, WARRANTY OF FITNESS FOR PURPOSE OR +# MERCHANTABILITY, EXCLUSIVITY, OR RESULTS OBTAINED FROM USE +# OF THE MATERIAL. CARNEGIE MELLON UNIVERSITY DOES NOT MAKE +# ANY WARRANTY OF ANY KIND WITH RESPECT TO FREEDOM FROM +# PATENT, TRADEMARK, OR COPYRIGHT INFRINGEMENT. +# Licensed under a MIT (SEI)-style license, please see LICENSE or contact +# permission@sei.cmu.edu for full terms. +# [DISTRIBUTION STATEMENT A] This material has been approved for +# public release and unlimited distribution. Please see Copyright notice +# for non-US Government use and distribution. +# This Software includes and/or makes use of Third-Party Software each +# subject to its own license. +# DM24-0278 + +import unittest +from datetime import datetime + +from ssvc import selection +from ssvc._mixins import VERSION_PATTERN +from ssvc.namespaces import NS_PATTERN +from ssvc.selection import MinimalSelectionList + + +class MyTestCase(unittest.TestCase): + def setUp(self): + self.s1 = selection.MinimalSelection( + namespace="x_test-namespace", + key="test_key_1", + version="1.0.0", + values=["value11", "value12"], + ) + self.s2 = selection.MinimalSelection( + namespace="x_test-namespace", + key="test_key_2", + version="1.0.0", + values=["value21", "value22"], + ) + self.selections = MinimalSelectionList( + selections=[self.s1, self.s2], timestamp=datetime.now() + ) + + def test_minimal_selection_init(self): + required_attrs = [ + "namespace", + "key", + "version", + "values", + ] + for attr in required_attrs: + self.assertTrue(hasattr(self.s1, attr), f"Attribute {attr} is missing") + # namespace is a valid NamespaceString + self.assertIsInstance(self.s1.namespace, str) + self.assertRegex( + self.s1.namespace, + NS_PATTERN, + "Namespace does not match the required pattern", + ) + + # key is a string + self.assertIsInstance(self.s1.key, str) + self.assertGreater(len(self.s1.key), 0, "Key should not be empty") + # version is a valid VersionField + self.assertIsInstance(self.s1.version, str) + self.assertRegex( + self.s1.version, + VERSION_PATTERN, + "Version does not match the required pattern", + ) + + # values is list of strings + self.assertIsInstance(self.s1.values, list) + for value in self.s1.values: + self.assertIsInstance(value, str, f"Value {value} is not a string") + + def test_minimal_selection_list_init(self): + required_attrs = [ + "schemaVersion", + "selections", + "timestamp", + ] + for attr in required_attrs: + self.assertTrue( + hasattr(self.selections, attr), f"Attribute {attr} is missing" + ) + + # schemaVersion is a string + self.assertIsInstance(self.selections.schemaVersion, str) + self.assertEqual( + self.selections.schemaVersion, + selection.SCHEMA_VERSION, + "Schema version does not match the expected value", + ) + self.assertRegex(self.selections.schemaVersion, VERSION_PATTERN) + + # vulnerability_id is optional and can be None or a string + self.assertIsInstance(self.selections.vulnerability_id, (str, type(None))) + + # selections is a list of MinimalSelection objects + self.assertIsInstance(self.selections.selections, list) + for sel in self.selections.selections: + self.assertIsInstance(sel, selection.MinimalSelection) + + # timestamp is a datetime object + self.assertIsInstance(self.selections.timestamp, datetime) + + +if __name__ == "__main__": + unittest.main() From 405f9d2df42762658e29f66aef38a6b906b087f3 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Wed, 16 Jul 2025 11:54:41 -0400 Subject: [PATCH 18/99] add namespace pattern and update examples --- .../v2/Decision_Point_Value_Selection-2-0-0.schema.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json index ff898f56..3efdb287 100644 --- a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json +++ b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json @@ -8,9 +8,12 @@ "examples": [ "ssvc", "cisa", - "certcc" + "x_private-test", + "ssvc/de-DE/reference-arch-1" ], + "maxLength": 1000, "minLength": 3, + "pattern": "^(?=.{3,1000}$)(x_)?[a-z0-9]{3}([/.-]?[a-z0-9]+){0,997}$$", "title": "Namespace", "type": "string" }, From 3993da4366d245b7edd9a53d38b033bd00cd687b Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Thu, 17 Jul 2025 12:20:06 -0400 Subject: [PATCH 19/99] work in progress commit. Not quite working yet --- src/ssvc/namespaces.py | 53 ++++-- src/ssvc/test/test_namespaces_pattern.py | 197 +++++++++++++++++++++++ 2 files changed, 238 insertions(+), 12 deletions(-) create mode 100644 src/ssvc/test/test_namespaces_pattern.py diff --git a/src/ssvc/namespaces.py b/src/ssvc/namespaces.py index 6ae3fc59..93414dc7 100644 --- a/src/ssvc/namespaces.py +++ b/src/ssvc/namespaces.py @@ -36,21 +36,40 @@ MAX_NS_LENGTH = 1000 NS_LENGTH_INTERVAL = MAX_NS_LENGTH - MIN_NS_LENGTH + +# from https://docs.oasis-open.org/csaf/csaf/v2.0/os/csaf-v2.0-os.html +BCP_47_PATTERN = r"(([A-Za-z]{2,3}(-[A-Za-z]{3}(-[A-Za-z]{3}){0,2})?|[A-Za-z]{4,8})(-[A-Za-z]{4})?(-([A-Za-z]{2}|[0-9]{3}))?(-([A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3}))*(-[A-WY-Za-wy-z0-9](-[A-Za-z0-9]{2,8})+)*(-[Xx](-[A-Za-z0-9]{1,8})+)?|[Xx](-[A-Za-z0-9]{1,8})+|[Ii]-[Dd][Ee][Ff][Aa][Uu][Ll][Tt]|[Ii]-[Mm][Ii][Nn][Gg][Oo])" + LENGTH_CHECK_PATTERN = rf"(?=.{{{MIN_NS_LENGTH},{MAX_NS_LENGTH}}}$)" """Ensures the string is between MIN_NS_LENGTH and MAX_NS_LENGTH characters long.""" -PREFIX_CHECK_PATTERN = rf"(x_)?[a-z0-9]{{{MIN_NS_LENGTH}}}" -"""Ensures the string starts with an optional prefix followed by at least 3 alphanumeric characters.""" +# Base namespace part (before any extensions) allows . and - with restrictions +BASE_PATTERN = ( + r"(?!.*[.-]{2,})" # no consecutive separators + r"[a-z][a-z0-9]{2,}" # first part starts with a letter, followed by one or more alphanumeric characters + r"(?:[.-][a-z0-9]+)*" # remaining parts can have alphanumeric characters and single . or - separators +) + +X_PFX = "x_" +EXPERIMENTAL_BASE = rf"{X_PFX}{BASE_PATTERN}" +BASE_NS_PATTERN = rf"({EXPERIMENTAL_BASE}|{BASE_PATTERN})" + +# Extension segment pattern (alphanumeric + limited punctuation, no consecutive punctuation, ends with alphanumeric) +EXT_SEGMENT_PATTERN = ( + r"(?!.*[.-]{2,})" # no consecutive separators + r"[a-zA-Z0-9]+" # first part starts with a letter, followed by one or more alphanumeric characters + r"(?:[.-][a-zA-Z0-9]+)*" # remaining parts can have alphanumeric characters and single ., -, / separators +) -REMAINDER_CHECK_PATTERN = rf"([/.-]?[a-z0-9]+){{0,{NS_LENGTH_INTERVAL}}}$" -"""Ensures that the string contains only lowercase alphanumeric characters and limited punctuation characters (`/`, `.`, `-`),""" +# Language extension pattern (BCP-47 or empty for //) +LANG_EXT_PATTERN = rf"(/({BCP_47_PATTERN})|/)" +# Subsequent extension segments +SUBSEQUENT_EXT_PATTERN = rf"(/{EXT_SEGMENT_PATTERN})*" -# pattern to match -# NOTE: be careful with this regex. We're using f-strings to insert the min and max lengths, so we need to ensure that -# literal { and } characters are escaped properly (doubled up) so they appear in as single braces in the final regex. +# Complete pattern with length validation NS_PATTERN = re.compile( - rf"^{LENGTH_CHECK_PATTERN}{PREFIX_CHECK_PATTERN}{REMAINDER_CHECK_PATTERN}$" + rf"^{LENGTH_CHECK_PATTERN}{BASE_NS_PATTERN}({LANG_EXT_PATTERN}{SUBSEQUENT_EXT_PATTERN})?$" ) f"""The regular expression pattern for validating namespaces. @@ -59,10 +78,20 @@ Namespace values must - be {MIN_NS_LENGTH}-{MAX_NS_LENGTH} characters long - - contain only lowercase alphanumeric characters and limited punctuation characters (`/`,`.` and `-`) - - have only one punctuation character in a row - - start with 3 alphanumeric characters after the optional extension prefix - - end with an alphanumeric character + - optionally start with the experimental/private prefix `{X_PFX}` + - after the optional experimental/private prefix, they must: + - start with a letter + - contain at least 3 alphanumeric characters (longer is permitted) + - contain only lowercase alphanumeric characters and limited punctuation characters (`.`, `-`) + - extensions are supported and optional, and are delineated by slashes (`/`) + - more than one extension segment is allowed, however: + - the first extension segment, if present, is reserved for a BCP-47 language tag, otherwise it must be empty + - if no BCP-47 tag is present, the first extension segment must be empty (i.e., `//`) + - double slashes (`//`) are *only* permitted in the *first segment* to indicate no BCP-47 tag + - beyond the first extension segment, subsequent segments must: + - contain only alphanumeric characters and limited punctuation characters (`.`, `-`) + - have only one punctuation character in a row (no double dashes or dots) + - end with an alphanumeric character """ diff --git a/src/ssvc/test/test_namespaces_pattern.py b/src/ssvc/test/test_namespaces_pattern.py new file mode 100644 index 00000000..fcb4b995 --- /dev/null +++ b/src/ssvc/test/test_namespaces_pattern.py @@ -0,0 +1,197 @@ +# Copyright (c) 2025 Carnegie Mellon University. +# NO WARRANTY. THIS CARNEGIE MELLON UNIVERSITY AND SOFTWARE +# ENGINEERING INSTITUTE MATERIAL IS FURNISHED ON AN "AS-IS" BASIS. +# CARNEGIE MELLON UNIVERSITY MAKES NO WARRANTIES OF ANY KIND, +# EITHER EXPRESSED OR IMPLIED, AS TO ANY MATTER INCLUDING, BUT +# NOT LIMITED TO, WARRANTY OF FITNESS FOR PURPOSE OR +# MERCHANTABILITY, EXCLUSIVITY, OR RESULTS OBTAINED FROM USE +# OF THE MATERIAL. CARNEGIE MELLON UNIVERSITY DOES NOT MAKE +# ANY WARRANTY OF ANY KIND WITH RESPECT TO FREEDOM FROM +# PATENT, TRADEMARK, OR COPYRIGHT INFRINGEMENT. +# Licensed under a MIT (SEI)-style license, please see LICENSE or contact +# permission@sei.cmu.edu for full terms. +# [DISTRIBUTION STATEMENT A] This material has been approved for +# public release and unlimited distribution. Please see Copyright notice +# for non-US Government use and distribution. +# This Software includes and/or makes use of Third-Party Software each +# subject to its own license. +# DM24-0278 + +import logging +import re +import unittest + +from ssvc.namespaces import ( + BASE_NS_PATTERN, + BASE_PATTERN, + LENGTH_CHECK_PATTERN, + MAX_NS_LENGTH, + MIN_NS_LENGTH, + NS_PATTERN, +) + +logger = logging.getLogger(__name__) + + +class TestNamespacePattern(unittest.TestCase): + def setUp(self): + self.expect_success = [ + "ssvc", + "cisa", + "custom", # not in enum, but valid for the pattern + "x_private-test", # valid namespace with dash + "x_custom", # valid namespace with x_ prefix + "x_custom.with.dots", # valid namespace with x_ prefix and dots + "abc", # not in enum, but valid for the pattern + "x_abc", # valid namespace with x_ prefix + "x_custom//extension", # double slash is okay when it's the first segment + "ssvc/de-DE/reference-arch-1", # valid BCP-47 tag with dashes + "x_test/pl-PL/foo/bar/baz/quux", # valid BCP-47 tag and multiple segments + ] + self.expect_fail = [ + "999", # invalid namespace, numeric only + "99xx", # invalid namespace, numeric prefix + "x__invalid", # invalid namespace, double underscore + "x_-invalid", # invalid namespace, dash after x_ + "x_.invalid", # invalid namespace, dash at end + "x_/foo", # invalid namespace, slash after x_, invalid BCP-47 tag + "x_//foo", # invalid namespace, double slash after x_ + "x_abc/invalid-bcp-47", # not a valid BCP-47 tag + "abc/invalid-bcp-47", # not in enum (but that's ok for the pattern), not a valid BCP-47 tag + "abc/invalid", # not in enum (but that's ok for the pattern), not a valid BCP-47 tag + "x_custom/extension", # not a valid BCP-47 tag + "x_test/not-bcp-47", # not a valid BCP-47 tag + "x_custom/extension/with/multiple/segments/" + + "a" * 990, # exceeds max length + "x_custom.extension.", # ends with punctuation + "x_custom..extension", # double dot + "x_custom/", # ends with slash + "x_custom/extension//", # double slash at end + "x_custom/extension/with//double/slash", # double slash in middle + "x_custom/extension/with..double.dot", # double dot in middle + "x_custom/extension/with--double-dash", # double dash in middle + "ab", # too short + "x_", # too short after prefix + ] + + def test_ns_pattern(self): + + self._test_successes_failures( + NS_PATTERN.pattern, self.expect_fail, self.expect_success + ) + + def test_base_pattern(self): + x_success = [ + "abc", + "contains.dot", + "contains-dash", + "contains-dash-and.dot", + ] + x_fail = [ + "a", # too short + "ab", # too short + "9abc", # starts with a number + "x_foo", # no x_ in base pattern + "contains..double.dot", # double dot + "contains--double-dash", # double dash + "contains_underscore", # underscore not allowed + "contains/slash", # slash not allowed + ".starts.with.dot", # starts with a dot + "-starts-with-dash", # starts with a dash + "/starts-with-slash", # starts with a slash + "_starts-with-underscore", # starts with an underscore + "ends-with-dot.", # ends with a dot + "ends-with-dash-", # ends with a dash + "ends-with-slash/", # ends with a slash + ] + self._test_successes_failures(BASE_PATTERN, x_fail, x_success) + + def test_experimental_base_pattern(self): + x_success = [ + "x_abc", + "x_custom", + "x_custom.with.dots", # dots are allowed in the base pattern + "x_custom-with-dashes", # dashes are allowed in the base pattern + ] + x_fail = [ + "9abc", # does not start with x_ + "x__invalid", # double underscore + "x_-invalid", # dash after x_ + "x_.invalid", # dash at end + "x_9abc", # starts with a number after x_ + "x_abc.", # ends with a dot + "x_abc-", # ends with a dash + "x_abc/", # ends with a slash + "x_/foo", # slashes aren't part of the base pattern + ] + self._test_successes_failures(BASE_NS_PATTERN, x_fail, x_success) + + def test_base_ns_pattern(self): + x_success = [ + "abc", + "x_abc", + "x_custom", + "x_custom.with.dots", # dots are allowed in the base pattern + "x_custom-with-dashes", # dashes are allowed in the base pattern + ] + x_fail = [ + "9abc", # starts with a number + "x__invalid", # double underscore + "x_-invalid", # dash after x_ + "x_.invalid", # dash at end + "x_9abc", # starts with a number after x_ + "x_abc.", # ends with a dot + "x_abc-", # ends with a dash + "x_abc/", # ends with a slash + "x_/foo", # slashes aren't part of the base pattern + ] + self._test_successes_failures(BASE_NS_PATTERN, x_fail, x_success) + + def _test_successes_failures( + self, pattern: str, x_fail: list[str], x_success: list[str] + ): + successes = [] + failures = [] + # if pattern is not anchored, anchor it + if not pattern.startswith("^"): + pattern = "^" + pattern + if not pattern.endswith("$"): + pattern = pattern + "$" + + for ns in x_success: + expected = f"Should match {ns}" + if re.match(pattern, ns) is None: + failures.append(expected) + else: + successes.append(expected) + for ns in x_fail: + expected = f"Should not match {ns}" + if re.match(pattern, ns) is not None: + failures.append(expected) + else: + successes.append(expected) + logger.debug(f"Successes: {successes}") + self.assertFalse(failures) + + def test_length_check_pattern(self): + """ + Test the length check pattern for namespaces. + The pattern should enforce a minimum and maximum length. + """ + min_length = MIN_NS_LENGTH + max_length = MAX_NS_LENGTH + + valid_ns = "x_valid_namespace" + too_short_ns = "x_v" + too_long_ns = "x_" + "a" * (max_length - 2) + + for i in range(0, MIN_NS_LENGTH): + # should fail for lengths less than MIN_NS_LENGTH + ns = "a" * i + self.assertIsNone( + re.match(LENGTH_CHECK_PATTERN, ns), f"Should not match: {ns}" + ) + + +if __name__ == "__main__": + unittest.main() From 494dbbd7193596cff4f3114fbe9a39006e71aa25 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Thu, 17 Jul 2025 15:23:42 -0400 Subject: [PATCH 20/99] updates namespace regex to fit test strings --- ...on_Point_Value_Selection-2-0-0.schema.json | 2 +- src/ssvc/namespaces.py | 68 +++++++++++-------- src/test/test_mixins.py | 2 +- src/test/test_namespaces.py | 5 +- .../test/test_namespaces_pattern.py | 1 + 5 files changed, 47 insertions(+), 31 deletions(-) rename src/{ssvc => }/test/test_namespaces_pattern.py (98%) diff --git a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json index 3efdb287..4a483cc4 100644 --- a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json +++ b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json @@ -13,7 +13,7 @@ ], "maxLength": 1000, "minLength": 3, - "pattern": "^(?=.{3,1000}$)(x_)?[a-z0-9]{3}([/.-]?[a-z0-9]+){0,997}$$", + "pattern": "^(?=.{3,1000}$)((x_(?!.*[.-]{2,})[a-z][a-z0-9]{2,}(?:[.-][a-z0-9]+)*|(?!.*[.-]{2,})[a-z][a-z0-9]{2,}(?:[.-][a-z0-9]+)*))((/((([A-Za-z]{2,3}(-[A-Za-z]{3}(-[A-Za-z]{3}){0,2})?|[A-Za-z]{4,8})(-[A-Za-z]{4})?(-([A-Za-z]{2}|[0-9]{3}))?(-([A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3}))*(-[A-WY-Za-wy-z0-9](-[A-Za-z0-9]{2,8})+)*(-[Xx](-[A-Za-z0-9]{1,8})+)?|[Xx](-[A-Za-z0-9]{1,8})+|[Ii]-[Dd][Ee][Ff][Aa][Uu][Ll][Tt]|[Ii]-[Mm][Ii][Nn][Gg][Oo]))/|//)(?!.*[.-]{2,})[a-zA-Z][a-zA-Z0-9]*(?:[.-][a-zA-Z0-9]+)*(?:/(?!.*[.-]{2,})[a-zA-Z][a-zA-Z0-9]*(?:[.-][a-zA-Z0-9]+)*)*)?$", "title": "Namespace", "type": "string" }, diff --git a/src/ssvc/namespaces.py b/src/ssvc/namespaces.py index 93414dc7..3693c139 100644 --- a/src/ssvc/namespaces.py +++ b/src/ssvc/namespaces.py @@ -29,16 +29,13 @@ from pydantic import Field -X_PFX = "x_" -"""The prefix for extension namespaces. Extension namespaces must start with this prefix.""" - MIN_NS_LENGTH = 3 MAX_NS_LENGTH = 1000 NS_LENGTH_INTERVAL = MAX_NS_LENGTH - MIN_NS_LENGTH - # from https://docs.oasis-open.org/csaf/csaf/v2.0/os/csaf-v2.0-os.html BCP_47_PATTERN = r"(([A-Za-z]{2,3}(-[A-Za-z]{3}(-[A-Za-z]{3}){0,2})?|[A-Za-z]{4,8})(-[A-Za-z]{4})?(-([A-Za-z]{2}|[0-9]{3}))?(-([A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3}))*(-[A-WY-Za-wy-z0-9](-[A-Za-z0-9]{2,8})+)*(-[Xx](-[A-Za-z0-9]{1,8})+)?|[Xx](-[A-Za-z0-9]{1,8})+|[Ii]-[Dd][Ee][Ff][Aa][Uu][Ll][Tt]|[Ii]-[Mm][Ii][Nn][Gg][Oo])" +"""A regular expression pattern for BCP-47 language tags.""" LENGTH_CHECK_PATTERN = rf"(?=.{{{MIN_NS_LENGTH},{MAX_NS_LENGTH}}}$)" """Ensures the string is between MIN_NS_LENGTH and MAX_NS_LENGTH characters long.""" @@ -46,52 +43,69 @@ # Base namespace part (before any extensions) allows . and - with restrictions BASE_PATTERN = ( r"(?!.*[.-]{2,})" # no consecutive separators - r"[a-z][a-z0-9]{2,}" # first part starts with a letter, followed by one or more alphanumeric characters + r"[a-z][a-z0-9]{2,}" # first part starts with a letter, followed by three or more alphanumeric characters r"(?:[.-][a-z0-9]+)*" # remaining parts can have alphanumeric characters and single . or - separators ) +"""The base pattern for namespaces, which must start with a letter and contain at least 3 alphanumeric characters.""" X_PFX = "x_" +"""The prefix for extension namespaces. Extension namespaces must start with this prefix.""" + EXPERIMENTAL_BASE = rf"{X_PFX}{BASE_PATTERN}" +f"""The base pattern for experimental namespaces, which must start with the {X_PFX} prefix, +followed by a string matching the base pattern.""" + BASE_NS_PATTERN = rf"({EXPERIMENTAL_BASE}|{BASE_PATTERN})" +"""The complete base namespace pattern, which allows for experimental namespaces.""" # Extension segment pattern (alphanumeric + limited punctuation, no consecutive punctuation, ends with alphanumeric) EXT_SEGMENT_PATTERN = ( r"(?!.*[.-]{2,})" # no consecutive separators - r"[a-zA-Z0-9]+" # first part starts with a letter, followed by one or more alphanumeric characters + r"[a-zA-Z][a-zA-Z0-9]*" # first part starts with a letter, followed by one or more alphanumeric characters r"(?:[.-][a-zA-Z0-9]+)*" # remaining parts can have alphanumeric characters and single ., -, / separators ) +"""The pattern for extension segments in namespaces, which must start with a letter and contain alphanumeric characters or +limited punctuation characters (., -), with no consecutive punctuation characters allowed.""" # Language extension pattern (BCP-47 or empty for //) -LANG_EXT_PATTERN = rf"(/({BCP_47_PATTERN})|/)" +LANG_EXT_PATTERN = rf"(/({BCP_47_PATTERN})/|//)" +"""The pattern for the first extension segment, which must be either a valid BCP-47 tag or empty (//).""" # Subsequent extension segments -SUBSEQUENT_EXT_PATTERN = rf"(/{EXT_SEGMENT_PATTERN})*" +SUBSEQUENT_EXT_PATTERN = rf"{EXT_SEGMENT_PATTERN}(?:/{EXT_SEGMENT_PATTERN})*" +"""The pattern for subsequent extension segments, which must follow the rules for extension segments, delimited by slashes (/).""" # Complete pattern with length validation NS_PATTERN = re.compile( - rf"^{LENGTH_CHECK_PATTERN}{BASE_NS_PATTERN}({LANG_EXT_PATTERN}{SUBSEQUENT_EXT_PATTERN})?$" + rf"^{LENGTH_CHECK_PATTERN}({BASE_NS_PATTERN})({LANG_EXT_PATTERN}{SUBSEQUENT_EXT_PATTERN})?$" ) -f"""The regular expression pattern for validating namespaces. +f"""The full regular expression pattern for validating namespaces. + +!!! note "Length Requirements" -!!! note "Namespace Validation Rules" + - Namespaces must be between {MIN_NS_LENGTH} and {MAX_NS_LENGTH} characters long. - Namespace values must +!!! note "Base Namespace Requirements" + + - Must start with a lowercase letter + - Must contain at least 3 total characters in the base part (after the optional experimental/private prefix) + - Must contain only lowercase letters, numbers, dots (`.`), and hyphens (`-`) + - Must not contain consecutive dots or hyphens (no `..`, `--`, `.-`, `-.`, `---`, etc.) + - May optionally start with the experimental/private prefix `{X_PFX}`. + +!!! note "Extension Requirements (Optional)" - - be {MIN_NS_LENGTH}-{MAX_NS_LENGTH} characters long - - optionally start with the experimental/private prefix `{X_PFX}` - - after the optional experimental/private prefix, they must: - - start with a letter - - contain at least 3 alphanumeric characters (longer is permitted) - - contain only lowercase alphanumeric characters and limited punctuation characters (`.`, `-`) - - extensions are supported and optional, and are delineated by slashes (`/`) - - more than one extension segment is allowed, however: - - the first extension segment, if present, is reserved for a BCP-47 language tag, otherwise it must be empty - - if no BCP-47 tag is present, the first extension segment must be empty (i.e., `//`) - - double slashes (`//`) are *only* permitted in the *first segment* to indicate no BCP-47 tag - - beyond the first extension segment, subsequent segments must: - - contain only alphanumeric characters and limited punctuation characters (`.`, `-`) - - have only one punctuation character in a row (no double dashes or dots) - - end with an alphanumeric character + - Extensions are optional + - Extensions must be delineated by slashes (`/`) + - If any extension segments are present, the following rules apply: + - The first extension segment, must be a valid BCP-47 language tag or empty (i.e., `//`). + - Subsequent extension segments: + - must start with a letter (upper or lowercase) + - may contain letters, numbers, dots (`.`), and hyphens (`-`) + - must not start or end with a dot or hyphen + - must not contain consecutive dots or hyphens (no `..`, `--`, `.-`, `-.`, `---`, etc.) + - are separated by single forward slashes (`/`) + - multiple extension segments are allowed """ diff --git a/src/test/test_mixins.py b/src/test/test_mixins.py index 5f7b42a3..adfe2973 100644 --- a/src/test/test_mixins.py +++ b/src/test/test_mixins.py @@ -114,7 +114,7 @@ def test_namespaced_create(self): # custom namespaces are allowed as long as they start with x_ for _ in range(100): # we're just fuzzing some random strings here - ns = f"x_{randint(1000,1000000)}" + ns = f"x_a{randint(1000,1000000)}" obj = _Namespaced(namespace=ns) self.assertEqual(obj.namespace, ns) diff --git a/src/test/test_namespaces.py b/src/test/test_namespaces.py index 598de3ed..4f527a88 100644 --- a/src/test/test_namespaces.py +++ b/src/test/test_namespaces.py @@ -34,8 +34,9 @@ def test_ns_pattern(self): "foo", "foo.bar", "foo.bar.baz", - "foo/bar/baz/quux", - "foo.bar/baz.quux", + "foo/jp-JP/bar.baz/quux", + "foo//bar/baz/quux", + "foo.bar//baz.quux", ] should_match.extend([f"x_{ns}" for ns in should_match]) diff --git a/src/ssvc/test/test_namespaces_pattern.py b/src/test/test_namespaces_pattern.py similarity index 98% rename from src/ssvc/test/test_namespaces_pattern.py rename to src/test/test_namespaces_pattern.py index fcb4b995..674a26fc 100644 --- a/src/ssvc/test/test_namespaces_pattern.py +++ b/src/test/test_namespaces_pattern.py @@ -47,6 +47,7 @@ def setUp(self): "x_custom//extension", # double slash is okay when it's the first segment "ssvc/de-DE/reference-arch-1", # valid BCP-47 tag with dashes "x_test/pl-PL/foo/bar/baz/quux", # valid BCP-47 tag and multiple segments + "foo.bar//baz.quux", # valid namespace with x_ prefix and mixed segments ] self.expect_fail = [ "999", # invalid namespace, numeric only From 2b8c5afbcd6b0ef4a41c95245592a7e8b6438749 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Thu, 17 Jul 2025 16:55:34 -0400 Subject: [PATCH 21/99] add namespaces docs --- docs/reference/code/namespaces.md | 300 ++++++++++++++++++++++++++++++ 1 file changed, 300 insertions(+) diff --git a/docs/reference/code/namespaces.md b/docs/reference/code/namespaces.md index bc7ed7b4..63a0464c 100644 --- a/docs/reference/code/namespaces.md +++ b/docs/reference/code/namespaces.md @@ -1,3 +1,303 @@ # SSVC Namespaces +We use namespaces in SSVC to organize the various components of the framework. +The bulk of our work is done in the `ssvc` namespace, which contains the core +decision points for SSVC. + +!!! question "Why does SSVC need namespaces?" + + We want to provide a clear way to differentiate between decision points we + developed as part of the SSVC project, and those that are derived from work + done by other projects. This helps us maintain clarity in our codebase and + to avoid confusion when integrating with other systems or libraries. + +!!! tip "Namespace syntax" + + The syntax for namespaces is `/`, where + + - `base` is the name of the namespace + - `extensions` is an optional set of extensions that can be used to further + specify the decision point. Extensions are delimited by a `/` + + See below for additional details on SSVC namespace extensions. + +!!! note "Namespace Requirements" + + A full namepace string must be between 3 and 1000 characters long. (We recommend + keeping them short ease of use.) + + Further requirements are noted in each section below. + + +## Registered Namespaces + +Registered namespaces appear in the `Namespaces` enum, and are intended to be used as follows: + +- Objects in the `ssvc` namespace are managed by the SSVC + project team. We have complete control over these ones. +- Objects in other explicitly registered namespaces are provided for convenience, + but the SSVC team is not responsible for modifying the content or semantics of + those decision points. + +!!! note "Base Namespace Requirements" + + Base namespaces must start with a letter and contain only lowercase + alphanumeric characters, dots (`.`), and dashes (`-`). + The sole exception is the the `x_` prefix for private namespaces described below. + + Consecutive dots or dashes or combinations thereof are not allowed. + Base namespaces cannot end with a dot or dash. + + For base namespaces only, we chose to use lowercase alphanumeric + characters to ensure consistency and avoid confusion when using namespaces + in code. (Extensions may contain mixed case alphanumeric characters, dots, and dashes.) + +The SSVC project may create, at our discretion, new namespaces to reflect +administrative scope for decision points we choose to include for user convenience. + +!!! example "Potential Standards-based namespaces" + + We may in the future add namespaces when needed to reflect different standards + bodies like `nist`, `iso-iec`, `ietf`, `oasis`, etc. + +!!! question "How do I request a new registered namespace?" + + If you have a suggestion for a new registered namespace, please open an + issue in the [SSVC GitHub repository](https://github.com/CERTCC/SSVC/issues) + and provide a brief description of the namespace and its intended use. + +### Current Registered Namespaces + +```python exec="true" idprefix="" +from ssvc.namespaces import NameSpace + +for ns in NameSpace: + print(f"- {ns.value}") +``` + +### Non-`ssvc` Namespaces + +We use namespaces other than `ssvc` to indicate decision points that are based +externally defined standards, specifications, or relevant projects. +We expect for decision points in these namespaces to be technically compatible +with SSVC, but we do not claim any ownership or responsibility for the +underlying specifications or their semantic content. +Objects in these namespaces are provided for the convenience +of SSVC users to allow them to use these decision points in their SSVC +decision models without needing to implement them from scratch. + +While we are happy to resolve technical issues with these decision points as +technically implemented in the SSVC project, all suggestions for changes to the +underlying specifications or semantic content should be directed to the +maintainers of the respective projects or standards. + +!!! example "The `cvss` namespace" + + We wanted to allow SSVC users to include Common Vulnerability Scoring System + (CVSS) vector elements as [decision points](../decision_points/cvss/index.md) + in their SSVC decision models. + So we created the `cvss` namespace to contain + [decision points](../decision_points/cvss/index.md) that are + based on various versions of the CVSS. These + [decision points](../decision_points/cvss/index.md) are provided + as part of the SSVC project for convenience, but we do not maintain the + underlying CVSS specifications, their semantic content or their implementations. + Suggestions for changes to the CVSS specifications should be directed to the + [FIRST CVSS Special Interest Group](https://www.first.org/cvss/) (SIG). + + +## Private / Experimental Namespaces + +Private and experimental namespaces may prepend a prefix `x_` to a namespace +to an otherwise valid namespace string to create private decision points that +are not intended to be shared outside of a specific scope, e.g., for internal +use only. + +The SSVC project does not manage namespaces with the `x_` prefix, so +collisions may occur across organizations who develop their own private SSVC +namespaces. + +!!! example "OT Monitoring Service (OTMS) Private Namespace" + + Organization A creates a set of decision points for testing purposes and + uses the `x_test` namespace. They do not intend to share these decision + points with anyone outside of their organization, so they use the `x_` + prefix to indicate that this namespace is private to them. + + Organization B also creates a set of decision points for testing purposes + and uses the same `x_test` namespace. They also do not intend to share + these decision points with anyone outside of their organization. + +!!! warning "Namespace Conflicts" + + Conflicts are possible in the x_ prefix space. + In the previous example, Organizations A and B could both choose to use + `x_test`, and there are no guarantees of global uniqueness for the + decision points in the `x_test` namespace. + +!!! tip "Private vs Extension Namespaces" + + Private namespaces are intended for internal use only and are not registered + with the SSVC project. They are not intended to be shared or used outside of + the organization that created them. In contrast, extension namespaces are + intended to extend the existing SSVC namespaces and may be shared with other + users of the SSVC framework. + +## Namespace Extensions + +We allow users to extend the SSVC namespaces to clarify existing decision +points or to add new decision points that are compatible with the SSVC framework. +The intent of an extension is to allow clarification of the application of +decision points and their values to specific constituencies. + +- Extensions must not alter the decision point key, version number, or value keys + for any decision point they are derived from. +- Extensions must not alter the meaning of existing values, or add values to + existing decision points in the parent namespace. +- Extensions may reduce the set of values for a decision point in the parent + namespace, but must not add new values. + + +!!! info "Namespace Extension Syntax and Structure" + + Extension strings may contain alphanumeric characters (upper or lower case), + dots (`.`), and dashes (`-`). + Multiple extension segments are separated by a `/` character. + + The structure of the namespace string is intended to show inheritance for + variations on SSVC objects. + + Extension order matters. `ssvc/de-DE/ref-arch-1` would describe an extension + for `ref-arch-1` derived from the German (Germany) translation of SSVC. + `ssvc/ref-arch-1/de-DE` would denote an extension of SSVC for `ref-arch-1` + (in English) that had subsequently been translated in to German (Germany). + + +!!! note "First Extension Segment Reserved for Language Tag" + + The first extension segment is reserved for a language tag, which is + optional but recommended. + This allows users to specify the language of extension, making it easier to + understand and use in different linguistic contexts. + + If *any* extensions are present, the first extension segment must be an + (optionally empty) + [BCP-47](https://www.rfc-editor.org/rfc/bcp/bcp47.txt) language tag. + E.g., `ssvc/jp-JP/extension` + + The language may be left empty in which case the default language (`en-US`) is + implied. An unspecified language tag will result in a `//` format. + + The use of a language tag in the first segment is intended to be used to + indicate translations of entire sets of decision points. + +!!! example "Translation and Localization" + + `ssvc/de-DE` might denote a German translation of the corresponding `ssvc` object. + +!!! example "Refinement of Concepts for a Specific Constituency" + + A sector-specific information sharing and analysis organization (ISAO) + might create an extension for their specific constituency. + For example, say that namespace foo has a decision point for + Regulated System=(Y,N). A medical-focused ISAO might create an extension + `foo//example.med-isao` where they refine the values to refer to specific + regulations. If multiple regulatory regimes exist, they might even have + `foo//example.med-isao/regulation-1` and `foo//example.med-isao/regulation-2` + to cover assessment of the appropriate regulations. + + +### Usage Suggestions + +Although we reserved the first segment of the extension for language tags, +there are scenarios where it may be appropriate to use a language tag in a later +segment of the extension. + +!!! tip "Use BCP-47 Language Tags" + + Regardless where they appear in the extension strings, we recommend using + BCP-47 strings for any language-based extension. Note, however that we do not + strictly enforce this recommendation in the SSVC codebase outside of the + first segment. + +!!! example "Translation of a custom extension" + + If you have a custom extension that is not a translation of an existing + decision point, you might use a language tag in a later segment to indicate + a translation of the extension. + For example, `ssvc//com.example/extension/pl-PL` would indicate that the + an extension in the default `en-US` language has been translated to Polish (Poland). + +!!! tip "Use Reverse Domain Name Notation for Extensions" + + To avoid conflicts with other users' extensions, we recommend using reverse + domain name notation for your extensions. This helps to ensure that your + extensions are unique and easily identifiable. + +!!! example "Reverse Domain Name Notation" + + If your organization has a domain name, you can use it as the base for your + extension. This helps to ensure that your extensions are unique and easily + identifiable. + + For example, if your organization is `example.com`, you might use an extension + like `ssvc//com.example/extension`. + + +## Technical requirements + +The following technical requirements are enforced for SSVC namespaces, +based on the implementation in `src/ssvc/namespaces.py` and the NS_PATTERN regular expression: + +```python exec="true" idprefix="" +from ssvc.namespaces import NS_PATTERN + +print(f"`{NS_PATTERN.pattern}`") +``` + +- **Length**: Namespaces must be between 3 and 1000 characters long. +- **Base Namespace**: + - Must start with a lowercase letter. + - Must contain at least 3 total characters in the base part (after the optional experimental/private prefix). + - Only lowercase letters, numbers, dots (`.`), and hyphens (`-`) are allowed. + - Must not contain consecutive dots or hyphens (no `..`, `--`, `.-`, `-.`, `---`, etc.). + - Cannot end with a dot or hyphen. + - May optionally start with the experimental/private prefix `x_`. +- **Experimental/Private Namespaces**: + - Must start with `x_` followed by a valid base namespace. +- **Extensions (Optional)**: + - Extensions are optional and must be delineated by slashes (`/`). + - If present, the first extension segment must be a valid BCP-47 language tag or empty (`//`). + - Subsequent extension segments: + - Must start with a letter (upper or lowercase). + - May contain letters, numbers, dots (`.`), and hyphens (`-`). + - Must not start or end with a dot or hyphen. + - Must not contain consecutive dots or hyphens. + - Are separated by single forward slashes (`/`). + - Multiple extension segments are allowed. +- **Examples of valid namespaces**: + - `ssvc` + - `cisa` + - `x_private-test` + - `ssvc/de-DE/reference-arch-1` + - `x_custom//extension` (empty language tag) +- **Examples of invalid namespaces**: + - `custom` (not in enum, no `x_` prefix) + - `x_custom/extension` (first segment must be a language tag) + - `x_custom.extension.` (ends with punctuation) + - `x_custom..extension` (double dot) + - `x_custom/` (ends with slash) + - `x_custom/extension//` (double slash at end) + - `ab` (too short) + - `x_` (too short after prefix) + +These requirements are strictly enforced by the `NS_PATTERN` regular expression +in the codebase. For full details, see the documentation below and +implementation in `src/ssvc/namespaces.py`. + +## The `ssvc.namespaces` module + +The `ssvc.namespaces` module provides a way to access and use these namespaces. + ::: ssvc.namespaces + From 3fbb206697a79de8a1c04ecf308c5f9e2df5a448 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Fri, 18 Jul 2025 15:01:42 -0400 Subject: [PATCH 22/99] Update docs/reference/code/namespaces.md Co-authored-by: tschmidtb51 <65305130+tschmidtb51@users.noreply.github.com> --- docs/reference/code/namespaces.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/code/namespaces.md b/docs/reference/code/namespaces.md index 63a0464c..2ef5801b 100644 --- a/docs/reference/code/namespaces.md +++ b/docs/reference/code/namespaces.md @@ -24,7 +24,7 @@ decision points for SSVC. !!! note "Namespace Requirements" A full namepace string must be between 3 and 1000 characters long. (We recommend - keeping them short ease of use.) + keeping them short for ease of use.) Further requirements are noted in each section below. From 21081576c424881fa17f4c4feb6bc39dd52c853f Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Fri, 18 Jul 2025 15:10:32 -0400 Subject: [PATCH 23/99] fix typo --- docs/reference/code/namespaces.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/reference/code/namespaces.md b/docs/reference/code/namespaces.md index 63a0464c..b1bfa774 100644 --- a/docs/reference/code/namespaces.md +++ b/docs/reference/code/namespaces.md @@ -108,8 +108,8 @@ maintainers of the respective projects or standards. ## Private / Experimental Namespaces -Private and experimental namespaces may prepend a prefix `x_` to a namespace -to an otherwise valid namespace string to create private decision points that +Private and experimental namespaces may prepend a prefix `x_` to +an otherwise valid namespace string to create private decision points that are not intended to be shared outside of a specific scope, e.g., for internal use only. From 5c0b2139a5681780a6ec72ddeeb216603fff74b4 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Fri, 18 Jul 2025 15:23:21 -0400 Subject: [PATCH 24/99] add reverse domain name recommendation for private / experimental namespaces --- docs/reference/code/namespaces.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/reference/code/namespaces.md b/docs/reference/code/namespaces.md index 2cfc7677..9a18edb5 100644 --- a/docs/reference/code/namespaces.md +++ b/docs/reference/code/namespaces.md @@ -115,7 +115,14 @@ use only. The SSVC project does not manage namespaces with the `x_` prefix, so collisions may occur across organizations who develop their own private SSVC -namespaces. +namespaces. + +!!! warning "Reverse domain name notation recommended" + + We strongly recommend using reverse domain name notation for private namespaces to + avoid conflicts with other users' private namespaces. This helps to ensure + that your private namespaces are unique and easily identifiable. + E.g., `x_org.cert-experimental` for an experimental namespace within the CERT organization. !!! example "OT Monitoring Service (OTMS) Private Namespace" @@ -233,6 +240,8 @@ segment of the extension. To avoid conflicts with other users' extensions, we recommend using reverse domain name notation for your extensions. This helps to ensure that your extensions are unique and easily identifiable. + For example, if your organization is `example.com`, you might use an extension + like `ssvc//com.example/extension`. !!! example "Reverse Domain Name Notation" From 85458785c0cc299be92cb2b6aaff725d86982d99 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Fri, 18 Jul 2025 15:23:21 -0400 Subject: [PATCH 25/99] add reverse domain name recommendation for private / experimental namespaces --- docs/reference/code/namespaces.md | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/docs/reference/code/namespaces.md b/docs/reference/code/namespaces.md index 2cfc7677..77a4908b 100644 --- a/docs/reference/code/namespaces.md +++ b/docs/reference/code/namespaces.md @@ -115,7 +115,14 @@ use only. The SSVC project does not manage namespaces with the `x_` prefix, so collisions may occur across organizations who develop their own private SSVC -namespaces. +namespaces. + +!!! warning "Reverse domain name notation recommended" + + We strongly recommend using reverse domain name notation for private namespaces to + avoid conflicts with other users' private namespaces. This helps to ensure + that your private namespaces are unique and easily identifiable. + E.g., `x_org.cert-experimental` for an experimental namespace within the CERT organization. !!! example "OT Monitoring Service (OTMS) Private Namespace" @@ -137,9 +144,15 @@ namespaces. !!! tip "Private vs Extension Namespaces" - Private namespaces are intended for internal use only and are not registered - with the SSVC project. They are not intended to be shared or used outside of - the organization that created them. In contrast, extension namespaces are + Private namespaces are intended for use within a closed scope + and are not registered with the SSVC project. + In other words, they are not intended to be used outside of a + specific constuency. + For example, an organization might create a private namespace for + decision points that are specific to their internal processes or policies. + Or an information sharing and analysis organization (ISAO) might create a + private namespace for decision points that are specific to their sector. + In contrast, extension namespaces are intended to extend the existing SSVC namespaces and may be shared with other users of the SSVC framework. @@ -233,6 +246,8 @@ segment of the extension. To avoid conflicts with other users' extensions, we recommend using reverse domain name notation for your extensions. This helps to ensure that your extensions are unique and easily identifiable. + For example, if your organization is `example.com`, you might use an extension + like `ssvc//com.example/extension`. !!! example "Reverse Domain Name Notation" From c2a5e8495109c9b495c7cea5cb664a1aeb6934fb Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Fri, 18 Jul 2025 15:40:01 -0400 Subject: [PATCH 26/99] add warning about "no adds" in extensions. --- docs/reference/code/namespaces.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/reference/code/namespaces.md b/docs/reference/code/namespaces.md index 77a4908b..878d04c9 100644 --- a/docs/reference/code/namespaces.md +++ b/docs/reference/code/namespaces.md @@ -159,7 +159,7 @@ namespaces. ## Namespace Extensions We allow users to extend the SSVC namespaces to clarify existing decision -points or to add new decision points that are compatible with the SSVC framework. +points. The intent of an extension is to allow clarification of the application of decision points and their values to specific constituencies. @@ -170,6 +170,12 @@ decision points and their values to specific constituencies. - Extensions may reduce the set of values for a decision point in the parent namespace, but must not add new values. +!!! warning "Extensions are not for new decision points" + + Extensions are not intended to be used to create new decision points. + If you want to create a new decision point, please use a + private/experimental namespace as described above + instead of an extension. !!! info "Namespace Extension Syntax and Structure" From 3dae4a94636bf88f53b4cc776346fb2cbfafadcd Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Fri, 18 Jul 2025 15:51:32 -0400 Subject: [PATCH 27/99] add one more test string --- src/test/test_namespaces_pattern.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/test/test_namespaces_pattern.py b/src/test/test_namespaces_pattern.py index 674a26fc..96424469 100644 --- a/src/test/test_namespaces_pattern.py +++ b/src/test/test_namespaces_pattern.py @@ -73,6 +73,7 @@ def setUp(self): "x_custom/extension/with--double-dash", # double dash in middle "ab", # too short "x_", # too short after prefix + "x_x_some-weird-private-one", # double x_ not allowed ] def test_ns_pattern(self): From 628709fc97e78119ba5ecc9986299862aba0a519 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Mon, 21 Jul 2025 10:49:02 -0400 Subject: [PATCH 28/99] we're enforcing version patterns, so should -> must. Also bump default version to 0.0.1 --- src/ssvc/_mixins.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ssvc/_mixins.py b/src/ssvc/_mixins.py index b8c69d94..f80109b5 100644 --- a/src/ssvc/_mixins.py +++ b/src/ssvc/_mixins.py @@ -35,7 +35,7 @@ VersionField = Annotated[ str, Field( - description="The version of the SSVC object. This should be a valid semantic version string.", + description="The version of the SSVC object. This must be a valid semantic version string.", examples=["1.0.0", "2.1.3"], pattern=VERSION_PATTERN, min_length=5, @@ -48,7 +48,7 @@ class _Versioned(BaseModel): Mixin class for versioned SSVC objects. """ - version: VersionField = Field(default="0.0.0") + version: VersionField = Field(default="0.0.1") @field_validator("version") @classmethod From e853f33371121e4ba934590b737d025f7d65f317 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Mon, 21 Jul 2025 11:01:36 -0400 Subject: [PATCH 29/99] fix default version implementation --- src/ssvc/_mixins.py | 3 ++- src/test/test_mixins.py | 11 +++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/ssvc/_mixins.py b/src/ssvc/_mixins.py index f80109b5..a3456d57 100644 --- a/src/ssvc/_mixins.py +++ b/src/ssvc/_mixins.py @@ -29,6 +29,7 @@ from ssvc import _schemaVersion from ssvc.namespaces import NameSpace, NamespaceString +DEFAULT_VERSION = "0.0.1" VERSION_PATTERN = r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$" @@ -48,7 +49,7 @@ class _Versioned(BaseModel): Mixin class for versioned SSVC objects. """ - version: VersionField = Field(default="0.0.1") + version: VersionField = Field(default=DEFAULT_VERSION) @field_validator("version") @classmethod diff --git a/src/test/test_mixins.py b/src/test/test_mixins.py index 5f7b42a3..9ea31446 100644 --- a/src/test/test_mixins.py +++ b/src/test/test_mixins.py @@ -22,7 +22,14 @@ from pydantic import BaseModel, ValidationError -from ssvc._mixins import _Base, _Keyed, _Namespaced, _Valued, _Versioned +from ssvc._mixins import ( + DEFAULT_VERSION, + _Base, + _Keyed, + _Namespaced, + _Valued, + _Versioned, +) from ssvc.namespaces import MAX_NS_LENGTH, NameSpace @@ -120,7 +127,7 @@ def test_namespaced_create(self): def test_versioned_create(self): obj = _Versioned() - self.assertEqual(obj.version, "0.0.0") + self.assertEqual(obj.version, DEFAULT_VERSION) obj = _Versioned(version="1.2.3") self.assertEqual(obj.version, "1.2.3") From 89c1c0b7498bd74b369e52480876894c16d2b1b7 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Mon, 21 Jul 2025 11:02:19 -0400 Subject: [PATCH 30/99] make timestamp required. --- ...cision_Point_Value_Selection-2-0-0.schema.json | 15 ++++----------- src/ssvc/selection.py | 2 +- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json index 3efdb287..a973e753 100644 --- a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json +++ b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json @@ -30,7 +30,7 @@ "type": "string" }, "version": { - "description": "The version of the SSVC object. This should be a valid semantic version string.", + "description": "The version of the SSVC object. This must be a valid semantic version string.", "examples": [ "1.0.0", "2.1.3" @@ -109,21 +109,14 @@ "type": "array" }, "timestamp": { - "anyOf": [ - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], "description": "Timestamp of when the selections were made, in ISO 8601 format.", "examples": [ "2025-01-01T12:00:00Z", "2025-01-02T15:30:45-04:00" ], - "title": "Timestamp" + "format": "date-time", + "title": "Timestamp", + "type": "string" } }, "required": [ diff --git a/src/ssvc/selection.py b/src/ssvc/selection.py index 3495bb48..e3a63ff3 100644 --- a/src/ssvc/selection.py +++ b/src/ssvc/selection.py @@ -82,7 +82,7 @@ class MinimalSelectionList(BaseModel): description="List of minimal selections made from decision points.", min_length=1, ) - timestamp: Optional[datetime] = Field( + timestamp: datetime = Field( ..., description="Timestamp of when the selections were made, in ISO 8601 format.", examples=["2025-01-01T12:00:00Z", "2025-01-02T15:30:45-04:00"], From b2132545f676a7a84ed66577c3debd8bc0809312 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Mon, 21 Jul 2025 11:16:05 -0400 Subject: [PATCH 31/99] force schema order --- ...on_Point_Value_Selection-2-0-0.schema.json | 114 +++++++++--------- src/ssvc/selection.py | 28 ++++- 2 files changed, 82 insertions(+), 60 deletions(-) diff --git a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json index a973e753..5b4c4a6b 100644 --- a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json +++ b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json @@ -1,4 +1,60 @@ { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://certcc.github.io/SSVC/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json", + "description": "This schema defines the structure for selecting SSVC Decision Points and their evaluated values for a given vulnerability. Each vulnerability can have multiple Decision Points, and each Decision Point can have multiple selected values when full certainty is not available.", + "type": "object", + "properties": { + "schemaVersion": { + "const": "2.0.0", + "default": "2.0.0", + "description": "The schema version of this selection list.", + "title": "Schemaversion", + "type": "string" + }, + "vulnerability_id": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional vulnerability ID associated with the selections.", + "examples": [ + "CVE-2025-0000", + "VU#999999", + "GHSA-0123-4567-89ab" + ], + "title": "Vulnerability Id" + }, + "selections": { + "description": "List of minimal selections made from decision points.", + "items": { + "$ref": "#/$defs/MinimalSelection" + }, + "minItems": 1, + "title": "Selections", + "type": "array" + }, + "timestamp": { + "description": "Timestamp of when the selections were made, in ISO 8601 format.", + "examples": [ + "2025-01-01T12:00:00Z", + "2025-01-02T15:30:45-04:00" + ], + "format": "date-time", + "title": "Timestamp", + "type": "string" + } + }, + "required": [ + "schemaVersion", + "selections", + "timestamp" + ], "$defs": { "MinimalSelection": { "description": "A minimal selection object that contains the decision point ID and the selected options.\nThis is used to transition from an SSVC decision point to a selection.", @@ -70,61 +126,5 @@ "title": "MinimalSelection", "type": "object" } - }, - "description": "This schema defines the structure for selecting SSVC Decision Points and their evaluated values for a given vulnerability. Each vulnerability can have multiple Decision Points, and each Decision Point can have multiple selected values when full certainty is not available.", - "properties": { - "schemaVersion": { - "const": "2.0.0", - "default": "2.0.0", - "description": "The schema version of this selection list.", - "title": "Schemaversion", - "type": "string" - }, - "vulnerability_id": { - "anyOf": [ - { - "minLength": 1, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Optional vulnerability ID associated with the selections.", - "examples": [ - "CVE-2025-0000", - "VU#999999", - "GHSA-0123-4567-89ab" - ], - "title": "Vulnerability Id" - }, - "selections": { - "description": "List of minimal selections made from decision points.", - "items": { - "$ref": "#/$defs/MinimalSelection" - }, - "minItems": 1, - "title": "Selections", - "type": "array" - }, - "timestamp": { - "description": "Timestamp of when the selections were made, in ISO 8601 format.", - "examples": [ - "2025-01-01T12:00:00Z", - "2025-01-02T15:30:45-04:00" - ], - "format": "date-time", - "title": "Timestamp", - "type": "string" - } - }, - "required": [ - "schemaVersion", - "selections", - "timestamp" - ], - "type": "object", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://certcc.github.io/SSVC/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json" + } } \ No newline at end of file diff --git a/src/ssvc/selection.py b/src/ssvc/selection.py index e3a63ff3..71b48844 100644 --- a/src/ssvc/selection.py +++ b/src/ssvc/selection.py @@ -24,7 +24,7 @@ from datetime import datetime from typing import Literal, Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field from ssvc._mixins import VersionField from ssvc.decision_points.base import DecisionPoint @@ -66,6 +66,7 @@ class MinimalSelectionList(BaseModel): A down-selection of SSVC Decision Points that represent an evaluation at a specific time of a Vulnerability evaluation. """ + model_config = ConfigDict(extra="allow") schemaVersion: Literal[SCHEMA_VERSION] = Field( default=SCHEMA_VERSION, description="The schema version of this selection list.", @@ -153,7 +154,28 @@ def main() -> None: # even though we set a default value schema["required"].insert(0, "schemaVersion") - print(json.dumps(schema, indent=2)) + # preferred order of fields, just setting for convention + preferred_order = [ + "$schema", + "$id", + "title", + "description", + "schemaVersion", + "type", + "properties", + "required", + "additionalProperties", + "$defs", + ] + + # create a new dict with the preferred order of fields first + ordered_fields = {k: schema[k] for k in preferred_order if k in schema} + # add the rest of the fields in their original order + for k in schema: + if k not in ordered_fields: + ordered_fields[k] = schema[k] + + print(json.dumps(ordered_fields, indent=2)) # find local path to this file import os @@ -167,7 +189,7 @@ def main() -> None: with open(schema_path, "w") as f: print(f"Writing schema to {schema_path}") - json.dump(schema, f, indent=2) + json.dump(ordered_fields, f, indent=2) if __name__ == "__main__": From 8729d51c90bf9c5fc970703b3874a08f4bcacf6a Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Mon, 21 Jul 2025 11:16:35 -0400 Subject: [PATCH 32/99] allow additional properties in selection object --- data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json | 1 + 1 file changed, 1 insertion(+) diff --git a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json index 5b4c4a6b..e041c913 100644 --- a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json +++ b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json @@ -55,6 +55,7 @@ "selections", "timestamp" ], + "additionalProperties": true, "$defs": { "MinimalSelection": { "description": "A minimal selection object that contains the decision point ID and the selected options.\nThis is used to transition from an SSVC decision point to a selection.", From 925d66120615baf1d07cfed624863611a85169f9 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Mon, 21 Jul 2025 11:36:19 -0400 Subject: [PATCH 33/99] fix how we set the schemaVersion --- ...ecision_Point_Value_Selection-2-0-0.schema.json | 1 - src/ssvc/selection.py | 14 +++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json index e041c913..ed0c31a3 100644 --- a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json +++ b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json @@ -6,7 +6,6 @@ "properties": { "schemaVersion": { "const": "2.0.0", - "default": "2.0.0", "description": "The schema version of this selection list.", "title": "Schemaversion", "type": "string" diff --git a/src/ssvc/selection.py b/src/ssvc/selection.py index 71b48844..88398dd3 100644 --- a/src/ssvc/selection.py +++ b/src/ssvc/selection.py @@ -24,7 +24,7 @@ from datetime import datetime from typing import Literal, Optional -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator from ssvc._mixins import VersionField from ssvc.decision_points.base import DecisionPoint @@ -68,7 +68,7 @@ class MinimalSelectionList(BaseModel): model_config = ConfigDict(extra="allow") schemaVersion: Literal[SCHEMA_VERSION] = Field( - default=SCHEMA_VERSION, + ..., description="The schema version of this selection list.", ) @@ -89,6 +89,13 @@ class MinimalSelectionList(BaseModel): examples=["2025-01-01T12:00:00Z", "2025-01-02T15:30:45-04:00"], ) + @model_validator(mode="before") + def set_schema_version(cls, data): + # If schemaVersion is missing, add it + if "schemaVersion" not in data: + data["schemaVersion"] = SCHEMA_VERSION + return data + def add_selection(self, selection: MinimalSelection) -> None: """ Adds a minimal selection to the list. @@ -150,9 +157,6 @@ def main() -> None: schema["description"] = ( "This schema defines the structure for selecting SSVC Decision Points and their evaluated values for a given vulnerability. Each vulnerability can have multiple Decision Points, and each Decision Point can have multiple selected values when full certainty is not available." ) - # force the schema version to be included in the required fields - # even though we set a default value - schema["required"].insert(0, "schemaVersion") # preferred order of fields, just setting for convention preferred_order = [ From 70fce8ea6c53706f822c8d8c24a461f9da5436dc Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Mon, 21 Jul 2025 11:56:07 -0400 Subject: [PATCH 34/99] RFC 3339 is more specific than ISO 8601 --- src/ssvc/selection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ssvc/selection.py b/src/ssvc/selection.py index 88398dd3..e2444821 100644 --- a/src/ssvc/selection.py +++ b/src/ssvc/selection.py @@ -85,7 +85,7 @@ class MinimalSelectionList(BaseModel): ) timestamp: datetime = Field( ..., - description="Timestamp of when the selections were made, in ISO 8601 format.", + description="Timestamp of when the selections were made, in RFC 3339 format.", examples=["2025-01-01T12:00:00Z", "2025-01-02T15:30:45-04:00"], ) From c2673cc33fd6099af22afc9d53d9e09974f5c6fe Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Mon, 21 Jul 2025 12:31:47 -0400 Subject: [PATCH 35/99] adjust example namespaces --- src/ssvc/namespaces.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/ssvc/namespaces.py b/src/ssvc/namespaces.py index 3693c139..459dfdb7 100644 --- a/src/ssvc/namespaces.py +++ b/src/ssvc/namespaces.py @@ -113,7 +113,13 @@ str, Field( description="The namespace of the SSVC object.", - examples=["ssvc", "cisa", "x_private-test", "ssvc/de-DE/reference-arch-1"], + examples=[ + "ssvc", + "cisa", + "x_com.example//private", + "com.example//some-extension", + "ssvc/de-DE/example.org/reference-arch-1", + ], pattern=NS_PATTERN, min_length=MIN_NS_LENGTH, max_length=MAX_NS_LENGTH, From 337bcf1cac4afea41946f0ca167c28d085e9ba85 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Mon, 21 Jul 2025 12:32:50 -0400 Subject: [PATCH 36/99] pattern was blocking 2-letter TLDs in reverse-domain-name convention Also added more test strings --- src/ssvc/namespaces.py | 2 +- src/test/test_namespaces_pattern.py | 24 ++++++++++++++++++------ 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/ssvc/namespaces.py b/src/ssvc/namespaces.py index 459dfdb7..1abc8130 100644 --- a/src/ssvc/namespaces.py +++ b/src/ssvc/namespaces.py @@ -43,7 +43,7 @@ # Base namespace part (before any extensions) allows . and - with restrictions BASE_PATTERN = ( r"(?!.*[.-]{2,})" # no consecutive separators - r"[a-z][a-z0-9]{2,}" # first part starts with a letter, followed by three or more alphanumeric characters + r"[a-z][a-z0-9]+" # first part starts with a letter, followed by one or more alphanumeric characters r"(?:[.-][a-z0-9]+)*" # remaining parts can have alphanumeric characters and single . or - separators ) """The base pattern for namespaces, which must start with a letter and contain at least 3 alphanumeric characters.""" diff --git a/src/test/test_namespaces_pattern.py b/src/test/test_namespaces_pattern.py index 96424469..a87df58d 100644 --- a/src/test/test_namespaces_pattern.py +++ b/src/test/test_namespaces_pattern.py @@ -39,15 +39,26 @@ def setUp(self): "ssvc", "cisa", "custom", # not in enum, but valid for the pattern - "x_private-test", # valid namespace with dash - "x_custom", # valid namespace with x_ prefix - "x_custom.with.dots", # valid namespace with x_ prefix and dots "abc", # not in enum, but valid for the pattern "x_abc", # valid namespace with x_ prefix + "x_custom", # valid namespace with x_ prefix + "x_private-test", # valid namespace with dash + "x_custom.with.dots", # valid namespace with x_ prefix and dots "x_custom//extension", # double slash is okay when it's the first segment - "ssvc/de-DE/reference-arch-1", # valid BCP-47 tag with dashes + "x_private-test", # valid namespace with x_ prefix and dash (does not follow reverse domain notation) + "x_com.example//custom-extension", # x_prefix, reverse domain notation, double slash, dashes + "ssvc/de-DE/example.org/reference-arch-1", # valid BCP-47 tag, reverse domain notation, dashes + "ssvc/de-DE/reference-arch-1", # valid BCP-47 tag with dashes (But doesn't follow reverse domain notation) "x_test/pl-PL/foo/bar/baz/quux", # valid BCP-47 tag and multiple segments - "foo.bar//baz.quux", # valid namespace with x_ prefix and mixed segments + "com.example", # valid namespace with dots following reverse domain notation + "x_com.example", # valid namespace with x_ prefix and dots following reverse domain notation + "au.com.example", # valid namespace with dots following reverse domain notation for 2-letter TLD + "x_au.com.example" # valid namespace with x_ prefix and dots following reverse domain notation + "abc//com.example", # valid namespace with double slash + "abc//com.au.example", + "abc//com.example/foo.bar", # valid namespace with double slash and additional segments + "abc//com.example-foo.bar", # valid namespace with double slash and dash + "foo.bar//baz.quux", ] self.expect_fail = [ "999", # invalid namespace, numeric only @@ -88,10 +99,11 @@ def test_base_pattern(self): "contains.dot", "contains-dash", "contains-dash-and.dot", + "com.example", # valid namespace with dots following reverse domain notation + "au.com.example", # valid namespace with dots following reverse domain notation ] x_fail = [ "a", # too short - "ab", # too short "9abc", # starts with a number "x_foo", # no x_ in base pattern "contains..double.dot", # double dot From b52b8ca91a936903baad03571c6cf292464c99d9 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Mon, 21 Jul 2025 12:35:39 -0400 Subject: [PATCH 37/99] example.org -> example.organization (as in organization.example reversed) to avoid confusion with "example.org" which is also reserved --- src/ssvc/namespaces.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ssvc/namespaces.py b/src/ssvc/namespaces.py index 1abc8130..2ff39529 100644 --- a/src/ssvc/namespaces.py +++ b/src/ssvc/namespaces.py @@ -118,7 +118,7 @@ "cisa", "x_com.example//private", "com.example//some-extension", - "ssvc/de-DE/example.org/reference-arch-1", + "ssvc/de-DE/example.organization/reference-arch-1", ], pattern=NS_PATTERN, min_length=MIN_NS_LENGTH, From 009b8a2fe8592343d872a8f9ffc908ddcbda26dd Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Mon, 21 Jul 2025 13:07:04 -0400 Subject: [PATCH 38/99] refactor defaults, patterns, and types into an ssvc.utils sub-package --- docs/reference/code/namespaces.md | 3 +- src/ssvc/_mixins.py | 22 ++--- src/ssvc/decision_points/base.py | 2 +- src/ssvc/namespaces.py | 103 +--------------------- src/ssvc/selection.py | 5 +- src/ssvc/utils/__init__.py | 20 +++++ src/ssvc/utils/defaults.py | 47 ++++++++++ src/ssvc/utils/patterns.py | 130 ++++++++++++++++++++++++++++ src/ssvc/utils/types.py | 86 ++++++++++++++++++ src/test/test_mixins.py | 4 +- src/test/test_namespaces.py | 3 +- src/test/test_namespaces_pattern.py | 5 +- src/test/test_selections.py | 5 +- 13 files changed, 303 insertions(+), 132 deletions(-) create mode 100644 src/ssvc/utils/__init__.py create mode 100644 src/ssvc/utils/defaults.py create mode 100644 src/ssvc/utils/patterns.py create mode 100644 src/ssvc/utils/types.py diff --git a/docs/reference/code/namespaces.md b/docs/reference/code/namespaces.md index 878d04c9..50cdf330 100644 --- a/docs/reference/code/namespaces.md +++ b/docs/reference/code/namespaces.md @@ -271,7 +271,8 @@ The following technical requirements are enforced for SSVC namespaces, based on the implementation in `src/ssvc/namespaces.py` and the NS_PATTERN regular expression: ```python exec="true" idprefix="" -from ssvc.namespaces import NS_PATTERN + +from ssvc.utils.patterns import NS_PATTERN print(f"`{NS_PATTERN.pattern}`") ``` diff --git a/src/ssvc/_mixins.py b/src/ssvc/_mixins.py index a3456d57..5039ab55 100644 --- a/src/ssvc/_mixins.py +++ b/src/ssvc/_mixins.py @@ -21,27 +21,15 @@ # subject to its own license. # DM24-0278 -from typing import Annotated, Optional +from typing import Optional from pydantic import BaseModel, ConfigDict, Field, field_validator from semver import Version from ssvc import _schemaVersion -from ssvc.namespaces import NameSpace, NamespaceString - -DEFAULT_VERSION = "0.0.1" -VERSION_PATTERN = r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$" - - -VersionField = Annotated[ - str, - Field( - description="The version of the SSVC object. This must be a valid semantic version string.", - examples=["1.0.0", "2.1.3"], - pattern=VERSION_PATTERN, - min_length=5, - ), -] +from ssvc.namespaces import NameSpace +from ssvc.utils.defaults import DEFAULT_VERSION +from ssvc.utils.types import NamespaceString, VersionString class _Versioned(BaseModel): @@ -49,7 +37,7 @@ class _Versioned(BaseModel): Mixin class for versioned SSVC objects. """ - version: VersionField = Field(default=DEFAULT_VERSION) + version: VersionString = Field(default=DEFAULT_VERSION) @field_validator("version") @classmethod diff --git a/src/ssvc/decision_points/base.py b/src/ssvc/decision_points/base.py index 69e42d96..2c803235 100644 --- a/src/ssvc/decision_points/base.py +++ b/src/ssvc/decision_points/base.py @@ -35,12 +35,12 @@ _Valued, _Versioned, ) +from ssvc.utils.defaults import FIELD_DELIMITER logger = logging.getLogger(__name__) REGISTERED_DECISION_POINTS = [] -FIELD_DELIMITER = ":" class Registry(BaseModel): diff --git a/src/ssvc/namespaces.py b/src/ssvc/namespaces.py index 2ff39529..d0f73a48 100644 --- a/src/ssvc/namespaces.py +++ b/src/ssvc/namespaces.py @@ -23,109 +23,10 @@ # subject to its own license. # DM24-0278 -import re from enum import StrEnum, auto -from typing import Annotated - -from pydantic import Field - -MIN_NS_LENGTH = 3 -MAX_NS_LENGTH = 1000 -NS_LENGTH_INTERVAL = MAX_NS_LENGTH - MIN_NS_LENGTH - -# from https://docs.oasis-open.org/csaf/csaf/v2.0/os/csaf-v2.0-os.html -BCP_47_PATTERN = r"(([A-Za-z]{2,3}(-[A-Za-z]{3}(-[A-Za-z]{3}){0,2})?|[A-Za-z]{4,8})(-[A-Za-z]{4})?(-([A-Za-z]{2}|[0-9]{3}))?(-([A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3}))*(-[A-WY-Za-wy-z0-9](-[A-Za-z0-9]{2,8})+)*(-[Xx](-[A-Za-z0-9]{1,8})+)?|[Xx](-[A-Za-z0-9]{1,8})+|[Ii]-[Dd][Ee][Ff][Aa][Uu][Ll][Tt]|[Ii]-[Mm][Ii][Nn][Gg][Oo])" -"""A regular expression pattern for BCP-47 language tags.""" - -LENGTH_CHECK_PATTERN = rf"(?=.{{{MIN_NS_LENGTH},{MAX_NS_LENGTH}}}$)" -"""Ensures the string is between MIN_NS_LENGTH and MAX_NS_LENGTH characters long.""" - -# Base namespace part (before any extensions) allows . and - with restrictions -BASE_PATTERN = ( - r"(?!.*[.-]{2,})" # no consecutive separators - r"[a-z][a-z0-9]+" # first part starts with a letter, followed by one or more alphanumeric characters - r"(?:[.-][a-z0-9]+)*" # remaining parts can have alphanumeric characters and single . or - separators -) -"""The base pattern for namespaces, which must start with a letter and contain at least 3 alphanumeric characters.""" - -X_PFX = "x_" -"""The prefix for extension namespaces. Extension namespaces must start with this prefix.""" - -EXPERIMENTAL_BASE = rf"{X_PFX}{BASE_PATTERN}" -f"""The base pattern for experimental namespaces, which must start with the {X_PFX} prefix, -followed by a string matching the base pattern.""" - -BASE_NS_PATTERN = rf"({EXPERIMENTAL_BASE}|{BASE_PATTERN})" -"""The complete base namespace pattern, which allows for experimental namespaces.""" - -# Extension segment pattern (alphanumeric + limited punctuation, no consecutive punctuation, ends with alphanumeric) -EXT_SEGMENT_PATTERN = ( - r"(?!.*[.-]{2,})" # no consecutive separators - r"[a-zA-Z][a-zA-Z0-9]*" # first part starts with a letter, followed by one or more alphanumeric characters - r"(?:[.-][a-zA-Z0-9]+)*" # remaining parts can have alphanumeric characters and single ., -, / separators -) -"""The pattern for extension segments in namespaces, which must start with a letter and contain alphanumeric characters or -limited punctuation characters (., -), with no consecutive punctuation characters allowed.""" - -# Language extension pattern (BCP-47 or empty for //) -LANG_EXT_PATTERN = rf"(/({BCP_47_PATTERN})/|//)" -"""The pattern for the first extension segment, which must be either a valid BCP-47 tag or empty (//).""" - -# Subsequent extension segments -SUBSEQUENT_EXT_PATTERN = rf"{EXT_SEGMENT_PATTERN}(?:/{EXT_SEGMENT_PATTERN})*" -"""The pattern for subsequent extension segments, which must follow the rules for extension segments, delimited by slashes (/).""" - -# Complete pattern with length validation -NS_PATTERN = re.compile( - rf"^{LENGTH_CHECK_PATTERN}({BASE_NS_PATTERN})({LANG_EXT_PATTERN}{SUBSEQUENT_EXT_PATTERN})?$" -) -f"""The full regular expression pattern for validating namespaces. - -!!! note "Length Requirements" - - - Namespaces must be between {MIN_NS_LENGTH} and {MAX_NS_LENGTH} characters long. - -!!! note "Base Namespace Requirements" - - - Must start with a lowercase letter - - Must contain at least 3 total characters in the base part (after the optional experimental/private prefix) - - Must contain only lowercase letters, numbers, dots (`.`), and hyphens (`-`) - - Must not contain consecutive dots or hyphens (no `..`, `--`, `.-`, `-.`, `---`, etc.) - - May optionally start with the experimental/private prefix `{X_PFX}`. - -!!! note "Extension Requirements (Optional)" - - - Extensions are optional - - Extensions must be delineated by slashes (`/`) - - If any extension segments are present, the following rules apply: - - The first extension segment, must be a valid BCP-47 language tag or empty (i.e., `//`). - - Subsequent extension segments: - - must start with a letter (upper or lowercase) - - may contain letters, numbers, dots (`.`), and hyphens (`-`) - - must not start or end with a dot or hyphen - - must not contain consecutive dots or hyphens (no `..`, `--`, `.-`, `-.`, `---`, etc.) - - are separated by single forward slashes (`/`) - - multiple extension segments are allowed - -""" -NamespaceString = Annotated[ - str, - Field( - description="The namespace of the SSVC object.", - examples=[ - "ssvc", - "cisa", - "x_com.example//private", - "com.example//some-extension", - "ssvc/de-DE/example.organization/reference-arch-1", - ], - pattern=NS_PATTERN, - min_length=MIN_NS_LENGTH, - max_length=MAX_NS_LENGTH, - ), -] -"""A string datatype for namespace values, for use in Pydantic models.""" +from ssvc.utils.defaults import MAX_NS_LENGTH, MIN_NS_LENGTH, X_PFX +from ssvc.utils.patterns import NS_PATTERN class NameSpace(StrEnum): diff --git a/src/ssvc/selection.py b/src/ssvc/selection.py index e2444821..6a009cdc 100644 --- a/src/ssvc/selection.py +++ b/src/ssvc/selection.py @@ -26,9 +26,8 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator -from ssvc._mixins import VersionField from ssvc.decision_points.base import DecisionPoint -from ssvc.namespaces import NamespaceString +from ssvc.utils.types import NamespaceString, VersionString SCHEMA_VERSION = "2.0.0" @@ -49,7 +48,7 @@ class MinimalSelection(BaseModel): examples=["E", "A", "MI", "PSI"], min_length=1, ) - version: VersionField + version: VersionString values: list[str] = Field( ..., description="A list of selected value keys from the decision point values.", diff --git a/src/ssvc/utils/__init__.py b/src/ssvc/utils/__init__.py new file mode 100644 index 00000000..ff52eb81 --- /dev/null +++ b/src/ssvc/utils/__init__.py @@ -0,0 +1,20 @@ +"""Provides utility features for the SSVC package.""" + +# Copyright (c) 2025 Carnegie Mellon University. +# NO WARRANTY. THIS CARNEGIE MELLON UNIVERSITY AND SOFTWARE +# ENGINEERING INSTITUTE MATERIAL IS FURNISHED ON AN "AS-IS" BASIS. +# CARNEGIE MELLON UNIVERSITY MAKES NO WARRANTIES OF ANY KIND, +# EITHER EXPRESSED OR IMPLIED, AS TO ANY MATTER INCLUDING, BUT +# NOT LIMITED TO, WARRANTY OF FITNESS FOR PURPOSE OR +# MERCHANTABILITY, EXCLUSIVITY, OR RESULTS OBTAINED FROM USE +# OF THE MATERIAL. CARNEGIE MELLON UNIVERSITY DOES NOT MAKE +# ANY WARRANTY OF ANY KIND WITH RESPECT TO FREEDOM FROM +# PATENT, TRADEMARK, OR COPYRIGHT INFRINGEMENT. +# Licensed under a MIT (SEI)-style license, please see LICENSE or contact +# permission@sei.cmu.edu for full terms. +# [DISTRIBUTION STATEMENT A] This material has been approved for +# public release and unlimited distribution. Please see Copyright notice +# for non-US Government use and distribution. +# This Software includes and/or makes use of Third-Party Software each +# subject to its own license. +# DM24-0278 diff --git a/src/ssvc/utils/defaults.py b/src/ssvc/utils/defaults.py new file mode 100644 index 00000000..b925a1a9 --- /dev/null +++ b/src/ssvc/utils/defaults.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python +""" +Provides default values and constants for use in SSVC objects. +""" + +# Copyright (c) 2025 Carnegie Mellon University. +# NO WARRANTY. THIS CARNEGIE MELLON UNIVERSITY AND SOFTWARE +# ENGINEERING INSTITUTE MATERIAL IS FURNISHED ON AN "AS-IS" BASIS. +# CARNEGIE MELLON UNIVERSITY MAKES NO WARRANTIES OF ANY KIND, +# EITHER EXPRESSED OR IMPLIED, AS TO ANY MATTER INCLUDING, BUT +# NOT LIMITED TO, WARRANTY OF FITNESS FOR PURPOSE OR +# MERCHANTABILITY, EXCLUSIVITY, OR RESULTS OBTAINED FROM USE +# OF THE MATERIAL. CARNEGIE MELLON UNIVERSITY DOES NOT MAKE +# ANY WARRANTY OF ANY KIND WITH RESPECT TO FREEDOM FROM +# PATENT, TRADEMARK, OR COPYRIGHT INFRINGEMENT. +# Licensed under a MIT (SEI)-style license, please see LICENSE or contact +# permission@sei.cmu.edu for full terms. +# [DISTRIBUTION STATEMENT A] This material has been approved for +# public release and unlimited distribution. Please see Copyright notice +# for non-US Government use and distribution. +# This Software includes and/or makes use of Third-Party Software each +# subject to its own license. +# DM24-0278 + +X_PFX = "x_" +"""The prefix for extension namespaces. Extension namespaces must start with this prefix.""" + +MIN_NS_LENGTH = 3 +"""The minimum length of a namespace string.""" + +MAX_NS_LENGTH = 1000 +"""The maximum length of a namespace string.""" + +NS_LENGTH_INTERVAL = MAX_NS_LENGTH - MIN_NS_LENGTH +"""The interval between the minimum and maximum lengths of a namespace string.""" + +FIELD_DELIMITER = ":" +"""The delimiter used to separate fields in SSVC object IDs.""" + + +def main(): + pass + + +if __name__ == "__main__": + main() +DEFAULT_VERSION = "0.0.1" diff --git a/src/ssvc/utils/patterns.py b/src/ssvc/utils/patterns.py new file mode 100644 index 00000000..3886b61f --- /dev/null +++ b/src/ssvc/utils/patterns.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python +""" +Provides python regular expressions and utility functions for SSVC-related patterns. +""" + +# Copyright (c) 2025 Carnegie Mellon University. +# NO WARRANTY. THIS CARNEGIE MELLON UNIVERSITY AND SOFTWARE +# ENGINEERING INSTITUTE MATERIAL IS FURNISHED ON AN "AS-IS" BASIS. +# CARNEGIE MELLON UNIVERSITY MAKES NO WARRANTIES OF ANY KIND, +# EITHER EXPRESSED OR IMPLIED, AS TO ANY MATTER INCLUDING, BUT +# NOT LIMITED TO, WARRANTY OF FITNESS FOR PURPOSE OR +# MERCHANTABILITY, EXCLUSIVITY, OR RESULTS OBTAINED FROM USE +# OF THE MATERIAL. CARNEGIE MELLON UNIVERSITY DOES NOT MAKE +# ANY WARRANTY OF ANY KIND WITH RESPECT TO FREEDOM FROM +# PATENT, TRADEMARK, OR COPYRIGHT INFRINGEMENT. +# Licensed under a MIT (SEI)-style license, please see LICENSE or contact +# permission@sei.cmu.edu for full terms. +# [DISTRIBUTION STATEMENT A] This material has been approved for +# public release and unlimited distribution. Please see Copyright notice +# for non-US Government use and distribution. +# This Software includes and/or makes use of Third-Party Software each +# subject to its own license. +# DM24-0278 + +import re + +from ssvc.utils.defaults import MAX_NS_LENGTH, MIN_NS_LENGTH, X_PFX + +# Copyright (c) 2025 Carnegie Mellon University. +# NO WARRANTY. THIS CARNEGIE MELLON UNIVERSITY AND SOFTWARE +# ENGINEERING INSTITUTE MATERIAL IS FURNISHED ON AN "AS-IS" BASIS. +# CARNEGIE MELLON UNIVERSITY MAKES NO WARRANTIES OF ANY KIND, +# EITHER EXPRESSED OR IMPLIED, AS TO ANY MATTER INCLUDING, BUT +# NOT LIMITED TO, WARRANTY OF FITNESS FOR PURPOSE OR +# MERCHANTABILITY, EXCLUSIVITY, OR RESULTS OBTAINED FROM USE +# OF THE MATERIAL. CARNEGIE MELLON UNIVERSITY DOES NOT MAKE +# ANY WARRANTY OF ANY KIND WITH RESPECT TO FREEDOM FROM +# PATENT, TRADEMARK, OR COPYRIGHT INFRINGEMENT. +# Licensed under a MIT (SEI)-style license, please see LICENSE or contact +# permission@sei.cmu.edu for full terms. +# [DISTRIBUTION STATEMENT A] This material has been approved for +# public release and unlimited distribution. Please see Copyright notice +# for non-US Government use and distribution. +# This Software includes and/or makes use of Third-Party Software each +# subject to its own license. +# DM24-0278 + +# from https://semver.org/ +VERSION_PATTERN = r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$" +"""A regular expression pattern for semantic versioning (semver).""" + +# from https://docs.oasis-open.org/csaf/csaf/v2.0/os/csaf-v2.0-os.html +BCP_47_PATTERN = r"(([A-Za-z]{2,3}(-[A-Za-z]{3}(-[A-Za-z]{3}){0,2})?|[A-Za-z]{4,8})(-[A-Za-z]{4})?(-([A-Za-z]{2}|[0-9]{3}))?(-([A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3}))*(-[A-WY-Za-wy-z0-9](-[A-Za-z0-9]{2,8})+)*(-[Xx](-[A-Za-z0-9]{1,8})+)?|[Xx](-[A-Za-z0-9]{1,8})+|[Ii]-[Dd][Ee][Ff][Aa][Uu][Ll][Tt]|[Ii]-[Mm][Ii][Nn][Gg][Oo])" +"""A regular expression pattern for BCP-47 language tags.""" + + +LENGTH_CHECK_PATTERN = rf"(?=.{{{MIN_NS_LENGTH},{MAX_NS_LENGTH}}}$)" +"""Ensures the string is between MIN_NS_LENGTH and MAX_NS_LENGTH characters long.""" + +# Base namespace part (before any extensions) allows . and - with restrictions +BASE_PATTERN = ( + r"(?!.*[.-]{2,})" # no consecutive separators + r"[a-z][a-z0-9]+" # first part starts with a letter, followed by one or more alphanumeric characters + r"(?:[.-][a-z0-9]+)*" # remaining parts can have alphanumeric characters and single . or - separators +) +"""The base pattern for namespaces, which must start with a letter followed by at least one alphanumeric character.""" + +EXPERIMENTAL_BASE = rf"{X_PFX}{BASE_PATTERN}" +f"""The base pattern for experimental namespaces, which must start with the {X_PFX} prefix, +followed by a string matching the base pattern.""" + +BASE_NS_PATTERN = rf"({EXPERIMENTAL_BASE}|{BASE_PATTERN})" +"""The complete base namespace pattern, which allows for experimental namespaces.""" + +EXT_SEGMENT_PATTERN = ( + r"(?!.*[.-]{2,})" # no consecutive separators + r"[a-zA-Z][a-zA-Z0-9]*" # first part starts with a letter, followed by zero or more alphanumeric characters + r"(?:[.-][a-zA-Z0-9]+)*" # remaining parts can have alphanumeric characters and single ., -, / separators +) +"""The pattern for extension segments in namespaces, which must start with a letter and contain alphanumeric characters or +limited punctuation characters (., -), with no consecutive punctuation characters allowed.""" + +LANG_EXT_PATTERN = rf"(/({BCP_47_PATTERN})/|//)" +# Language extension pattern (BCP-47 or empty for //) +"""The pattern for the first extension segment, which must be either a valid BCP-47 tag or empty (//).""" + +SUBSEQUENT_EXT_PATTERN = rf"{EXT_SEGMENT_PATTERN}(?:/{EXT_SEGMENT_PATTERN})*" +# Subsequent extension segments +"""The pattern for subsequent extension segments, which must follow the rules for extension segments, delimited by slashes (/).""" + +NS_PATTERN = re.compile( + rf"^{LENGTH_CHECK_PATTERN}({BASE_NS_PATTERN})({LANG_EXT_PATTERN}{SUBSEQUENT_EXT_PATTERN})?$" +) +f"""The full regular expression pattern for validating namespaces. + +!!! note "Length Requirements" + + - Namespaces must be between {MIN_NS_LENGTH} and {MAX_NS_LENGTH} characters long. + +!!! note "Base Namespace Requirements" + + - Must start with a lowercase letter + - Must contain at least 3 total characters in the base part (after the optional experimental/private prefix) + - Must contain only lowercase letters, numbers, dots (`.`), and hyphens (`-`) + - Must not contain consecutive dots or hyphens (no `..`, `--`, `.-`, `-.`, `---`, etc.) + - May optionally start with the experimental/private prefix `{X_PFX}`. + +!!! note "Extension Requirements (Optional)" + + - Extensions are optional + - Extensions must be delineated by slashes (`/`) + - If any extension segments are present, the following rules apply: + - The first extension segment, must be a valid BCP-47 language tag or empty (i.e., `//`). + - Subsequent extension segments: + - must start with a letter (upper or lowercase) + - may contain letters, numbers, dots (`.`), and hyphens (`-`) + - must not start or end with a dot or hyphen + - must not contain consecutive dots or hyphens (no `..`, `--`, `.-`, `-.`, `---`, etc.) + - are separated by single forward slashes (`/`) + - multiple extension segments are allowed + +""" + + +def main(): + pass + + +if __name__ == "__main__": + main() diff --git a/src/ssvc/utils/types.py b/src/ssvc/utils/types.py new file mode 100644 index 00000000..ec33938b --- /dev/null +++ b/src/ssvc/utils/types.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python +""" +Provides custom data types for use in SSVC objects. +""" + +# Copyright (c) 2025 Carnegie Mellon University. +# NO WARRANTY. THIS CARNEGIE MELLON UNIVERSITY AND SOFTWARE +# ENGINEERING INSTITUTE MATERIAL IS FURNISHED ON AN "AS-IS" BASIS. +# CARNEGIE MELLON UNIVERSITY MAKES NO WARRANTIES OF ANY KIND, +# EITHER EXPRESSED OR IMPLIED, AS TO ANY MATTER INCLUDING, BUT +# NOT LIMITED TO, WARRANTY OF FITNESS FOR PURPOSE OR +# MERCHANTABILITY, EXCLUSIVITY, OR RESULTS OBTAINED FROM USE +# OF THE MATERIAL. CARNEGIE MELLON UNIVERSITY DOES NOT MAKE +# ANY WARRANTY OF ANY KIND WITH RESPECT TO FREEDOM FROM +# PATENT, TRADEMARK, OR COPYRIGHT INFRINGEMENT. +# Licensed under a MIT (SEI)-style license, please see LICENSE or contact +# permission@sei.cmu.edu for full terms. +# [DISTRIBUTION STATEMENT A] This material has been approved for +# public release and unlimited distribution. Please see Copyright notice +# for non-US Government use and distribution. +# This Software includes and/or makes use of Third-Party Software each +# subject to its own license. +# DM24-0278 + +from typing import Annotated + +from pydantic import Field + +from ssvc.utils.defaults import MAX_NS_LENGTH, MIN_NS_LENGTH +from ssvc.utils.patterns import NS_PATTERN, VERSION_PATTERN + +# Copyright (c) 2025 Carnegie Mellon University. +# NO WARRANTY. THIS CARNEGIE MELLON UNIVERSITY AND SOFTWARE +# ENGINEERING INSTITUTE MATERIAL IS FURNISHED ON AN "AS-IS" BASIS. +# CARNEGIE MELLON UNIVERSITY MAKES NO WARRANTIES OF ANY KIND, +# EITHER EXPRESSED OR IMPLIED, AS TO ANY MATTER INCLUDING, BUT +# NOT LIMITED TO, WARRANTY OF FITNESS FOR PURPOSE OR +# MERCHANTABILITY, EXCLUSIVITY, OR RESULTS OBTAINED FROM USE +# OF THE MATERIAL. CARNEGIE MELLON UNIVERSITY DOES NOT MAKE +# ANY WARRANTY OF ANY KIND WITH RESPECT TO FREEDOM FROM +# PATENT, TRADEMARK, OR COPYRIGHT INFRINGEMENT. +# Licensed under a MIT (SEI)-style license, please see LICENSE or contact +# permission@sei.cmu.edu for full terms. +# [DISTRIBUTION STATEMENT A] This material has been approved for +# public release and unlimited distribution. Please see Copyright notice +# for non-US Government use and distribution. +# This Software includes and/or makes use of Third-Party Software each +# subject to its own license. +# DM24-0278 + +VersionString = Annotated[ + str, + Field( + description="The version of the SSVC object. This must be a valid semantic version string.", + examples=["1.0.0", "2.1.3"], + pattern=VERSION_PATTERN, + min_length=5, + ), +] +"""A string datatype for version values, for use in Pydantic models.""" + +NamespaceString = Annotated[ + str, + Field( + description="The namespace of the SSVC object.", + examples=[ + "ssvc", + "cisa", + "x_com.example//private", + "com.example//some-extension", + "ssvc/de-DE/example.organization/reference-arch-1", + ], + pattern=NS_PATTERN, + min_length=MIN_NS_LENGTH, + max_length=MAX_NS_LENGTH, + ), +] +"""A string datatype for namespace values, for use in Pydantic models.""" + + +def main(): + pass + + +if __name__ == "__main__": + main() diff --git a/src/test/test_mixins.py b/src/test/test_mixins.py index a5269aee..9744d8f5 100644 --- a/src/test/test_mixins.py +++ b/src/test/test_mixins.py @@ -23,14 +23,14 @@ from pydantic import BaseModel, ValidationError from ssvc._mixins import ( - DEFAULT_VERSION, _Base, _Keyed, _Namespaced, _Valued, _Versioned, ) -from ssvc.namespaces import MAX_NS_LENGTH, NameSpace +from ssvc.namespaces import NameSpace +from ssvc.utils.defaults import DEFAULT_VERSION, MAX_NS_LENGTH class TestMixins(unittest.TestCase): diff --git a/src/test/test_namespaces.py b/src/test/test_namespaces.py index 4f527a88..dde85463 100644 --- a/src/test/test_namespaces.py +++ b/src/test/test_namespaces.py @@ -19,7 +19,8 @@ import unittest -from ssvc.namespaces import NS_PATTERN, NameSpace +from ssvc.namespaces import NameSpace +from ssvc.utils.patterns import NS_PATTERN class MyTestCase(unittest.TestCase): diff --git a/src/test/test_namespaces_pattern.py b/src/test/test_namespaces_pattern.py index a87df58d..54aeff84 100644 --- a/src/test/test_namespaces_pattern.py +++ b/src/test/test_namespaces_pattern.py @@ -21,12 +21,11 @@ import re import unittest -from ssvc.namespaces import ( +from ssvc.utils.defaults import MAX_NS_LENGTH, MIN_NS_LENGTH +from ssvc.utils.patterns import ( BASE_NS_PATTERN, BASE_PATTERN, LENGTH_CHECK_PATTERN, - MAX_NS_LENGTH, - MIN_NS_LENGTH, NS_PATTERN, ) diff --git a/src/test/test_selections.py b/src/test/test_selections.py index d4d0bf59..2ff73fbe 100644 --- a/src/test/test_selections.py +++ b/src/test/test_selections.py @@ -21,9 +21,8 @@ from datetime import datetime from ssvc import selection -from ssvc._mixins import VERSION_PATTERN -from ssvc.namespaces import NS_PATTERN from ssvc.selection import MinimalSelectionList +from ssvc.utils.patterns import NS_PATTERN, VERSION_PATTERN class MyTestCase(unittest.TestCase): @@ -64,7 +63,7 @@ def test_minimal_selection_init(self): # key is a string self.assertIsInstance(self.s1.key, str) self.assertGreater(len(self.s1.key), 0, "Key should not be empty") - # version is a valid VersionField + # version is a valid VersionString self.assertIsInstance(self.s1.version, str) self.assertRegex( self.s1.version, From cc4a67961e55a327fe7fba2f5fb25a8764c630a1 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Mon, 21 Jul 2025 13:08:41 -0400 Subject: [PATCH 39/99] cleanup defaults module --- src/ssvc/utils/defaults.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ssvc/utils/defaults.py b/src/ssvc/utils/defaults.py index b925a1a9..e864283b 100644 --- a/src/ssvc/utils/defaults.py +++ b/src/ssvc/utils/defaults.py @@ -22,6 +22,9 @@ # subject to its own license. # DM24-0278 +DEFAULT_VERSION = "0.0.1" +"""The default version for SSVC objects, used when no version is specified at object creation.""" + X_PFX = "x_" """The prefix for extension namespaces. Extension namespaces must start with this prefix.""" @@ -44,4 +47,3 @@ def main(): if __name__ == "__main__": main() -DEFAULT_VERSION = "0.0.1" From a1c071992c060a42c708626a92a8229bdf16ccbb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Jul 2025 17:08:49 +0000 Subject: [PATCH 40/99] Bump jsonschema from 4.24.0 to 4.25.0 --- updated-dependencies: - dependency-name: jsonschema dependency-version: 4.25.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 9e991667..3100fdcf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,7 +11,7 @@ markdown-exec==1.11.0 thefuzz==0.22.1 pandas==2.3.1 scikit-learn==1.6.1 -jsonschema==4.24.0 +jsonschema==4.25.0 networkx==3.4.2 pydantic==2.11.7 semver==3.0.4 \ No newline at end of file From 989a07af6a22016d5a105b4021dbd9ed772d1963 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Mon, 21 Jul 2025 13:09:55 -0400 Subject: [PATCH 41/99] fix double-copyright blocks --- src/ssvc/utils/patterns.py | 19 ------------------- src/ssvc/utils/types.py | 19 ------------------- 2 files changed, 38 deletions(-) diff --git a/src/ssvc/utils/patterns.py b/src/ssvc/utils/patterns.py index 3886b61f..04463b0c 100644 --- a/src/ssvc/utils/patterns.py +++ b/src/ssvc/utils/patterns.py @@ -26,25 +26,6 @@ from ssvc.utils.defaults import MAX_NS_LENGTH, MIN_NS_LENGTH, X_PFX -# Copyright (c) 2025 Carnegie Mellon University. -# NO WARRANTY. THIS CARNEGIE MELLON UNIVERSITY AND SOFTWARE -# ENGINEERING INSTITUTE MATERIAL IS FURNISHED ON AN "AS-IS" BASIS. -# CARNEGIE MELLON UNIVERSITY MAKES NO WARRANTIES OF ANY KIND, -# EITHER EXPRESSED OR IMPLIED, AS TO ANY MATTER INCLUDING, BUT -# NOT LIMITED TO, WARRANTY OF FITNESS FOR PURPOSE OR -# MERCHANTABILITY, EXCLUSIVITY, OR RESULTS OBTAINED FROM USE -# OF THE MATERIAL. CARNEGIE MELLON UNIVERSITY DOES NOT MAKE -# ANY WARRANTY OF ANY KIND WITH RESPECT TO FREEDOM FROM -# PATENT, TRADEMARK, OR COPYRIGHT INFRINGEMENT. -# Licensed under a MIT (SEI)-style license, please see LICENSE or contact -# permission@sei.cmu.edu for full terms. -# [DISTRIBUTION STATEMENT A] This material has been approved for -# public release and unlimited distribution. Please see Copyright notice -# for non-US Government use and distribution. -# This Software includes and/or makes use of Third-Party Software each -# subject to its own license. -# DM24-0278 - # from https://semver.org/ VERSION_PATTERN = r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$" """A regular expression pattern for semantic versioning (semver).""" diff --git a/src/ssvc/utils/types.py b/src/ssvc/utils/types.py index ec33938b..c50bee1d 100644 --- a/src/ssvc/utils/types.py +++ b/src/ssvc/utils/types.py @@ -29,25 +29,6 @@ from ssvc.utils.defaults import MAX_NS_LENGTH, MIN_NS_LENGTH from ssvc.utils.patterns import NS_PATTERN, VERSION_PATTERN -# Copyright (c) 2025 Carnegie Mellon University. -# NO WARRANTY. THIS CARNEGIE MELLON UNIVERSITY AND SOFTWARE -# ENGINEERING INSTITUTE MATERIAL IS FURNISHED ON AN "AS-IS" BASIS. -# CARNEGIE MELLON UNIVERSITY MAKES NO WARRANTIES OF ANY KIND, -# EITHER EXPRESSED OR IMPLIED, AS TO ANY MATTER INCLUDING, BUT -# NOT LIMITED TO, WARRANTY OF FITNESS FOR PURPOSE OR -# MERCHANTABILITY, EXCLUSIVITY, OR RESULTS OBTAINED FROM USE -# OF THE MATERIAL. CARNEGIE MELLON UNIVERSITY DOES NOT MAKE -# ANY WARRANTY OF ANY KIND WITH RESPECT TO FREEDOM FROM -# PATENT, TRADEMARK, OR COPYRIGHT INFRINGEMENT. -# Licensed under a MIT (SEI)-style license, please see LICENSE or contact -# permission@sei.cmu.edu for full terms. -# [DISTRIBUTION STATEMENT A] This material has been approved for -# public release and unlimited distribution. Please see Copyright notice -# for non-US Government use and distribution. -# This Software includes and/or makes use of Third-Party Software each -# subject to its own license. -# DM24-0278 - VersionString = Annotated[ str, Field( From be4f0364417c56da7dd6f02f38904356683390fb Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Mon, 21 Jul 2025 13:14:02 -0400 Subject: [PATCH 42/99] rename types.py to field_specs.py --- src/ssvc/_mixins.py | 2 +- src/ssvc/selection.py | 2 +- src/ssvc/utils/{types.py => field_specs.py} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename src/ssvc/utils/{types.py => field_specs.py} (100%) diff --git a/src/ssvc/_mixins.py b/src/ssvc/_mixins.py index 5039ab55..4c3fa7f4 100644 --- a/src/ssvc/_mixins.py +++ b/src/ssvc/_mixins.py @@ -29,7 +29,7 @@ from ssvc import _schemaVersion from ssvc.namespaces import NameSpace from ssvc.utils.defaults import DEFAULT_VERSION -from ssvc.utils.types import NamespaceString, VersionString +from ssvc.utils.field_specs import NamespaceString, VersionString class _Versioned(BaseModel): diff --git a/src/ssvc/selection.py b/src/ssvc/selection.py index 6a009cdc..e8d1fdd3 100644 --- a/src/ssvc/selection.py +++ b/src/ssvc/selection.py @@ -27,7 +27,7 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator from ssvc.decision_points.base import DecisionPoint -from ssvc.utils.types import NamespaceString, VersionString +from ssvc.utils.field_specs import NamespaceString, VersionString SCHEMA_VERSION = "2.0.0" diff --git a/src/ssvc/utils/types.py b/src/ssvc/utils/field_specs.py similarity index 100% rename from src/ssvc/utils/types.py rename to src/ssvc/utils/field_specs.py From 9816c5c660df82d35f0fcb092de29519b17d8b58 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Mon, 21 Jul 2025 13:19:02 -0400 Subject: [PATCH 43/99] reinstate additionalProperties=false in JSON schema --- .../v2/Decision_Point_Value_Selection-2-0-0.schema.json | 4 ++-- src/ssvc/selection.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json index ed0c31a3..ad2fa0c3 100644 --- a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json +++ b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json @@ -39,7 +39,7 @@ "type": "array" }, "timestamp": { - "description": "Timestamp of when the selections were made, in ISO 8601 format.", + "description": "Timestamp of when the selections were made, in RFC 3339 format.", "examples": [ "2025-01-01T12:00:00Z", "2025-01-02T15:30:45-04:00" @@ -54,7 +54,7 @@ "selections", "timestamp" ], - "additionalProperties": true, + "additionalProperties": false, "$defs": { "MinimalSelection": { "description": "A minimal selection object that contains the decision point ID and the selected options.\nThis is used to transition from an SSVC decision point to a selection.", diff --git a/src/ssvc/selection.py b/src/ssvc/selection.py index e2444821..94e3e416 100644 --- a/src/ssvc/selection.py +++ b/src/ssvc/selection.py @@ -66,7 +66,7 @@ class MinimalSelectionList(BaseModel): A down-selection of SSVC Decision Points that represent an evaluation at a specific time of a Vulnerability evaluation. """ - model_config = ConfigDict(extra="allow") + model_config = ConfigDict(extra="forbid") schemaVersion: Literal[SCHEMA_VERSION] = Field( ..., description="The schema version of this selection list.", From cb05a84d3655f96df218c73df61de763df916ea0 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Mon, 21 Jul 2025 13:28:08 -0400 Subject: [PATCH 44/99] update schema to reflect recent changes --- .../v2/Decision_Point_Value_Selection-2-0-0.schema.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json index d2d986db..0a325d2c 100644 --- a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json +++ b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json @@ -64,12 +64,13 @@ "examples": [ "ssvc", "cisa", - "x_private-test", - "ssvc/de-DE/reference-arch-1" + "x_com.example//private", + "com.example//some-extension", + "ssvc/de-DE/example.organization/reference-arch-1" ], "maxLength": 1000, "minLength": 3, - "pattern": "^(?=.{3,1000}$)((x_(?!.*[.-]{2,})[a-z][a-z0-9]{2,}(?:[.-][a-z0-9]+)*|(?!.*[.-]{2,})[a-z][a-z0-9]{2,}(?:[.-][a-z0-9]+)*))((/((([A-Za-z]{2,3}(-[A-Za-z]{3}(-[A-Za-z]{3}){0,2})?|[A-Za-z]{4,8})(-[A-Za-z]{4})?(-([A-Za-z]{2}|[0-9]{3}))?(-([A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3}))*(-[A-WY-Za-wy-z0-9](-[A-Za-z0-9]{2,8})+)*(-[Xx](-[A-Za-z0-9]{1,8})+)?|[Xx](-[A-Za-z0-9]{1,8})+|[Ii]-[Dd][Ee][Ff][Aa][Uu][Ll][Tt]|[Ii]-[Mm][Ii][Nn][Gg][Oo]))/|//)(?!.*[.-]{2,})[a-zA-Z][a-zA-Z0-9]*(?:[.-][a-zA-Z0-9]+)*(?:/(?!.*[.-]{2,})[a-zA-Z][a-zA-Z0-9]*(?:[.-][a-zA-Z0-9]+)*)*)?$", + "pattern": "^(?=.{3,1000}$)((x_(?!.*[.-]{2,})[a-z][a-z0-9]+(?:[.-][a-z0-9]+)*|(?!.*[.-]{2,})[a-z][a-z0-9]+(?:[.-][a-z0-9]+)*))((/((([A-Za-z]{2,3}(-[A-Za-z]{3}(-[A-Za-z]{3}){0,2})?|[A-Za-z]{4,8})(-[A-Za-z]{4})?(-([A-Za-z]{2}|[0-9]{3}))?(-([A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3}))*(-[A-WY-Za-wy-z0-9](-[A-Za-z0-9]{2,8})+)*(-[Xx](-[A-Za-z0-9]{1,8})+)?|[Xx](-[A-Za-z0-9]{1,8})+|[Ii]-[Dd][Ee][Ff][Aa][Uu][Ll][Tt]|[Ii]-[Mm][Ii][Nn][Gg][Oo]))/|//)(?!.*[.-]{2,})[a-zA-Z][a-zA-Z0-9]*(?:[.-][a-zA-Z0-9]+)*(?:/(?!.*[.-]{2,})[a-zA-Z][a-zA-Z0-9]*(?:[.-][a-zA-Z0-9]+)*)*)?$", "title": "Namespace", "type": "string" }, From 85dab997f5bc6dd3e78a4f946c4b05775ef7628a Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Mon, 21 Jul 2025 15:27:15 -0400 Subject: [PATCH 45/99] change "vulnerability_id" to "target_ids" and make it a list of strings rather than a single string. --- ...on_Point_Value_Selection-2-0-0.schema.json | 23 ++++++++++----- src/ssvc/selection.py | 29 ++++++++++++++++--- src/test/test_selections.py | 11 +++++-- 3 files changed, 48 insertions(+), 15 deletions(-) diff --git a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json index ad2fa0c3..33e92925 100644 --- a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json +++ b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json @@ -10,24 +10,31 @@ "title": "Schemaversion", "type": "string" }, - "vulnerability_id": { + "target_ids": { "anyOf": [ { - "minLength": 1, - "type": "string" + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" }, { "type": "null" } ], "default": null, - "description": "Optional vulnerability ID associated with the selections.", + "description": "Optional list of identifiers for the item or items (vulnerabilities, reports, advisories, systems, assets, etc.) being evaluated by these selections.", "examples": [ - "CVE-2025-0000", - "VU#999999", - "GHSA-0123-4567-89ab" + [ + "CVE-2025-0000" + ], + [ + "VU#999999", + "GHSA-0123-4567-89ab" + ] ], - "title": "Vulnerability Id" + "title": "Target Ids" }, "selections": { "description": "List of minimal selections made from decision points.", diff --git a/src/ssvc/selection.py b/src/ssvc/selection.py index 94e3e416..629309c8 100644 --- a/src/ssvc/selection.py +++ b/src/ssvc/selection.py @@ -24,7 +24,7 @@ from datetime import datetime from typing import Literal, Optional -from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from ssvc._mixins import VersionField from ssvc.decision_points.base import DecisionPoint @@ -72,10 +72,15 @@ class MinimalSelectionList(BaseModel): description="The schema version of this selection list.", ) - vulnerability_id: Optional[str] = Field( + target_ids: Optional[list[str]] = Field( default=None, - description="Optional vulnerability ID associated with the selections.", - examples=["CVE-2025-0000", "VU#999999", "GHSA-0123-4567-89ab"], + description="Optional list of identifiers for the item or items " + "(vulnerabilities, reports, advisories, systems, assets, etc.) " + "being evaluated by these selections.", + examples=[ + ["CVE-2025-0000"], + ["VU#999999", "GHSA-0123-4567-89ab"], + ], min_length=1, ) selections: list[MinimalSelection] = Field( @@ -96,6 +101,22 @@ def set_schema_version(cls, data): data["schemaVersion"] = SCHEMA_VERSION return data + # target_ids should be a non-empty list if not None + @field_validator("target_ids", mode="before") + @classmethod + def validate_target_ids(cls, value: Optional[list[str]]) -> Optional[list[str]]: + """ + Validate the target_ids field. + If target_ids is provided, it must be a non-empty list of strings. + """ + if value is not None: + if not isinstance(value, list) or len(value) == 0: + raise ValueError("target_ids must be a non-empty list of strings.") + for item in value: + if not isinstance(item, str): + raise ValueError("Each target_id must be a string.") + return value + def add_selection(self, selection: MinimalSelection) -> None: """ Adds a minimal selection to the list. diff --git a/src/test/test_selections.py b/src/test/test_selections.py index d4d0bf59..bfa67741 100644 --- a/src/test/test_selections.py +++ b/src/test/test_selections.py @@ -41,7 +41,9 @@ def setUp(self): values=["value21", "value22"], ) self.selections = MinimalSelectionList( - selections=[self.s1, self.s2], timestamp=datetime.now() + selections=[self.s1, self.s2], + timestamp=datetime.now(), + target_ids=["target_id_1", "target_id_2"], ) def test_minimal_selection_init(self): @@ -97,8 +99,11 @@ def test_minimal_selection_list_init(self): ) self.assertRegex(self.selections.schemaVersion, VERSION_PATTERN) - # vulnerability_id is optional and can be None or a string - self.assertIsInstance(self.selections.vulnerability_id, (str, type(None))) + self.assertIsInstance(self.selections.target_ids, (list, type(None))) + for target_id in self.selections.target_ids: + self.assertIsInstance( + target_id, str, f"Target ID {target_id} is not a string" + ) # selections is a list of MinimalSelection objects self.assertIsInstance(self.selections.selections, list) From 1876d68a3599a0e5cd154aa037f89c2754738c0b Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Mon, 21 Jul 2025 15:29:06 -0400 Subject: [PATCH 46/99] make single selection sub-object forbid extras too --- data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json | 1 + src/ssvc/selection.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json index 33e92925..616bd12b 100644 --- a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json +++ b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json @@ -64,6 +64,7 @@ "additionalProperties": false, "$defs": { "MinimalSelection": { + "additionalProperties": false, "description": "A minimal selection object that contains the decision point ID and the selected options.\nThis is used to transition from an SSVC decision point to a selection.", "properties": { "namespace": { diff --git a/src/ssvc/selection.py b/src/ssvc/selection.py index 629309c8..ce41a84a 100644 --- a/src/ssvc/selection.py +++ b/src/ssvc/selection.py @@ -39,6 +39,8 @@ class MinimalSelection(BaseModel): This is used to transition from an SSVC decision point to a selection. """ + model_config = ConfigDict(extra="forbid") + namespace: NamespaceString = Field( ..., description="The namespace of the decision point.", From 8dd15693075dbca90725d403116b445a56dcac56 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Mon, 21 Jul 2025 16:10:43 -0400 Subject: [PATCH 47/99] fails on "ssvc//example.organization#model/com.example#foo" --- src/ssvc/utils/patterns.py | 49 ++++++++++++++++++----------- src/test/test_namespaces_pattern.py | 37 +++++++++++++++++++++- 2 files changed, 66 insertions(+), 20 deletions(-) diff --git a/src/ssvc/utils/patterns.py b/src/ssvc/utils/patterns.py index 04463b0c..8a4fa3af 100644 --- a/src/ssvc/utils/patterns.py +++ b/src/ssvc/utils/patterns.py @@ -35,42 +35,53 @@ """A regular expression pattern for BCP-47 language tags.""" +# --- Namespace Regex Components --- + +# Length check LENGTH_CHECK_PATTERN = rf"(?=.{{{MIN_NS_LENGTH},{MAX_NS_LENGTH}}}$)" """Ensures the string is between MIN_NS_LENGTH and MAX_NS_LENGTH characters long.""" -# Base namespace part (before any extensions) allows . and - with restrictions +# Base namespace pattern (before any // or /lang/) BASE_PATTERN = ( r"(?!.*[.-]{2,})" # no consecutive separators - r"[a-z][a-z0-9]+" # first part starts with a letter, followed by one or more alphanumeric characters - r"(?:[.-][a-z0-9]+)*" # remaining parts can have alphanumeric characters and single . or - separators + r"[a-z][a-z0-9]+" # starts with a letter, followed by one or more alphanumeric chars + r"(?:[.-][a-z0-9]+)*" # then . or - followed by alphanumerics ) -"""The base pattern for namespaces, which must start with a letter followed by at least one alphanumeric character.""" +"""The base pattern for namespaces.""" EXPERIMENTAL_BASE = rf"{X_PFX}{BASE_PATTERN}" -f"""The base pattern for experimental namespaces, which must start with the {X_PFX} prefix, -followed by a string matching the base pattern.""" +"""The base pattern for experimental namespaces with the x_ prefix.""" + +BASE_NS_PATTERN = rf"(?:{EXPERIMENTAL_BASE}|{BASE_PATTERN})" +"""The complete base namespace pattern.""" -BASE_NS_PATTERN = rf"({EXPERIMENTAL_BASE}|{BASE_PATTERN})" -"""The complete base namespace pattern, which allows for experimental namespaces.""" +# --- Extension Segments --- +# Single extension segment between slashes. +# Requirements: +# - Starts with a letter +# - May contain '.', '-', or '#' as separators +# - No consecutive '.' or '-' +# - At most one '#' EXT_SEGMENT_PATTERN = ( - r"(?!.*[.-]{2,})" # no consecutive separators - r"[a-zA-Z][a-zA-Z0-9]*" # first part starts with a letter, followed by zero or more alphanumeric characters - r"(?:[.-][a-zA-Z0-9]+)*" # remaining parts can have alphanumeric characters and single ., -, / separators + r"(?!.*#.*#)" # at most one hash + r"(?!.*[.-]{2,})" # no consecutive dots or hyphens + r"[a-zA-Z][a-zA-Z0-9]*" # must start with a letter + r"(?:[.#-][a-zA-Z0-9]+)*" # allowed separators with alphanumerics ) -"""The pattern for extension segments in namespaces, which must start with a letter and contain alphanumeric characters or -limited punctuation characters (., -), with no consecutive punctuation characters allowed.""" +"""The pattern for a single extension segment.""" -LANG_EXT_PATTERN = rf"(/({BCP_47_PATTERN})/|//)" -# Language extension pattern (BCP-47 or empty for //) -"""The pattern for the first extension segment, which must be either a valid BCP-47 tag or empty (//).""" +# Language extension pattern: either // or // +LANG_EXT_PATTERN = rf"(?:/{BCP_47_PATTERN}/|//)" +"""The first extension segment, either empty (//) or a valid BCP-47 tag.""" +# Subsequent extension segments (zero or more) SUBSEQUENT_EXT_PATTERN = rf"{EXT_SEGMENT_PATTERN}(?:/{EXT_SEGMENT_PATTERN})*" -# Subsequent extension segments -"""The pattern for subsequent extension segments, which must follow the rules for extension segments, delimited by slashes (/).""" +"""The pattern for all subsequent extension segments.""" +# --- Full Namespace Pattern --- NS_PATTERN = re.compile( - rf"^{LENGTH_CHECK_PATTERN}({BASE_NS_PATTERN})({LANG_EXT_PATTERN}{SUBSEQUENT_EXT_PATTERN})?$" + rf"^{LENGTH_CHECK_PATTERN}{BASE_NS_PATTERN}(?:{LANG_EXT_PATTERN}{SUBSEQUENT_EXT_PATTERN})?$" ) f"""The full regular expression pattern for validating namespaces. diff --git a/src/test/test_namespaces_pattern.py b/src/test/test_namespaces_pattern.py index 54aeff84..d936dac0 100644 --- a/src/test/test_namespaces_pattern.py +++ b/src/test/test_namespaces_pattern.py @@ -25,6 +25,7 @@ from ssvc.utils.patterns import ( BASE_NS_PATTERN, BASE_PATTERN, + EXT_SEGMENT_PATTERN, LENGTH_CHECK_PATTERN, NS_PATTERN, ) @@ -46,7 +47,8 @@ def setUp(self): "x_custom//extension", # double slash is okay when it's the first segment "x_private-test", # valid namespace with x_ prefix and dash (does not follow reverse domain notation) "x_com.example//custom-extension", # x_prefix, reverse domain notation, double slash, dashes - "ssvc/de-DE/example.org/reference-arch-1", # valid BCP-47 tag, reverse domain notation, dashes + "ssvc/de-DE/example.organization#reference-arch-1", # valid BCP-47 tag, reverse domain notation, hash + "ssvc//example.organization#model/com.example#foo", # valid BCP-47 tag, two segments with one hash each "ssvc/de-DE/reference-arch-1", # valid BCP-47 tag with dashes (But doesn't follow reverse domain notation) "x_test/pl-PL/foo/bar/baz/quux", # valid BCP-47 tag and multiple segments "com.example", # valid namespace with dots following reverse domain notation @@ -74,6 +76,8 @@ def setUp(self): "x_test/not-bcp-47", # not a valid BCP-47 tag "x_custom/extension/with/multiple/segments/" + "a" * 990, # exceeds max length + "ssvc/de-DE/example.organization##reference-arch-1", # valid BCP-47 tag, reverse domain notation, double hash + "ssvc/de-DE/example.organization#multi#hash#forbidden", # valid BCP-47 tag, reverse domain notation, more than one hash per segment "x_custom.extension.", # ends with punctuation "x_custom..extension", # double dot "x_custom/", # ends with slash @@ -105,6 +109,9 @@ def test_base_pattern(self): "a", # too short "9abc", # starts with a number "x_foo", # no x_ in base pattern + "com.example#foo", # no hashes in base + "com.example##foo", # double hash + "com.example#foo#bar", # multiple hashes not allowed "contains..double.dot", # double dot "contains--double-dash", # double dash "contains_underscore", # underscore not allowed @@ -205,6 +212,34 @@ def test_length_check_pattern(self): re.match(LENGTH_CHECK_PATTERN, ns), f"Should not match: {ns}" ) + def test_ext_segment_pattern(self): + """ + Test the extension segment pattern. + The pattern should allow valid extension segments and disallow invalid ones. + """ + valid_segments = [ + "valid", + "valid.extension", + "valid-extension", + "valid#extension", + "valid.extension#with-hash", + "com.example#foo", # valid namespace with hash + ] + invalid_segments = [ + "a_bc", # underscore not allowed + "invalid..segment", # double dot + "invalid--segment", # double dash + "invalid.segment.", # ends with a dot + "invalid.segment-", # ends with a dash + "invalid/segment", # slash not allowed + "com.example##foo", # valid namespace with hash + "invalid#segment#with#multiple#hashes", # multiple hashes not allowed + "invalid/segment/", # ends with a slash + ] + self._test_successes_failures( + EXT_SEGMENT_PATTERN, invalid_segments, valid_segments + ) + if __name__ == "__main__": unittest.main() From 0a2ee249e71187dc2a2ad64cfc0382e330272104 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Mon, 21 Jul 2025 16:16:25 -0400 Subject: [PATCH 48/99] make tests pass --- src/ssvc/utils/patterns.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/ssvc/utils/patterns.py b/src/ssvc/utils/patterns.py index 8a4fa3af..554251d6 100644 --- a/src/ssvc/utils/patterns.py +++ b/src/ssvc/utils/patterns.py @@ -64,10 +64,9 @@ # - No consecutive '.' or '-' # - At most one '#' EXT_SEGMENT_PATTERN = ( - r"(?!.*#.*#)" # at most one hash r"(?!.*[.-]{2,})" # no consecutive dots or hyphens - r"[a-zA-Z][a-zA-Z0-9]*" # must start with a letter - r"(?:[.#-][a-zA-Z0-9]+)*" # allowed separators with alphanumerics + r"[a-zA-Z][a-zA-Z0-9]*(?:[.-][a-zA-Z0-9]+)*" # main part, will handle reverse domain style + r"(?:#[a-zA-Z0-9]+(?:[.-][a-zA-Z0-9]+)*)?" # optional single hash part ) """The pattern for a single extension segment.""" From 37c55e3bcff9d2e764a17cf4b673b53219c78a07 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Mon, 21 Jul 2025 16:29:13 -0400 Subject: [PATCH 49/99] revise docstring, add abnf draft --- src/ssvc/utils/patterns.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/ssvc/utils/patterns.py b/src/ssvc/utils/patterns.py index 554251d6..b674899d 100644 --- a/src/ssvc/utils/patterns.py +++ b/src/ssvc/utils/patterns.py @@ -101,15 +101,29 @@ - Extensions are optional - Extensions must be delineated by slashes (`/`) - If any extension segments are present, the following rules apply: - - The first extension segment, must be a valid BCP-47 language tag or empty (i.e., `//`). + - The first extension segment must be a valid BCP-47 language tag or empty (i.e., `//`). - Subsequent extension segments: - must start with a letter (upper or lowercase) - - may contain letters, numbers, dots (`.`), and hyphens (`-`) - - must not start or end with a dot or hyphen + - may contain letters, numbers, dots (`.`), hyphens (`-`), and at most one hash (`#`) - must not contain consecutive dots or hyphens (no `..`, `--`, `.-`, `-.`, `---`, etc.) + - if a hash is present, it separates the main part from an optional fragment part - are separated by single forward slashes (`/`) - multiple extension segments are allowed +!!! info "ABNF Notation" + + namespace = base-ns [extensions] + base-ns = [x-prefix] ns-core + x-prefix = "x_" + ns-core = LOWER 1*ALNUMLOW *("." / "-" 1*ALNUMLOW) + extensions = lang-ext [*("/" ext-seg)] + lang-ext = "//" / ("/" bcp47 "/") + ext-seg = ALPHA *ALNUM *("." / "-" 1*ALNUM) ["#" 1*ALNUM *("." / "-" 1*ALNUM)] + bcp47 = (2*3ALPHA ["-" 3ALPHA *2("-" 3ALPHA)] / 4*8ALPHA) ["-" 4ALPHA] ["-" (2ALPHA / 3DIGIT)] *("-" (5*8ALNUM / DIGIT 3ALNUM)) *("-" %x41-57.59-5A.61-7A.7C-7E "-" 2*8ALNUM) ["-" %x58.78 1*("-" 1*8ALNUM)] / %x58.78 1*("-" 1*8ALNUM) / %x49.69 "-" %x44.64 %x45.65 %x46.66 %x41.61 %x55.75 %x4C.6C %x54.74 / %x49.69 "-" %x4D.6D %x49.69 %x4E.6E %x47.67 %x4F.6F + LOWER = %x61-7A + ALNUMLOW = LOWER / DIGIT + ; constraints: 3-1000 chars total, no consecutive separators + """ From f9904fc3687a46e9125ace88c0d02c25343c1653 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Mon, 21 Jul 2025 16:44:01 -0400 Subject: [PATCH 50/99] update ns pattern documentation --- docs/reference/code/namespaces.md | 132 ++++++++++++++++++++---------- src/ssvc/utils/patterns.py | 105 ++++++------------------ 2 files changed, 116 insertions(+), 121 deletions(-) diff --git a/docs/reference/code/namespaces.md b/docs/reference/code/namespaces.md index 50cdf330..a57198a4 100644 --- a/docs/reference/code/namespaces.md +++ b/docs/reference/code/namespaces.md @@ -270,53 +270,99 @@ segment of the extension. The following technical requirements are enforced for SSVC namespaces, based on the implementation in `src/ssvc/namespaces.py` and the NS_PATTERN regular expression: -```python exec="true" idprefix="" +!!! info "Namespace Pattern" -from ssvc.utils.patterns import NS_PATTERN + The regular expression used to validate namespaces is: -print(f"`{NS_PATTERN.pattern}`") -``` + ```python exec="true" idprefix="" + + from ssvc.utils.patterns import NS_PATTERN + + print(f"`{NS_PATTERN.pattern}`") + ``` + +### Length Requirements + +- Namespaces must be between 3 and 1000 characters long. + +### Base Namespace Requirements + +- Must start with a lowercase letter +- Must contain at least 3 total characters in the base part (after the optional experimental/private prefix) +- Must contain only lowercase letters, numbers, dots (`.`), and hyphens (`-`) +- Must not contain consecutive dots or hyphens (no `..`, `--`, `.-`, `-.`, `---`, etc.) +- May optionally start with the experimental/private prefix `{X_PFX}`. + +### Extension Requirements (Optional) -- **Length**: Namespaces must be between 3 and 1000 characters long. -- **Base Namespace**: - - Must start with a lowercase letter. - - Must contain at least 3 total characters in the base part (after the optional experimental/private prefix). - - Only lowercase letters, numbers, dots (`.`), and hyphens (`-`) are allowed. - - Must not contain consecutive dots or hyphens (no `..`, `--`, `.-`, `-.`, `---`, etc.). - - Cannot end with a dot or hyphen. - - May optionally start with the experimental/private prefix `x_`. -- **Experimental/Private Namespaces**: - - Must start with `x_` followed by a valid base namespace. -- **Extensions (Optional)**: - - Extensions are optional and must be delineated by slashes (`/`). - - If present, the first extension segment must be a valid BCP-47 language tag or empty (`//`). - - Subsequent extension segments: - - Must start with a letter (upper or lowercase). - - May contain letters, numbers, dots (`.`), and hyphens (`-`). - - Must not start or end with a dot or hyphen. - - Must not contain consecutive dots or hyphens. - - Are separated by single forward slashes (`/`). - - Multiple extension segments are allowed. -- **Examples of valid namespaces**: - - `ssvc` - - `cisa` - - `x_private-test` - - `ssvc/de-DE/reference-arch-1` - - `x_custom//extension` (empty language tag) -- **Examples of invalid namespaces**: - - `custom` (not in enum, no `x_` prefix) - - `x_custom/extension` (first segment must be a language tag) - - `x_custom.extension.` (ends with punctuation) - - `x_custom..extension` (double dot) - - `x_custom/` (ends with slash) - - `x_custom/extension//` (double slash at end) - - `ab` (too short) - - `x_` (too short after prefix) - -These requirements are strictly enforced by the `NS_PATTERN` regular expression -in the codebase. For full details, see the documentation below and -implementation in `src/ssvc/namespaces.py`. +- Extensions are optional +- Extensions must be delineated by slashes (`/`) +- If any extension segments are present, the following rules apply: + - The first extension segment must be a valid BCP-47 language tag or empty (i.e., `//`). + - Subsequent extension segments: + - must start with a letter (upper or lowercase) + - may contain letters, numbers, dots (`.`), hyphens (`-`), and at most one hash (`#`) + - must not contain consecutive dots or hyphens (no `..`, `--`, `.-`, `-.`, `---`, etc.) + - if a hash is present, it separates the main part from an optional fragment part + - are separated by single forward slashes (`/`) +- Multiple extension segments are allowed +!!! info "ABNF Notation" + + ```abnf + namespace = base-ns [extensions] + ; Overall namespace must be 3–1000 characters + ; (Enforced via regex length lookahead) + + base-ns = x-base / std-base + x-base = "x_" ns-core + std-base = ns-core + + ; ns-core starts with a lowercase letter and may have '.' or '-' separators. + ; Consecutive '.' or '-' are not allowed. + ns-core = LOWER ALNUMLOW *("." / "-" 1*ALNUMLOW) + + extensions = lang-ext [ *("/" ext-seg) ] + + ; Language extension: either // (empty language extension) + ; or // (BCP-47 language code) + lang-ext = "//" / ( "/" bcp47 "/" ) + + ; Extension segment between slashes. + ; - Must start with ALPHA + ; - May have '.' or '-' separators + ; - Optional '#' section, at most one per segment + ; - No consecutive '.' or '-' + ext-seg = ALPHA ALNUM * + ( ("." / "-") 1*ALNUM ) * + [ "#" 1*ALNUM * ( ("." / "-") 1*ALNUM ) ] + + ; BCP-47 tag (based on the regex expansion) + bcp47 = ( 2*3ALPHA + [ "-" 3ALPHA *2( "-" 3ALPHA ) ] + / 4*8ALPHA ) + [ "-" 4ALPHA ] + [ "-" ( 2ALPHA / 3DIGIT ) ] + * ( "-" ( 5*8ALNUM / DIGIT 3ALNUM ) ) + * ( "-" %x41-57.59-5A.61-7A.7C-7E "-" 2*8ALNUM ) + [ "-" %x58.78 1*( "-" 1*8ALNUM ) ] + / %x58.78 1*( "-" 1*8ALNUM ) + / %x49.69 "-" %x44.64 %x45.65 %x46.66 %x41.61 %x55.75 %x4C.6C %x54.74 + / %x49.69 "-" %x4D.6D %x49.69 %x4E.6E %x47.67 %x4F.6F + + ; Character sets + LOWER = %x61-7A ; a-z + ALPHA = %x41-5A / %x61-7A ; A-Z / a-z + DIGIT = %x30-39 ; 0-9 + ALNUM = ALPHA / DIGIT + ALNUMLOW = LOWER / DIGIT + + ; Constraints: + ; - No consecutive "." or "-" in ns-core or ext-seg. + ; - Each ext-seg can contain at most one "#". + ; - Overall namespace is 3–1000 chars. + ``` + ## The `ssvc.namespaces` module The `ssvc.namespaces` module provides a way to access and use these namespaces. diff --git a/src/ssvc/utils/patterns.py b/src/ssvc/utils/patterns.py index b674899d..c3cef410 100644 --- a/src/ssvc/utils/patterns.py +++ b/src/ssvc/utils/patterns.py @@ -24,8 +24,6 @@ import re -from ssvc.utils.defaults import MAX_NS_LENGTH, MIN_NS_LENGTH, X_PFX - # from https://semver.org/ VERSION_PATTERN = r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$" """A regular expression pattern for semantic versioning (semver).""" @@ -37,94 +35,45 @@ # --- Namespace Regex Components --- -# Length check -LENGTH_CHECK_PATTERN = rf"(?=.{{{MIN_NS_LENGTH},{MAX_NS_LENGTH}}}$)" -"""Ensures the string is between MIN_NS_LENGTH and MAX_NS_LENGTH characters long.""" +# --- Length constraint --- +LENGTH_CHECK_PATTERN = r"(?=.{3,1000}$)" + +# --- Base namespace --- +NO_CONSECUTIVE_SEP = r"(?!.*[.-]{2,})" # no consecutive '.' or '-' -# Base namespace pattern (before any // or /lang/) BASE_PATTERN = ( - r"(?!.*[.-]{2,})" # no consecutive separators - r"[a-z][a-z0-9]+" # starts with a letter, followed by one or more alphanumeric chars - r"(?:[.-][a-z0-9]+)*" # then . or - followed by alphanumerics + rf"{NO_CONSECUTIVE_SEP}" + r"[a-z][a-z0-9]+" # starts with lowercase letter + 1+ alnum + r"(?:[.-][a-z0-9]+)*" # optional dot or dash + alnum ) -"""The base pattern for namespaces.""" - -EXPERIMENTAL_BASE = rf"{X_PFX}{BASE_PATTERN}" -"""The base pattern for experimental namespaces with the x_ prefix.""" -BASE_NS_PATTERN = rf"(?:{EXPERIMENTAL_BASE}|{BASE_PATTERN})" -"""The complete base namespace pattern.""" +BASE_NS_PATTERN = rf"(?:x_{BASE_PATTERN}|{BASE_PATTERN})" -# --- Extension Segments --- - -# Single extension segment between slashes. -# Requirements: -# - Starts with a letter -# - May contain '.', '-', or '#' as separators -# - No consecutive '.' or '-' -# - At most one '#' +# --- Extension segments --- +# A single ext-seg with at most one '#' EXT_SEGMENT_PATTERN = ( - r"(?!.*[.-]{2,})" # no consecutive dots or hyphens - r"[a-zA-Z][a-zA-Z0-9]*(?:[.-][a-zA-Z0-9]+)*" # main part, will handle reverse domain style - r"(?:#[a-zA-Z0-9]+(?:[.-][a-zA-Z0-9]+)*)?" # optional single hash part + rf"{NO_CONSECUTIVE_SEP}" + r"[a-zA-Z][a-zA-Z0-9]*" # start with a letter + r"(?:[.-][a-zA-Z0-9]+)*" # dot or dash + alnum + r"(?:#[a-zA-Z0-9]+(?:[.-][a-zA-Z0-9]+)*)?" # optional single hash section ) -"""The pattern for a single extension segment.""" -# Language extension pattern: either // or // -LANG_EXT_PATTERN = rf"(?:/{BCP_47_PATTERN}/|//)" -"""The first extension segment, either empty (//) or a valid BCP-47 tag.""" +# Subsequent ext-seg(s) +SUBSEQUENT_EXT = rf"{EXT_SEGMENT_PATTERN}(?:/{EXT_SEGMENT_PATTERN})*" + -# Subsequent extension segments (zero or more) -SUBSEQUENT_EXT_PATTERN = rf"{EXT_SEGMENT_PATTERN}(?:/{EXT_SEGMENT_PATTERN})*" -"""The pattern for all subsequent extension segments.""" +# --- Language extension --- +LANG_EXT = rf"(?:/{BCP_47_PATTERN}/|//)" -# --- Full Namespace Pattern --- -NS_PATTERN = re.compile( - rf"^{LENGTH_CHECK_PATTERN}{BASE_NS_PATTERN}(?:{LANG_EXT_PATTERN}{SUBSEQUENT_EXT_PATTERN})?$" +# --- Combine all parts into the full namespace pattern --- +NS_PATTERN_STR = ( + rf"^{LENGTH_CHECK_PATTERN}" + rf"{BASE_NS_PATTERN}" + rf"(?:{LANG_EXT}{SUBSEQUENT_EXT})?$" ) -f"""The full regular expression pattern for validating namespaces. - -!!! note "Length Requirements" - - - Namespaces must be between {MIN_NS_LENGTH} and {MAX_NS_LENGTH} characters long. - -!!! note "Base Namespace Requirements" - - - Must start with a lowercase letter - - Must contain at least 3 total characters in the base part (after the optional experimental/private prefix) - - Must contain only lowercase letters, numbers, dots (`.`), and hyphens (`-`) - - Must not contain consecutive dots or hyphens (no `..`, `--`, `.-`, `-.`, `---`, etc.) - - May optionally start with the experimental/private prefix `{X_PFX}`. - -!!! note "Extension Requirements (Optional)" - - - Extensions are optional - - Extensions must be delineated by slashes (`/`) - - If any extension segments are present, the following rules apply: - - The first extension segment must be a valid BCP-47 language tag or empty (i.e., `//`). - - Subsequent extension segments: - - must start with a letter (upper or lowercase) - - may contain letters, numbers, dots (`.`), hyphens (`-`), and at most one hash (`#`) - - must not contain consecutive dots or hyphens (no `..`, `--`, `.-`, `-.`, `---`, etc.) - - if a hash is present, it separates the main part from an optional fragment part - - are separated by single forward slashes (`/`) - - multiple extension segments are allowed - -!!! info "ABNF Notation" - - namespace = base-ns [extensions] - base-ns = [x-prefix] ns-core - x-prefix = "x_" - ns-core = LOWER 1*ALNUMLOW *("." / "-" 1*ALNUMLOW) - extensions = lang-ext [*("/" ext-seg)] - lang-ext = "//" / ("/" bcp47 "/") - ext-seg = ALPHA *ALNUM *("." / "-" 1*ALNUM) ["#" 1*ALNUM *("." / "-" 1*ALNUM)] - bcp47 = (2*3ALPHA ["-" 3ALPHA *2("-" 3ALPHA)] / 4*8ALPHA) ["-" 4ALPHA] ["-" (2ALPHA / 3DIGIT)] *("-" (5*8ALNUM / DIGIT 3ALNUM)) *("-" %x41-57.59-5A.61-7A.7C-7E "-" 2*8ALNUM) ["-" %x58.78 1*("-" 1*8ALNUM)] / %x58.78 1*("-" 1*8ALNUM) / %x49.69 "-" %x44.64 %x45.65 %x46.66 %x41.61 %x55.75 %x4C.6C %x54.74 / %x49.69 "-" %x4D.6D %x49.69 %x4E.6E %x47.67 %x4F.6F - LOWER = %x61-7A - ALNUMLOW = LOWER / DIGIT - ; constraints: 3-1000 chars total, no consecutive separators -""" +# Compile the regex with verbose flag for readability (if needed) +NS_PATTERN = re.compile(NS_PATTERN_STR) def main(): From 13c364e8b31dbbfc460b2c0b4ea5d51bd6238ffa Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Mon, 21 Jul 2025 16:44:58 -0400 Subject: [PATCH 51/99] updated NS pattern --- data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json index 3314e5ed..e4cf875d 100644 --- a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json +++ b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json @@ -78,7 +78,7 @@ ], "maxLength": 1000, "minLength": 3, - "pattern": "^(?=.{3,1000}$)((x_(?!.*[.-]{2,})[a-z][a-z0-9]+(?:[.-][a-z0-9]+)*|(?!.*[.-]{2,})[a-z][a-z0-9]+(?:[.-][a-z0-9]+)*))((/((([A-Za-z]{2,3}(-[A-Za-z]{3}(-[A-Za-z]{3}){0,2})?|[A-Za-z]{4,8})(-[A-Za-z]{4})?(-([A-Za-z]{2}|[0-9]{3}))?(-([A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3}))*(-[A-WY-Za-wy-z0-9](-[A-Za-z0-9]{2,8})+)*(-[Xx](-[A-Za-z0-9]{1,8})+)?|[Xx](-[A-Za-z0-9]{1,8})+|[Ii]-[Dd][Ee][Ff][Aa][Uu][Ll][Tt]|[Ii]-[Mm][Ii][Nn][Gg][Oo]))/|//)(?!.*[.-]{2,})[a-zA-Z][a-zA-Z0-9]*(?:[.-][a-zA-Z0-9]+)*(?:/(?!.*[.-]{2,})[a-zA-Z][a-zA-Z0-9]*(?:[.-][a-zA-Z0-9]+)*)*)?$", + "pattern": "^(?=.{3,1000}$)(?:x_(?!.*[.-]{2,})[a-z][a-z0-9]+(?:[.-][a-z0-9]+)*|(?!.*[.-]{2,})[a-z][a-z0-9]+(?:[.-][a-z0-9]+)*)(?:(?:/(([A-Za-z]{2,3}(-[A-Za-z]{3}(-[A-Za-z]{3}){0,2})?|[A-Za-z]{4,8})(-[A-Za-z]{4})?(-([A-Za-z]{2}|[0-9]{3}))?(-([A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3}))*(-[A-WY-Za-wy-z0-9](-[A-Za-z0-9]{2,8})+)*(-[Xx](-[A-Za-z0-9]{1,8})+)?|[Xx](-[A-Za-z0-9]{1,8})+|[Ii]-[Dd][Ee][Ff][Aa][Uu][Ll][Tt]|[Ii]-[Mm][Ii][Nn][Gg][Oo])/|//)(?!.*[.-]{2,})[a-zA-Z][a-zA-Z0-9]*(?:[.-][a-zA-Z0-9]+)*(?:#[a-zA-Z0-9]+(?:[.-][a-zA-Z0-9]+)*)?(?:/(?!.*[.-]{2,})[a-zA-Z][a-zA-Z0-9]*(?:[.-][a-zA-Z0-9]+)*(?:#[a-zA-Z0-9]+(?:[.-][a-zA-Z0-9]+)*)?)*)?$", "title": "Namespace", "type": "string" }, From 4aae5b0ccbabc4adc5785bb231c0358e126a0de7 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Tue, 22 Jul 2025 09:53:55 -0400 Subject: [PATCH 52/99] add # to allowed chars --- src/ssvc/namespaces.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ssvc/namespaces.py b/src/ssvc/namespaces.py index d0f73a48..0c1592b3 100644 --- a/src/ssvc/namespaces.py +++ b/src/ssvc/namespaces.py @@ -36,7 +36,7 @@ class NameSpace(StrEnum): The namespace value must be one of the members of this enum or start with the prefix specified in X_PFX. Namespaces must be {MIN_NS_LENGTH}-{MAX_NS_LENGTH} lowercase characters long and must start with 3-4 alphanumeric characters after the optional prefix. - Limited punctuation characters (/.-) are allowed between alphanumeric characters, but only one at a time. + Limited punctuation characters (#/.-) are allowed between alphanumeric characters, but only one at a time. Example: Following are examples of valid and invalid namespace values: From e26856b15306b3818533f907d4d6966b2b7b6a9e Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Tue, 22 Jul 2025 11:08:19 -0400 Subject: [PATCH 53/99] replace "x_test" with "x_example.test" to bring into line with reverse domain expectation --- docs/reference/code/namespaces.md | 8 ++++---- src/test/decision_points/test_dp_base.py | 8 ++++---- src/test/decision_points/test_dp_helpers.py | 2 +- src/test/dp_groups/test_dp_groups.py | 2 +- src/test/outcomes/test_outcomes.py | 2 +- src/test/test_doc_helpers.py | 2 +- src/test/test_mixins.py | 2 +- src/test/test_namespaces_pattern.py | 4 ++-- src/test/test_policy_generator.py | 4 ++-- src/test/test_selections.py | 4 ++-- 10 files changed, 19 insertions(+), 19 deletions(-) diff --git a/docs/reference/code/namespaces.md b/docs/reference/code/namespaces.md index a57198a4..75c47884 100644 --- a/docs/reference/code/namespaces.md +++ b/docs/reference/code/namespaces.md @@ -127,20 +127,20 @@ namespaces. !!! example "OT Monitoring Service (OTMS) Private Namespace" Organization A creates a set of decision points for testing purposes and - uses the `x_test` namespace. They do not intend to share these decision + uses the `x_example.test` namespace. They do not intend to share these decision points with anyone outside of their organization, so they use the `x_` prefix to indicate that this namespace is private to them. Organization B also creates a set of decision points for testing purposes - and uses the same `x_test` namespace. They also do not intend to share + and uses the same `x_example.test` namespace. They also do not intend to share these decision points with anyone outside of their organization. !!! warning "Namespace Conflicts" Conflicts are possible in the x_ prefix space. In the previous example, Organizations A and B could both choose to use - `x_test`, and there are no guarantees of global uniqueness for the - decision points in the `x_test` namespace. + `x_example.test`, and there are no guarantees of global uniqueness for the + decision points in the `x_example.test` namespace. !!! tip "Private vs Extension Namespaces" diff --git a/src/test/decision_points/test_dp_base.py b/src/test/decision_points/test_dp_base.py index 9c340fc5..d5b1fcf5 100644 --- a/src/test/decision_points/test_dp_base.py +++ b/src/test/decision_points/test_dp_base.py @@ -44,7 +44,7 @@ def setUp(self) -> None: key="bar", description="baz", version="1.0.0", - namespace="x_test", + namespace="x_example.test", values=tuple(self.values), ) @@ -95,7 +95,7 @@ def test_registry_errors_on_duplicate_key(self): dp3 = ssvc.decision_points.ssvc.base.DecisionPoint( name="asdfad", description="asdfasdf", - namespace="x_test-extra", # different namespace + namespace="x_example.test.extra", # different namespace key=self.dp.key, # same key version=self.dp.version, # same version values=tuple(self.values), @@ -131,7 +131,7 @@ def test_registry(self): key="asdfasdf", description="asdfasdf", version="1.33.1", - namespace="x_test", + namespace="x_example.test", values=self.values, ) @@ -157,7 +157,7 @@ def test_ssvc_decision_point(self): self.assertEqual(obj.key, "bar") self.assertEqual(obj.description, "baz") self.assertEqual(obj.version, "1.0.0") - self.assertEqual(obj.namespace, "x_test") + self.assertEqual(obj.namespace, "x_example.test") self.assertEqual(len(self.values), len(obj.values)) def test_ssvc_value_json_roundtrip(self): diff --git a/src/test/decision_points/test_dp_helpers.py b/src/test/decision_points/test_dp_helpers.py index 9be7148d..cd30fe43 100644 --- a/src/test/decision_points/test_dp_helpers.py +++ b/src/test/decision_points/test_dp_helpers.py @@ -31,7 +31,7 @@ def setUp(self) -> None: key="test_dp", description="This is a test decision point", version="1.0.0", - namespace='x_test', + namespace="x_example.test", values=( DecisionPointValue( name="Yes", diff --git a/src/test/dp_groups/test_dp_groups.py b/src/test/dp_groups/test_dp_groups.py index 7feefe94..907f3df6 100644 --- a/src/test/dp_groups/test_dp_groups.py +++ b/src/test/dp_groups/test_dp_groups.py @@ -31,7 +31,7 @@ def setUp(self) -> None: dp = ssvc.decision_points.ssvc.base.DecisionPoint( name=f"Decision Point {i}", key=f"DP_{i}", - namespace="x_test", + namespace="x_example.test", description=f"Description of Decision Point {i}", version="1.0.0", values=( diff --git a/src/test/outcomes/test_outcomes.py b/src/test/outcomes/test_outcomes.py index 1a130481..5d3245c3 100644 --- a/src/test/outcomes/test_outcomes.py +++ b/src/test/outcomes/test_outcomes.py @@ -41,7 +41,7 @@ def test_outcome_group(self): name="Outcome Group", key="OG", description="an outcome group", - namespace="x_test", + namespace="x_example.test", values=tuple(values), ) diff --git a/src/test/test_doc_helpers.py b/src/test/test_doc_helpers.py index 29899026..124dd407 100644 --- a/src/test/test_doc_helpers.py +++ b/src/test/test_doc_helpers.py @@ -26,7 +26,7 @@ class MyTestCase(unittest.TestCase): def setUp(self): self.dp = DecisionPoint( - namespace="x_test", + namespace="x_example.test", name="test name", description="test description", key="TK", diff --git a/src/test/test_mixins.py b/src/test/test_mixins.py index 9744d8f5..41c8d480 100644 --- a/src/test/test_mixins.py +++ b/src/test/test_mixins.py @@ -160,7 +160,7 @@ def test_mixin_combos(self): {"class": _Keyed, "args": {"key": "fizz"}, "has_default": False}, { "class": _Namespaced, - "args": {"namespace": "x_test"}, + "args": {"namespace": "x_example.test"}, "has_default": False, }, { diff --git a/src/test/test_namespaces_pattern.py b/src/test/test_namespaces_pattern.py index d936dac0..f24d9019 100644 --- a/src/test/test_namespaces_pattern.py +++ b/src/test/test_namespaces_pattern.py @@ -50,7 +50,7 @@ def setUp(self): "ssvc/de-DE/example.organization#reference-arch-1", # valid BCP-47 tag, reverse domain notation, hash "ssvc//example.organization#model/com.example#foo", # valid BCP-47 tag, two segments with one hash each "ssvc/de-DE/reference-arch-1", # valid BCP-47 tag with dashes (But doesn't follow reverse domain notation) - "x_test/pl-PL/foo/bar/baz/quux", # valid BCP-47 tag and multiple segments + "x_example.test/pl-PL/foo/bar/baz/quux", # valid BCP-47 tag and multiple segments "com.example", # valid namespace with dots following reverse domain notation "x_com.example", # valid namespace with x_ prefix and dots following reverse domain notation "au.com.example", # valid namespace with dots following reverse domain notation for 2-letter TLD @@ -73,7 +73,7 @@ def setUp(self): "abc/invalid-bcp-47", # not in enum (but that's ok for the pattern), not a valid BCP-47 tag "abc/invalid", # not in enum (but that's ok for the pattern), not a valid BCP-47 tag "x_custom/extension", # not a valid BCP-47 tag - "x_test/not-bcp-47", # not a valid BCP-47 tag + "x_example.test/not-bcp-47", # not a valid BCP-47 tag "x_custom/extension/with/multiple/segments/" + "a" * 990, # exceeds max length "ssvc/de-DE/example.organization##reference-arch-1", # valid BCP-47 tag, reverse domain notation, double hash diff --git a/src/test/test_policy_generator.py b/src/test/test_policy_generator.py index 57f7b033..1dcc6250 100644 --- a/src/test/test_policy_generator.py +++ b/src/test/test_policy_generator.py @@ -39,7 +39,7 @@ def setUp(self) -> None: name="test", description="test", key="TEST", - namespace="x_test", + namespace="x_example.test", values=tuple( [ DecisionPointValue(key=c, name=c, description=c) @@ -56,7 +56,7 @@ def setUp(self) -> None: name=c, description=c, key=c, - namespace="x_test", + namespace="x_example.test", values=tuple( [ DecisionPointValue(name=v, key=v, description=v) diff --git a/src/test/test_selections.py b/src/test/test_selections.py index 839fe033..50982702 100644 --- a/src/test/test_selections.py +++ b/src/test/test_selections.py @@ -28,13 +28,13 @@ class MyTestCase(unittest.TestCase): def setUp(self): self.s1 = selection.MinimalSelection( - namespace="x_test-namespace", + namespace="x_example.test", key="test_key_1", version="1.0.0", values=["value11", "value12"], ) self.s2 = selection.MinimalSelection( - namespace="x_test-namespace", + namespace="x_example.test", key="test_key_2", version="1.0.0", values=["value21", "value22"], From 343542b7bd9da7eaeddd1cbd14a1cca9639920ad Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Tue, 22 Jul 2025 14:34:24 -0400 Subject: [PATCH 54/99] revise documentation --- docs/reference/code/namespaces.md | 351 +++++++++++++++++------------- 1 file changed, 197 insertions(+), 154 deletions(-) diff --git a/docs/reference/code/namespaces.md b/docs/reference/code/namespaces.md index 75c47884..defe180b 100644 --- a/docs/reference/code/namespaces.md +++ b/docs/reference/code/namespaces.md @@ -11,27 +11,70 @@ decision points for SSVC. done by other projects. This helps us maintain clarity in our codebase and to avoid confusion when integrating with other systems or libraries. -!!! tip "Namespace syntax" +## Namespace Structure + +Namespaces are structured as follows: + +```mermaid +--- +title: SSVC Namespace Structure +--- +flowchart LR + base_ns[Base Namespace] + exts[Extensions] + base_ns -->|/| exts +``` - The syntax for namespaces is `/`, where +A namespace consists of a base namespace and optional extensions. - - `base` is the name of the namespace - - `extensions` is an optional set of extensions that can be used to further - specify the decision point. Extensions are delimited by a `/` +### Base Namespace - See below for additional details on SSVC namespace extensions. +The base namespace can be either registered or unregistered. +The following diagram illustrates the structure of the base namespace: -!!! note "Namespace Requirements" +```mermaid +--- +title: Base Namespace Structure +--- +flowchart LR - A full namepace string must be between 3 and 1000 characters long. (We recommend - keeping them short for ease of use.) +subgraph base_ns[Base Namespace] + direction LR + subgraph unregistered[Unregistered Namespace] + direction LR + xpfx[x_] + reverse_ns[Reverse Domain Name Notation] + xpfx --> reverse_ns + end + subgraph registered[Registered Namespace] + direction LR + base_registered[Registered Base Namespace] + end + registered ~~~|OR| unregistered +end +``` + + +!!! info inline end "Current Registered Namespaces" - Further requirements are noted in each section below. + The SSVC project currently has a set of registered namespaces that are + intended to be used as part of the framework. These namespaces are defined + in the `ssvc.namespaces` module and can be accessed via the `NameSpace` enum. + Current registered namespaces are: + ```python exec="true" idprefix="" + from ssvc.namespaces import NameSpace + + for ns in NameSpace: + print(f"- {ns.value}") + ``` -## Registered Namespaces +#### Registered Namespace -Registered namespaces appear in the `Namespaces` enum, and are intended to be used as follows: +Registered namespaces are those that are explicitly defined in the SSVC project. +A list of the current registered namespaces can be found in the sidebar. + +Registered namespaces are intended to be used as follows: - Objects in the `ssvc` namespace are managed by the SSVC project team. We have complete control over these ones. @@ -39,57 +82,21 @@ Registered namespaces appear in the `Namespaces` enum, and are intended to be us but the SSVC team is not responsible for modifying the content or semantics of those decision points. -!!! note "Base Namespace Requirements" - - Base namespaces must start with a letter and contain only lowercase - alphanumeric characters, dots (`.`), and dashes (`-`). - The sole exception is the the `x_` prefix for private namespaces described below. - - Consecutive dots or dashes or combinations thereof are not allowed. - Base namespaces cannot end with a dot or dash. - - For base namespaces only, we chose to use lowercase alphanumeric - characters to ensure consistency and avoid confusion when using namespaces - in code. (Extensions may contain mixed case alphanumeric characters, dots, and dashes.) - -The SSVC project may create, at our discretion, new namespaces to reflect -administrative scope for decision points we choose to include for user convenience. - -!!! example "Potential Standards-based namespaces" - - We may in the future add namespaces when needed to reflect different standards - bodies like `nist`, `iso-iec`, `ietf`, `oasis`, etc. - -!!! question "How do I request a new registered namespace?" - - If you have a suggestion for a new registered namespace, please open an - issue in the [SSVC GitHub repository](https://github.com/CERTCC/SSVC/issues) - and provide a brief description of the namespace and its intended use. - -### Current Registered Namespaces - -```python exec="true" idprefix="" -from ssvc.namespaces import NameSpace - -for ns in NameSpace: - print(f"- {ns.value}") -``` - -### Non-`ssvc` Namespaces - -We use namespaces other than `ssvc` to indicate decision points that are based -externally defined standards, specifications, or relevant projects. -We expect for decision points in these namespaces to be technically compatible -with SSVC, but we do not claim any ownership or responsibility for the -underlying specifications or their semantic content. -Objects in these namespaces are provided for the convenience -of SSVC users to allow them to use these decision points in their SSVC -decision models without needing to implement them from scratch. +!!! note "Registered Non-`ssvc` Namespaces" -While we are happy to resolve technical issues with these decision points as -technically implemented in the SSVC project, all suggestions for changes to the -underlying specifications or semantic content should be directed to the -maintainers of the respective projects or standards. + We use namespaces other than `ssvc` to indicate decision points that are based + externally defined standards, specifications, or relevant projects. + We expect for decision points in these namespaces to be technically compatible + with SSVC, but we do not claim any ownership or responsibility for the + underlying specifications or their semantic content. + Objects in these namespaces are provided for the convenience + of SSVC users to allow them to use these decision points in their SSVC + decision models without needing to implement them from scratch. + + While we are happy to resolve technical issues with these decision points as + technically implemented in the SSVC project, all suggestions for changes to the + underlying specifications or semantic content should be directed to the + maintainers of the respective projects or standards. !!! example "The `cvss` namespace" @@ -106,34 +113,34 @@ maintainers of the respective projects or standards. [FIRST CVSS Special Interest Group](https://www.first.org/cvss/) (SIG). -## Private / Experimental Namespaces -Private and experimental namespaces may prepend a prefix `x_` to -an otherwise valid namespace string to create private decision points that -are not intended to be shared outside of a specific scope, e.g., for internal -use only. +!!! example "Potential Standards-based namespaces" -The SSVC project does not manage namespaces with the `x_` prefix, so -collisions may occur across organizations who develop their own private SSVC -namespaces. + We may in the future add namespaces when needed to reflect different standards + bodies like `nist`, `iso-iec`, `ietf`, `oasis`, etc. -!!! warning "Reverse domain name notation recommended" +!!! question "How do I request a new registered namespace?" - We strongly recommend using reverse domain name notation for private namespaces to - avoid conflicts with other users' private namespaces. This helps to ensure - that your private namespaces are unique and easily identifiable. - E.g., `x_org.cert-experimental` for an experimental namespace within the CERT organization. + If you have a suggestion for a new registered namespace, please open an + issue in the [SSVC GitHub repository](https://github.com/CERTCC/SSVC/issues) + and provide a brief description of the namespace and its intended use. + +#### Unregistered Namespace -!!! example "OT Monitoring Service (OTMS) Private Namespace" +Unregistered namespaces are those that are not explicitly defined in the SSVC project. +Because unregistered namespaces are not managed by the SSVC project team, +there is no strict guarantee of uniqueness across different users or organizations. +However, because we require unregistered namespaces to use reverse domain name notation, +we expect that this will rarely lead to conflicts in practice. - Organization A creates a set of decision points for testing purposes and - uses the `x_example.test` namespace. They do not intend to share these decision - points with anyone outside of their organization, so they use the `x_` - prefix to indicate that this namespace is private to them. +!!! info "Unregistered Namespace Requirements" + + Unregistered namespaces must follow the following structure: + + - Unregistered namespaces must use the `x_` prefix. + - Following the `x_` prefix, unregistered namespaces must use reverse domain name notation to ensure uniqueness. + - Aside from the required `x_` prefix, unregistered namespaces must contain only alphanumeric characters, dots (`.`), and dashes (`-`). - Organization B also creates a set of decision points for testing purposes - and uses the same `x_example.test` namespace. They also do not intend to share - these decision points with anyone outside of their organization. !!! warning "Namespace Conflicts" @@ -142,90 +149,135 @@ namespaces. `x_example.test`, and there are no guarantees of global uniqueness for the decision points in the `x_example.test` namespace. -!!! tip "Private vs Extension Namespaces" - Private namespaces are intended for use within a closed scope - and are not registered with the SSVC project. - In other words, they are not intended to be used outside of a - specific constuency. - For example, an organization might create a private namespace for - decision points that are specific to their internal processes or policies. - Or an information sharing and analysis organization (ISAO) might create a - private namespace for decision points that are specific to their sector. - In contrast, extension namespaces are - intended to extend the existing SSVC namespaces and may be shared with other - users of the SSVC framework. +!!! tip "Test Namespace" + + The `x_example.test` namespace is used for testing purposes and is not intended for production use. + It is used to test the SSVC framework and its components, and may contain decision points that are not fully implemented or tested. -## Namespace Extensions +### Namespace Extensions -We allow users to extend the SSVC namespaces to clarify existing decision -points. -The intent of an extension is to allow clarification of the application of -decision points and their values to specific constituencies. +Namespace extensions allow users to extend the SSVC namespaces to clarify existing decision points +from a base namespace. +Extensions are optional and may be used to refine or clarify existing decision points. +Extensions allow SSVC users to create decision points that are specific to their +constituencies or to provide translations of existing decision points. -- Extensions must not alter the decision point key, version number, or value keys - for any decision point they are derived from. -- Extensions must not alter the meaning of existing values, or add values to - existing decision points in the parent namespace. -- Extensions may reduce the set of values for a decision point in the parent - namespace, but must not add new values. +!!! info "Namespace Extension Requirements" -!!! warning "Extensions are not for new decision points" + Extensions must follow the following requirements: + + - Extensions must not alter the decision point key, version number, or value keys + for any decision point they are derived from. + - Extensions must not alter the meaning of existing values, or add values to + existing decision points in the parent namespace. + - Extensions may reduce the set of values for a decision point in the parent + namespace, but must not add new values. + +!!! question "What if I want to create a new decision point?" + If you want to create a new decision point, please use a private/experimental namespace + as described above instead of an extension. Extensions are not intended to be used to create new decision points. - If you want to create a new decision point, please use a - private/experimental namespace as described above - instead of an extension. -!!! info "Namespace Extension Syntax and Structure" +#### Namespace Extension Structure + +The first extension segment is reserved for an optional BCP-47 language tag, which may be left empty. +When empty, the default language (`en-US`) is implied. + +Subsequent extension segments must begin with a reverse domain name notation string, +and may contain alphanumeric characters (upper or lower case), dots (`.`), and dashes (`-`). +A single fragment identifier (`#`) may be included in an extension segment, but it is optional. +Fragment identifiers can be used to indicate a specific interpretation or context for the extension. + +The following diagram illustrates the structure of namespace extensions: +```mermaid +--- +title: Namespace Extensions +--- +flowchart LR + +subgraph exts[Extensions] + direction LR + subgraph first[1st Extension Segment] + direction TB + lang[Language Tag] + empty_lang[Empty String] + lang ~~~|OR| empty_lang + end + subgraph ext[Subsequent Extension Segments] + direction LR + reverse_ns_ext[Reverse Domain Name Notation] + fragment[#Optional Fragment ID] + reverse_ns_ext --> fragment + end + first -->|/| ext + ext -->|/| ext +end + +base_ns[Base Namespace] +base_ns -->|/| first + +``` - Extension strings may contain alphanumeric characters (upper or lower case), - dots (`.`), and dashes (`-`). - Multiple extension segments are separated by a `/` character. +!!! info "Namespace Extension Requirements" + + Extensions must follow the following structure: + + - Extension segments are separated by slashes (`/`). + - Multiple extension segments are allowed. + - If any extension segments are present, the first segment must be a valid BCP-47 language tag or an empty string. + - When the first segment is left as an empty string, the default language (`en-US`) is implied. + - Subsequent extension segments must begin with a reverse domain name notation string. + - A fragment identifier (`#`) may be included in extension segments, but it is optional. + - Extension segments may contain alphanumeric characters (upper or lower case), dots (`.`), and dashes (`-`), and zero or one hash (`#`). + - Extensions must not alter the decision point key, version number, or value keys for any decision point they are derived from. + - Extensions may reduce the set of values for a decision point in the parent namespace, but must not add new values. The structure of the namespace string is intended to show inheritance for variations on SSVC objects. - Extension order matters. `ssvc/de-DE/ref-arch-1` would describe an extension - for `ref-arch-1` derived from the German (Germany) translation of SSVC. - `ssvc/ref-arch-1/de-DE` would denote an extension of SSVC for `ref-arch-1` - (in English) that had subsequently been translated in to German (Germany). +!!! tip "Extension Order Matters" + Extension order matters. `ssvc/de-DE/example.organization#ref-arch-1` + denotes that (a) a German (Germany) translation of the SSVC decision points + is available, and (b) that this translation has been extended with an extension + by `organization.example` to fit their specific needs for `ref-arch-1`. -!!! note "First Extension Segment Reserved for Language Tag" + On the other hand, `ssvc//example.organization#ref-arch-1/de-DE` + denotes that (a) the `example.organization#ref-arch-1` extension is + available in the default language (`en-US`), and (b) that this extension has + been translated into German (Germany). - The first extension segment is reserved for a language tag, which is - optional but recommended. - This allows users to specify the language of extension, making it easier to - understand and use in different linguistic contexts. - If *any* extensions are present, the first extension segment must be an - (optionally empty) - [BCP-47](https://www.rfc-editor.org/rfc/bcp/bcp47.txt) language tag. - E.g., `ssvc/jp-JP/extension` - - The language may be left empty in which case the default language (`en-US`) is - implied. An unspecified language tag will result in a `//` format. +!!! example "Use of fragment identifiers and language tags" - The use of a language tag in the first segment is intended to be used to - indicate translations of entire sets of decision points. + Imagine an Information Sharing and Analysis Organization (ISAO) `isao.example` + wants to create an extension to refine an existing decision point in the `ssvc` namespace + with additional context for a part of their constituency. They could create an extension + namespace like `ssvc//example.isao#constituency` to indicate that this extension + is specifically tailored for a particular constituency within the ISAO. + Note the empty first segment, which implies the default language (`en-US`). -!!! example "Translation and Localization" - - `ssvc/de-DE` might denote a German translation of the corresponding `ssvc` object. + If they further chose to create a Polish language version of their extension, + they would add a language segment _following_ their extension namespace, + e.g., `ssvc//example.isao#constituency/pl-PL`. Note that this is different + from a hypothetical `ssvc/pl-PL/example.isao#constituency` extension, which would imply + that the `ssvc` namespace has been translated to Polish (Poland) and then extended + (in Polish) with the `example.isao#constituency` extension. !!! example "Refinement of Concepts for a Specific Constituency" A sector-specific information sharing and analysis organization (ISAO) might create an extension for their specific constituency. - For example, say that namespace foo has a decision point for - Regulated System=(Y,N). A medical-focused ISAO might create an extension + For example, say that a hypothetical registered namespace `foo` + has a decision point for `Regulated System=(Y,N)`. + A medical-focused ISAO might create an extension `foo//example.med-isao` where they refine the values to refer to specific regulations. If multiple regulatory regimes exist, they might even have - `foo//example.med-isao/regulation-1` and `foo//example.med-isao/regulation-2` + `foo//example.med-isao#regulation-1` and `foo//example.med-isao#regulation-2` to cover assessment of the appropriate regulations. - ### Usage Suggestions Although we reserved the first segment of the extension for language tags, @@ -234,10 +286,10 @@ segment of the extension. !!! tip "Use BCP-47 Language Tags" - Regardless where they appear in the extension strings, we recommend using - BCP-47 strings for any language-based extension. Note, however that we do not - strictly enforce this recommendation in the SSVC codebase outside of the - first segment. + Regardless where they appear in the extension strings, BCP-47 language tags + must be for any language-based extension. + Note, however that we do not strictly enforce this recommendation in the + SSVC codebase outside of the first extension segment. !!! example "Translation of a custom extension" @@ -249,20 +301,11 @@ segment of the extension. !!! tip "Use Reverse Domain Name Notation for Extensions" - To avoid conflicts with other users' extensions, we recommend using reverse + To avoid conflicts with other users' extensions, we require the use of reverse domain name notation for your extensions. This helps to ensure that your extensions are unique and easily identifiable. For example, if your organization is `example.com`, you might use an extension - like `ssvc//com.example/extension`. - -!!! example "Reverse Domain Name Notation" - - If your organization has a domain name, you can use it as the base for your - extension. This helps to ensure that your extensions are unique and easily - identifiable. - - For example, if your organization is `example.com`, you might use an extension - like `ssvc//com.example/extension`. + like `ssvc//com.example#extension`. ## Technical requirements From 9210646d2f7b0852b10865d9de882af173623b40 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Tue, 22 Jul 2025 14:54:54 -0400 Subject: [PATCH 55/99] refine `target_ids` to avoid nullable values --- ...on_Point_Value_Selection-2-0-0.schema.json | 22 +++------ src/ssvc/selection.py | 46 ++++++++++++++----- 2 files changed, 42 insertions(+), 26 deletions(-) diff --git a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json index 616bd12b..c47bc6df 100644 --- a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json +++ b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json @@ -11,30 +11,22 @@ "type": "string" }, "target_ids": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "minItems": 1, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, "description": "Optional list of identifiers for the item or items (vulnerabilities, reports, advisories, systems, assets, etc.) being evaluated by these selections.", "examples": [ [ - "CVE-2025-0000" + "CVE-1900-0000" ], [ "VU#999999", "GHSA-0123-4567-89ab" ] ], - "title": "Target Ids" + "items": { + "type": "string" + }, + "minItems": 1, + "title": "Target Ids", + "type": "array" }, "selections": { "description": "List of minimal selections made from decision points.", diff --git a/src/ssvc/selection.py b/src/ssvc/selection.py index ce41a84a..332374c5 100644 --- a/src/ssvc/selection.py +++ b/src/ssvc/selection.py @@ -22,9 +22,16 @@ # DM24-0278 from datetime import datetime -from typing import Literal, Optional +from typing import Annotated, Literal, Optional -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from pydantic import ( + BaseModel, + ConfigDict, + Field, + field_validator, + model_serializer, + model_validator, +) from ssvc._mixins import VersionField from ssvc.decision_points.base import DecisionPoint @@ -63,6 +70,9 @@ class MinimalSelection(BaseModel): ) +TargetIdList = Annotated[list[str], Field(min_length=1)] + + class MinimalSelectionList(BaseModel): """ A down-selection of SSVC Decision Points that represent an evaluation at a specific time of a Vulnerability evaluation. @@ -74,13 +84,13 @@ class MinimalSelectionList(BaseModel): description="The schema version of this selection list.", ) - target_ids: Optional[list[str]] = Field( - default=None, + target_ids: TargetIdList = Field( + default_factory=list, description="Optional list of identifiers for the item or items " "(vulnerabilities, reports, advisories, systems, assets, etc.) " "being evaluated by these selections.", examples=[ - ["CVE-2025-0000"], + ["CVE-1900-0000"], ["VU#999999", "GHSA-0123-4567-89ab"], ], min_length=1, @@ -111,14 +121,28 @@ def validate_target_ids(cls, value: Optional[list[str]]) -> Optional[list[str]]: Validate the target_ids field. If target_ids is provided, it must be a non-empty list of strings. """ - if value is not None: - if not isinstance(value, list) or len(value) == 0: - raise ValueError("target_ids must be a non-empty list of strings.") - for item in value: - if not isinstance(item, str): - raise ValueError("Each target_id must be a string.") + if value is None: + return [] + if not isinstance(value, list): + raise ValueError("target_ids must be a list of strings.") + if len(value) == 0: + raise ValueError("target_ids must be a non-empty list of strings.") + for item in value: + if not isinstance(item, str): + raise ValueError("Each target_id must be a string.") return value + @model_serializer + def serialize_model(self) -> dict: + data = dict() + + data["schemaVersion"] = self.schemaVersion + if self.target_ids: + data["targetIds"] = self.target_ids + data["selections"] = self.selections + data["timestamp"] = self.timestamp + return data + def add_selection(self, selection: MinimalSelection) -> None: """ Adds a minimal selection to the list. From 8179b0fdc41eabe42ac5a665475a5d66a7837789 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Tue, 22 Jul 2025 15:01:40 -0400 Subject: [PATCH 56/99] update field descriptions --- src/ssvc/selection.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/ssvc/selection.py b/src/ssvc/selection.py index 332374c5..efae39d4 100644 --- a/src/ssvc/selection.py +++ b/src/ssvc/selection.py @@ -97,7 +97,9 @@ class MinimalSelectionList(BaseModel): ) selections: list[MinimalSelection] = Field( ..., - description="List of minimal selections made from decision points.", + description="List of selections made from decision points. Each selection item corresponds to " + "value keys contained in a specific decision point identified by its namespace, key, and version. " + "Note that selection objects are deliberately minimal objects and do not contain the full decision point details.", min_length=1, ) timestamp: datetime = Field( @@ -202,7 +204,9 @@ def main() -> None: "https://certcc.github.io/SSVC/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json" ) schema["description"] = ( - "This schema defines the structure for selecting SSVC Decision Points and their evaluated values for a given vulnerability. Each vulnerability can have multiple Decision Points, and each Decision Point can have multiple selected values when full certainty is not available." + "This schema defines the structure for selecting SSVC Decision Points and their evaluated values " + "for a given vulnerability. Each vulnerability can have multiple Decision Points, and each " + "Decision Point can have multiple selected values when full certainty is not available." ) # preferred order of fields, just setting for convention From eba76b4ba763663643e59ff92513646c30e38fe4 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Tue, 22 Jul 2025 15:06:13 -0400 Subject: [PATCH 57/99] fix examples in namespace field_specs.py --- src/ssvc/utils/field_specs.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/ssvc/utils/field_specs.py b/src/ssvc/utils/field_specs.py index c50bee1d..0e1eda43 100644 --- a/src/ssvc/utils/field_specs.py +++ b/src/ssvc/utils/field_specs.py @@ -47,9 +47,8 @@ examples=[ "ssvc", "cisa", - "x_com.example//private", - "com.example//some-extension", - "ssvc/de-DE/example.organization/reference-arch-1", + "x_com.example//com.example#private", + "ssvc/de-DE/example.organization#reference-arch-1", ], pattern=NS_PATTERN, min_length=MIN_NS_LENGTH, From 08558750b87398489e1cca697c2fd8d7589bf153 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Tue, 22 Jul 2025 15:10:14 -0400 Subject: [PATCH 58/99] refactor TargetIdList into ssvc.utils.field_specs --- src/ssvc/selection.py | 7 ++----- src/ssvc/utils/field_specs.py | 3 +++ 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/ssvc/selection.py b/src/ssvc/selection.py index e05bc4fb..4180f760 100644 --- a/src/ssvc/selection.py +++ b/src/ssvc/selection.py @@ -22,7 +22,7 @@ # DM24-0278 from datetime import datetime -from typing import Annotated, Literal, Optional +from typing import Literal, Optional from pydantic import ( BaseModel, @@ -34,7 +34,7 @@ ) from ssvc.decision_points.base import DecisionPoint -from ssvc.utils.field_specs import NamespaceString, VersionString +from ssvc.utils.field_specs import NamespaceString, TargetIdList, VersionString SCHEMA_VERSION = "2.0.0" @@ -69,9 +69,6 @@ class MinimalSelection(BaseModel): ) -TargetIdList = Annotated[list[str], Field(min_length=1)] - - class MinimalSelectionList(BaseModel): """ A down-selection of SSVC Decision Points that represent an evaluation at a specific time of a Vulnerability evaluation. diff --git a/src/ssvc/utils/field_specs.py b/src/ssvc/utils/field_specs.py index 0e1eda43..9c4a84df 100644 --- a/src/ssvc/utils/field_specs.py +++ b/src/ssvc/utils/field_specs.py @@ -57,6 +57,9 @@ ] """A string datatype for namespace values, for use in Pydantic models.""" +TargetIdList = Annotated[list[str], Field(min_length=1)] +"""A list of target IDs, for use in Pydantic models.""" + def main(): pass From abc3bde9536908583f6f9f505c6e4e48640a71b6 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Tue, 22 Jul 2025 15:10:37 -0400 Subject: [PATCH 59/99] update schema --- .../v2/Decision_Point_Value_Selection-2-0-0.schema.json | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json index dc7dd90c..fd5a8c30 100644 --- a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json +++ b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json @@ -29,7 +29,7 @@ "type": "array" }, "selections": { - "description": "List of minimal selections made from decision points.", + "description": "List of selections made from decision points. Each selection item corresponds to value keys contained in a specific decision point identified by its namespace, key, and version. Note that selection objects are deliberately minimal objects and do not contain the full decision point details.", "items": { "$ref": "#/$defs/MinimalSelection" }, @@ -64,9 +64,8 @@ "examples": [ "ssvc", "cisa", - "x_com.example//private", - "com.example//some-extension", - "ssvc/de-DE/example.organization/reference-arch-1" + "x_com.example//com.example#private", + "ssvc/de-DE/example.organization#reference-arch-1" ], "maxLength": 1000, "minLength": 3, From 670255803c4b9d24c275ded5ae1b181fa8bf0de4 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Tue, 22 Jul 2025 16:17:41 -0400 Subject: [PATCH 60/99] fix #753 --- docs/adr/0012-ssvc-namespaces.md | 121 +++++++++++++++++++++++++++++++ docs/adr/index.md | 1 + 2 files changed, 122 insertions(+) create mode 100644 docs/adr/0012-ssvc-namespaces.md diff --git a/docs/adr/0012-ssvc-namespaces.md b/docs/adr/0012-ssvc-namespaces.md new file mode 100644 index 00000000..01e64b6b --- /dev/null +++ b/docs/adr/0012-ssvc-namespaces.md @@ -0,0 +1,121 @@ +--- +status: "accepted" +date: 2025-07-22 +deciders: @ahouseholer @sei-vsarvepalli +consulted: @tschmidtb51 +--- +# Use of Namespaces in SSVC objects + +## Context and Problem Statement + +We need to include decision points and other objects that are not directly +defined by the SSVC project team. For example, CVSS vector elements are a +rich source of structured data that can be used to inform SSVC decisions and +modeled as SSVC decision point objects. However, the +[FIRST CVSS SIG](https://www.first.org/cvss) owns the definition of CVSS vector +elements. So we need a way to describe these objects in SSVC format +without making them part of the SSVC specification. + + +## Decision Drivers + +- Need to include decision points based on data, objects, standards, and other + definitions that are not part of the SSVC specification. +- Need to clearly distinguish between objects managed by the SSVC project and + objects provided for convenience by the SSVC project, but whose semantics are + defined by other projects or standards. + +## Considered Options + +- One big pile of objects (effectively no namespaces) +- Use namespaces to distinguish between SSVC project objects and other objects + +## Decision Outcome + +Chosen option: "Use namespaces", because + +- Clearly distinguishes between SSVC project objects and objects derived from other sources +- Allows for extension of SSVC objects with additional data from other sources +- Allows for extensions for langauages, translation, localization, etc. + +Specifically, we intend to use: + +**Registered namespaces** for objects that we create and maintain (even if they are +based on other sources). + +!!! example + + We use the `ssvc` namespace for all SSVC objects that are part of the + main project. We use the `cvss` namespace to contain CVSS vector elements. + +**Unregistered namespaces** for objects that we do not create or maintain, but +that others may want for their own use. Unregistered namespaces must start with +an `x_` prefix followed by a reverse domain name, such as `x_org.example`. +Unregistered namespaces are intended for experimental or private use. + +!!! example + + A government agency might create a set of decision points for internal use + using the `x_example.agency` namespace. This allows them to use SSVC objects + of their own design alongside existig SSVC objects without needing to + register their namespace with the SSVC project. + +**Namespace extensions** for objects that are derived from other objects in an +registered or unregistered namespace. Extensions are not intended to be used to +introduce new objects, but rather to refine existing objects with additional data +or semantics. +Namespace extensions can be used for refining the meaning of decision point +values for a specific constituency, or adding additional nuance to +interpretation of a decision point in a specific context. + +!!! example + + An ISAO might want to refine the meaning of decision point values for their + constituency, and could use `ssvc//example.isao` as the namespace for their + collection of extensions. + +### Consequences + +#### Positive Consequences + +- SSVC users can customize SSVC objects with additional refinements using extensions +- SSVC users can create their own SSVC objects in an unregistered namespace for + their own use, and share them with others +- Facilitates language translation and localization of SSVC objects to specific + constituencies + + +#### Negative Consequences + +- Registered namespaces must be managed and maintained +- Potential for confusion if unregistered namespaces are used without care or + violating the naming conventions + + +### Confirmation + +- Regular expressions are used in the SSVC specification in both python objects + and JSON schema to validate the namespace format. +- Object validators can be used to ensure that namespaces are correctly formatted + and that registered namespaces are used for objects that are part of the SSVC + specification. + + +## Pros and Cons of the Options + +### One big pile of objects + +We started out with all objects having no namespaces, which meant that +all objects were effectively part of the SSVC specification. This was problematic +because it made it difficult to distinguish between objects that were part of the +SSVC specification under our control and objects that were derived from other sources. + +- Good, because it was simple and easy to understand +- Bad, because it made it difficult to distinguish between SSVC project objects and + objects based on specifications we neither created nor maintained + + + +## More Information + +- [SSVC Namespace Documentation](../reference/code/namespaces.md) diff --git a/docs/adr/index.md b/docs/adr/index.md index e99a6286..e613b442 100644 --- a/docs/adr/index.md +++ b/docs/adr/index.md @@ -23,6 +23,7 @@ the decision records that have been made. - [0009 - Outcomes are Ordered Sets](0009-outcomes-are-ordered-sets.md) - [0010 - Outcome Sets are separate from Decision Point Groups](0010-outcome-sets-are-separate-from-decision-point-groups.md) - [0011 - Correspondence between Automatable v2.0.0, Value Density v1.0.0, and CVSS v4](0011-automatable-and-value-density-and-CVSSv4.md) +- [0012 - SSVC Namespaces](0012-ssvc-namespaces.md) ## Rejected Records From 5dcf367f342124c66bec8e51aca9a88843cd8b8d Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Wed, 23 Jul 2025 12:11:29 -0400 Subject: [PATCH 61/99] change how we set default version so that "version" is required in versioned json schemas --- src/ssvc/_mixins.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/ssvc/_mixins.py b/src/ssvc/_mixins.py index 4c3fa7f4..f7e2f15c 100644 --- a/src/ssvc/_mixins.py +++ b/src/ssvc/_mixins.py @@ -23,7 +23,7 @@ from typing import Optional -from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic import BaseModel, ConfigDict, field_validator from semver import Version from ssvc import _schemaVersion @@ -37,7 +37,7 @@ class _Versioned(BaseModel): Mixin class for versioned SSVC objects. """ - version: VersionString = Field(default=DEFAULT_VERSION) + version: VersionString @field_validator("version") @classmethod @@ -53,6 +53,8 @@ def validate_version(cls, value: str) -> str: Raises: ValueError: if the value is not a valid version number """ + if value is None: + value = DEFAULT_VERSION version = Version.parse(value, optional_minor_and_patch=True) return version.__str__() From afe9c61d4b638fded8905b18dd2bba78a3d01ff6 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Wed, 23 Jul 2025 12:11:52 -0400 Subject: [PATCH 62/99] clean up definitions with mixins --- ...on_Point_Value_Selection-2-0-0.schema.json | 48 ++++++++++++------- src/ssvc/selection.py | 30 +++++------- 2 files changed, 44 insertions(+), 34 deletions(-) diff --git a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json index fd5a8c30..e9ee4e9d 100644 --- a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json +++ b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json @@ -55,12 +55,26 @@ ], "additionalProperties": false, "$defs": { + "MinimalDecisionPointValue": { + "description": "A minimal representation of a decision point value.", + "properties": { + "key": { + "title": "Key", + "type": "string" + } + }, + "required": [ + "key" + ], + "title": "MinimalDecisionPointValue", + "type": "object" + }, "MinimalSelection": { "additionalProperties": false, - "description": "A minimal selection object that contains the decision point ID and the selected options.\nThis is used to transition from an SSVC decision point to a selection.", + "description": "A minimal selection object that contains the decision point ID and the selected values.\nThis is used to transition from an SSVC decision point to a selection.", "properties": { "namespace": { - "description": "The namespace of the decision point.", + "description": "The namespace of the SSVC object.", "examples": [ "ssvc", "cisa", @@ -74,14 +88,6 @@ "type": "string" }, "key": { - "description": "The decision point key.", - "examples": [ - "E", - "A", - "MI", - "PSI" - ], - "minLength": 1, "title": "Key", "type": "string" }, @@ -100,17 +106,27 @@ "description": "A list of selected value keys from the decision point values.", "examples": [ [ - "N", - "Y" + { + "key": "N" + }, + { + "key": "Y" + } ], [ - "A", - "B", - "C" + { + "key": "A" + }, + { + "key": "B" + }, + { + "key": "C" + } ] ], "items": { - "type": "string" + "$ref": "#/$defs/MinimalDecisionPointValue" }, "minItems": 1, "title": "Values", diff --git a/src/ssvc/selection.py b/src/ssvc/selection.py index 4180f760..6a262f83 100644 --- a/src/ssvc/selection.py +++ b/src/ssvc/selection.py @@ -33,38 +33,32 @@ model_validator, ) +from ssvc._mixins import _Keyed, _Namespaced, _Valued, _Versioned from ssvc.decision_points.base import DecisionPoint -from ssvc.utils.field_specs import NamespaceString, TargetIdList, VersionString +from ssvc.utils.field_specs import TargetIdList SCHEMA_VERSION = "2.0.0" -class MinimalSelection(BaseModel): +class MinimalDecisionPointValue(_Keyed, BaseModel): + """A minimal representation of a decision point value.""" + + +class MinimalSelection(_Valued, _Versioned, _Keyed, _Namespaced, BaseModel): """ - A minimal selection object that contains the decision point ID and the selected options. + A minimal selection object that contains the decision point ID and the selected values. This is used to transition from an SSVC decision point to a selection. """ model_config = ConfigDict(extra="forbid") - namespace: NamespaceString = Field( - ..., - description="The namespace of the decision point.", - ) - key: str = Field( - ..., - description="The decision point key.", - examples=["E", "A", "MI", "PSI"], - min_length=1, - ) - version: VersionString - values: list[str] = Field( + values: tuple[MinimalDecisionPointValue, ...] = Field( ..., description="A list of selected value keys from the decision point values.", min_length=1, examples=[ - ["N", "Y"], - ["A", "B", "C"], + [{"key": "N"}, {"key": "Y"}], + [{"key": "A"}, {"key": "B"}, {"key": "C"}], ], # Example values ) @@ -165,7 +159,7 @@ def selection_from_decision_point(decision_point: DecisionPoint) -> MinimalSelec "namespace": decision_point.namespace, "key": decision_point.key, "version": decision_point.version, - "values": [val.key for val in decision_point.values], + "values": [{"key": val.key} for val in decision_point.values], } return MinimalSelection(**data) From 53d550d772104965f8b64281d6048f2741c98026 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Wed, 23 Jul 2025 12:39:46 -0400 Subject: [PATCH 63/99] enforce UTC and no milliseconds when serializing out to JSON --- src/ssvc/selection.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/ssvc/selection.py b/src/ssvc/selection.py index 6a262f83..d1c04f45 100644 --- a/src/ssvc/selection.py +++ b/src/ssvc/selection.py @@ -21,7 +21,7 @@ # subject to its own license. # DM24-0278 -from datetime import datetime +from datetime import datetime, timezone from typing import Literal, Optional from pydantic import ( @@ -132,7 +132,12 @@ def serialize_model(self) -> dict: if self.target_ids: data["targetIds"] = self.target_ids data["selections"] = self.selections - data["timestamp"] = self.timestamp + + # 1. Ensure the datetime object is UTC + dt = self.timestamp.astimezone(timezone.utc) + # 2. Format as ISO 8601 with 'Z' for UTC and no milliseconds + data["timestamp"] = dt.strftime("%Y-%m-%dT%H:%M:%SZ") + return data def add_selection(self, selection: MinimalSelection) -> None: From 46c27a9eab9b65a609e17bb35cda4f5f7faebb51 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Wed, 23 Jul 2025 12:49:57 -0400 Subject: [PATCH 64/99] refactor timestamp into a mixin class --- ...on_Point_Value_Selection-2-0-0.schema.json | 24 ++++++++--------- src/ssvc/_mixins.py | 26 ++++++++++++++++++- src/ssvc/selection.py | 6 ++--- 3 files changed, 40 insertions(+), 16 deletions(-) diff --git a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json index e9ee4e9d..0521f2db 100644 --- a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json +++ b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json @@ -4,6 +4,16 @@ "description": "This schema defines the structure for selecting SSVC Decision Points and their evaluated values for a given vulnerability. Each vulnerability can have multiple Decision Points, and each Decision Point can have multiple selected values when full certainty is not available.", "type": "object", "properties": { + "timestamp": { + "description": "Timestamp of the selections, in RFC 3339 format.", + "examples": [ + "2025-01-01T12:00:00Z", + "2025-01-02T15:30:45-04:00" + ], + "format": "date-time", + "title": "Timestamp", + "type": "string" + }, "schemaVersion": { "const": "2.0.0", "description": "The schema version of this selection list.", @@ -36,22 +46,12 @@ "minItems": 1, "title": "Selections", "type": "array" - }, - "timestamp": { - "description": "Timestamp of when the selections were made, in RFC 3339 format.", - "examples": [ - "2025-01-01T12:00:00Z", - "2025-01-02T15:30:45-04:00" - ], - "format": "date-time", - "title": "Timestamp", - "type": "string" } }, "required": [ + "timestamp", "schemaVersion", - "selections", - "timestamp" + "selections" ], "additionalProperties": false, "$defs": { diff --git a/src/ssvc/_mixins.py b/src/ssvc/_mixins.py index f7e2f15c..69ef38ff 100644 --- a/src/ssvc/_mixins.py +++ b/src/ssvc/_mixins.py @@ -2,6 +2,7 @@ """ This module provides mixin classes for adding features to SSVC objects. """ + # Copyright (c) 2023-2025 Carnegie Mellon University. # NO WARRANTY. THIS CARNEGIE MELLON UNIVERSITY AND SOFTWARE # ENGINEERING INSTITUTE MATERIAL IS FURNISHED ON AN "AS-IS" BASIS. @@ -21,9 +22,10 @@ # subject to its own license. # DM24-0278 +from datetime import datetime, timezone from typing import Optional -from pydantic import BaseModel, ConfigDict, field_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from semver import Version from ssvc import _schemaVersion @@ -139,6 +141,28 @@ class _Commented(BaseModel): model_config = ConfigDict(json_encoders={Optional[str]: exclude_if_none}) +class _Timestamped(BaseModel): + """ + Mixin class for timestamped SSVC objects. + """ + + timestamp: datetime = Field( + ..., + description="Timestamp of the SSVC object, in RFC 3339 format.", + examples=["2025-01-01T12:00:00Z", "2025-01-02T15:30:45-04:00"], + ) + + # set the default value to the current time + @model_validator(mode="before") + def set_timestamp(cls, data): + """ + Set the timestamp to the current time if not provided. + """ + if "timestamp" not in data: + data["timestamp"] = datetime.now().astimezone(timezone.utc) + return data + + class _Base(BaseModel): """ Base class for SSVC objects. diff --git a/src/ssvc/selection.py b/src/ssvc/selection.py index d1c04f45..7bd6397e 100644 --- a/src/ssvc/selection.py +++ b/src/ssvc/selection.py @@ -33,7 +33,7 @@ model_validator, ) -from ssvc._mixins import _Keyed, _Namespaced, _Valued, _Versioned +from ssvc._mixins import _Keyed, _Namespaced, _Timestamped, _Valued, _Versioned from ssvc.decision_points.base import DecisionPoint from ssvc.utils.field_specs import TargetIdList @@ -63,7 +63,7 @@ class MinimalSelection(_Valued, _Versioned, _Keyed, _Namespaced, BaseModel): ) -class MinimalSelectionList(BaseModel): +class MinimalSelectionList(_Timestamped, BaseModel): """ A down-selection of SSVC Decision Points that represent an evaluation at a specific time of a Vulnerability evaluation. """ @@ -94,7 +94,7 @@ class MinimalSelectionList(BaseModel): ) timestamp: datetime = Field( ..., - description="Timestamp of when the selections were made, in RFC 3339 format.", + description="Timestamp of the selections, in RFC 3339 format.", examples=["2025-01-01T12:00:00Z", "2025-01-02T15:30:45-04:00"], ) From 8dd88e1ee741033b29086e28ba0cc5d174ffdb29 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Wed, 23 Jul 2025 13:28:55 -0400 Subject: [PATCH 65/99] fix tests --- src/ssvc/_mixins.py | 4 +--- src/test/test_selections.py | 20 ++++++++++++++------ 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/ssvc/_mixins.py b/src/ssvc/_mixins.py index 69ef38ff..76583603 100644 --- a/src/ssvc/_mixins.py +++ b/src/ssvc/_mixins.py @@ -39,7 +39,7 @@ class _Versioned(BaseModel): Mixin class for versioned SSVC objects. """ - version: VersionString + version: VersionString = Field(default=DEFAULT_VERSION) @field_validator("version") @classmethod @@ -55,8 +55,6 @@ def validate_version(cls, value: str) -> str: Raises: ValueError: if the value is not a valid version number """ - if value is None: - value = DEFAULT_VERSION version = Version.parse(value, optional_minor_and_patch=True) return version.__str__() diff --git a/src/test/test_selections.py b/src/test/test_selections.py index 50982702..f2a836a2 100644 --- a/src/test/test_selections.py +++ b/src/test/test_selections.py @@ -21,7 +21,7 @@ from datetime import datetime from ssvc import selection -from ssvc.selection import MinimalSelectionList +from ssvc.selection import MinimalDecisionPointValue, MinimalSelectionList from ssvc.utils.patterns import NS_PATTERN, VERSION_PATTERN @@ -31,13 +31,13 @@ def setUp(self): namespace="x_example.test", key="test_key_1", version="1.0.0", - values=["value11", "value12"], + values=[{"key": "value11"}, {"key": "value12"}], ) self.s2 = selection.MinimalSelection( namespace="x_example.test", key="test_key_2", version="1.0.0", - values=["value21", "value22"], + values=[{"key": "value21"}, {"key": "value22"}], ) self.selections = MinimalSelectionList( selections=[self.s1, self.s2], @@ -73,10 +73,18 @@ def test_minimal_selection_init(self): "Version does not match the required pattern", ) - # values is list of strings - self.assertIsInstance(self.s1.values, list) + # values is list of strings' + self.assertIsInstance(self.s1.values, tuple) for value in self.s1.values: - self.assertIsInstance(value, str, f"Value {value} is not a string") + self.assertIsInstance( + value, + MinimalDecisionPointValue, + f"Value {value} is not a MinimalDecisionPoint", + ) + self.assertTrue( + hasattr(value, "key"), f"Attribute 'key' is missing from {value}" + ) + self.assertIsInstance(value.key, str) def test_minimal_selection_list_init(self): required_attrs = [ From 4746d8e55f72f21804b85b519183b8f4ace32dd7 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Wed, 23 Jul 2025 13:51:31 -0400 Subject: [PATCH 66/99] add keys in parentheses when we generate examples of decision points for documentation --- src/ssvc/doc_helpers.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/ssvc/doc_helpers.py b/src/ssvc/doc_helpers.py index 9ab8a8dc..c491706d 100644 --- a/src/ssvc/doc_helpers.py +++ b/src/ssvc/doc_helpers.py @@ -25,7 +25,7 @@ from ssvc.decision_points.ssvc.base import SsvcDecisionPoint -MD_TABLE_ROW_TEMPLATE = "| {value.name} | {value.description} |" +MD_TABLE_ROW_TEMPLATE = "| {value.name} ({value.key}) | {value.description} |" def markdown_table(dp: SsvcDecisionPoint, indent: int = 0) -> str: @@ -56,9 +56,11 @@ def markdown_table(dp: SsvcDecisionPoint, indent: int = 0) -> str: def example_block_tabbed(dp: SsvcDecisionPoint, indent=4) -> str: """Given a decision point, return a markdown block that contains an example of the decision point.""" + dp_title_str = f"{dp.name} ({dp.key}) v{dp.version}" + indent_ = " " * 4 rows = [] - rows.append(f'!!! note "{dp.name} v{dp.version}"') + rows.append(f'!!! note "{dp_title_str}"') rows.append("") rows.append(indent_ + '=== "Table"') @@ -80,9 +82,11 @@ def example_block( ) -> str: """Given a decision point, return a markdown block that contains an example of the decision point.""" + dp_title_str = f"{dp.name} ({dp.key}) v{dp.version}" + indent_ = " " * indent rows = [] - rows.append(f'!!! note "{dp.name} v{dp.version}"') + rows.append(f'!!! note "{dp_title_str}"') rows.append("") for row in markdown_table(dp).splitlines(): @@ -90,7 +94,7 @@ def example_block( rows.append("") if include_json: - rows.append(indent_ + f'??? example "{dp.name} v{dp.version} JSON Example"') + rows.append(indent_ + f'??? example "{dp_title_str} JSON Example"') rows.append("") for row in json_example(dp, indent=4).splitlines(): rows.append(indent_ + row) From 079d7b29de7ad68e73dfff19c6a3e6c73a13f537 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Wed, 23 Jul 2025 13:56:51 -0400 Subject: [PATCH 67/99] add full identity to dp title strings --- src/ssvc/doc_helpers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ssvc/doc_helpers.py b/src/ssvc/doc_helpers.py index c491706d..bdf68d80 100644 --- a/src/ssvc/doc_helpers.py +++ b/src/ssvc/doc_helpers.py @@ -56,7 +56,7 @@ def markdown_table(dp: SsvcDecisionPoint, indent: int = 0) -> str: def example_block_tabbed(dp: SsvcDecisionPoint, indent=4) -> str: """Given a decision point, return a markdown block that contains an example of the decision point.""" - dp_title_str = f"{dp.name} ({dp.key}) v{dp.version}" + dp_title_str = f"{dp.name} ({dp.namespace}:{dp.key}:{dp.version})" indent_ = " " * 4 rows = [] @@ -82,7 +82,7 @@ def example_block( ) -> str: """Given a decision point, return a markdown block that contains an example of the decision point.""" - dp_title_str = f"{dp.name} ({dp.key}) v{dp.version}" + dp_title_str = f"{dp.name} ({dp.namespace}:{dp.key}:{dp.version})" indent_ = " " * indent rows = [] From 65cef849927839ccaa24ffbd5b11340761374b8d Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Wed, 23 Jul 2025 14:00:17 -0400 Subject: [PATCH 68/99] port over decision point identity method from another branch --- src/ssvc/decision_points/base.py | 8 ++++++++ src/ssvc/doc_helpers.py | 4 ++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/ssvc/decision_points/base.py b/src/ssvc/decision_points/base.py index 2c803235..30eb2261 100644 --- a/src/ssvc/decision_points/base.py +++ b/src/ssvc/decision_points/base.py @@ -187,6 +187,14 @@ class DecisionPoint( def __str__(self): return FIELD_DELIMITER.join([self.namespace, self.key, self.version]) + @property + def id(self): + """ + Return an identity string for the DecisionPoint. + """ + + return FIELD_DELIMITER.join([self.namespace, self.key, self.version]) + @property def str(self) -> str: """ diff --git a/src/ssvc/doc_helpers.py b/src/ssvc/doc_helpers.py index bdf68d80..a78a6fee 100644 --- a/src/ssvc/doc_helpers.py +++ b/src/ssvc/doc_helpers.py @@ -56,7 +56,7 @@ def markdown_table(dp: SsvcDecisionPoint, indent: int = 0) -> str: def example_block_tabbed(dp: SsvcDecisionPoint, indent=4) -> str: """Given a decision point, return a markdown block that contains an example of the decision point.""" - dp_title_str = f"{dp.name} ({dp.namespace}:{dp.key}:{dp.version})" + dp_title_str = f"{dp.name} ({dp.id})" indent_ = " " * 4 rows = [] @@ -82,7 +82,7 @@ def example_block( ) -> str: """Given a decision point, return a markdown block that contains an example of the decision point.""" - dp_title_str = f"{dp.name} ({dp.namespace}:{dp.key}:{dp.version})" + dp_title_str = f"{dp.name} ({dp.id})" indent_ = " " * indent rows = [] From a623e2c6932783a6809d04ab61f32d0e12a1e6ba Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Wed, 23 Jul 2025 14:03:40 -0400 Subject: [PATCH 69/99] fix tests --- src/test/test_doc_helpers.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/test/test_doc_helpers.py b/src/test/test_doc_helpers.py index 124dd407..73d54650 100644 --- a/src/test/test_doc_helpers.py +++ b/src/test/test_doc_helpers.py @@ -48,8 +48,8 @@ def test_markdown_table(self): "\n" "| Value | Definition |\n" "|:-----|:-----------|\n" - "| A | A Definition |\n" - "| B | B Definition |" + "| A (A) | A Definition |\n" + "| B (B) | B Definition |" ) self.assertEqual(result, expected) @@ -61,8 +61,8 @@ def test_markdown_table(self): "\n" " | Value | Definition |\n" " |:-----|:-----------|\n" - " | A | A Definition |\n" - " | B | B Definition |" + " | A (A) | A Definition |\n" + " | B (B) | B Definition |" ) self.assertEqual(indented, expected_indented) @@ -73,8 +73,8 @@ def test_example_block(self): self.assertIn("!!! note", result) self.assertIn("\n | Value | Definition |", result) - self.assertIn("\n | A | A Definition |", result) - self.assertIn("\n | B | B Definition |", result) + self.assertIn("\n | A (A) | A Definition |", result) + self.assertIn("\n | B (B) | B Definition |", result) self.assertIn("\n ??? example", result) self.assertIn("\n ```json", result) From d41bfc3abbf88603ac10c60fb599e4ddfbdd7f3a Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Wed, 23 Jul 2025 15:35:56 -0400 Subject: [PATCH 70/99] add resources and reference lists to selection object --- ...on_Point_Value_Selection-2-0-0.schema.json | 65 ++++++++++++++++++- src/ssvc/selection.py | 59 ++++++++++++++++- 2 files changed, 121 insertions(+), 3 deletions(-) diff --git a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json index 0521f2db..6f7c8fa8 100644 --- a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json +++ b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json @@ -46,6 +46,48 @@ "minItems": 1, "title": "Selections", "type": "array" + }, + "resources": { + "description": "A list of references to resources that provide additional context about the decision points found in this selection.", + "examples": [ + [ + { + "description": "Documentation for a set of decision points", + "uri": "https://example.com/decision_points" + }, + { + "description": "JSON representation of decision point 2", + "uri": "https://example.org/definitions/dp2.json" + }, + { + "description": "A JSON file containing extension decision points in the x_com.example namespace", + "uri": "https://example.com/ssvc/x_com.example/decision_points.json" + } + ] + ], + "items": { + "$ref": "#/$defs/Reference" + }, + "minItems": 1, + "title": "Resources", + "type": "array" + }, + "references": { + "description": "A list of references to resources that provide additional context about the specific values selected.", + "examples": [ + [ + { + "description": "A report on which the selections were based", + "uri": "https://example.com/report" + } + ] + ], + "items": { + "$ref": "#/$defs/Reference" + }, + "minItems": 1, + "title": "References", + "type": "array" } }, "required": [ @@ -92,6 +134,7 @@ "type": "string" }, "version": { + "default": "0.0.1", "description": "The version of the SSVC object. This must be a valid semantic version string.", "examples": [ "1.0.0", @@ -136,11 +179,31 @@ "required": [ "namespace", "key", - "version", "values" ], "title": "MinimalSelection", "type": "object" + }, + "Reference": { + "description": "A reference to a resource that provides additional context about the decision points or selections.", + "properties": { + "uri": { + "format": "uri", + "minLength": 1, + "title": "Uri", + "type": "string" + }, + "description": { + "title": "Description", + "type": "string" + } + }, + "required": [ + "uri", + "description" + ], + "title": "Reference", + "type": "object" } } } \ No newline at end of file diff --git a/src/ssvc/selection.py b/src/ssvc/selection.py index 7bd6397e..7581d8a3 100644 --- a/src/ssvc/selection.py +++ b/src/ssvc/selection.py @@ -25,6 +25,7 @@ from typing import Literal, Optional from pydantic import ( + AnyUrl, BaseModel, ConfigDict, Field, @@ -63,6 +64,13 @@ class MinimalSelection(_Valued, _Versioned, _Keyed, _Namespaced, BaseModel): ) +class Reference(BaseModel): + """A reference to a resource that provides additional context about the decision points or selections.""" + + uri: AnyUrl + description: str + + class MinimalSelectionList(_Timestamped, BaseModel): """ A down-selection of SSVC Decision Points that represent an evaluation at a specific time of a Vulnerability evaluation. @@ -97,6 +105,40 @@ class MinimalSelectionList(_Timestamped, BaseModel): description="Timestamp of the selections, in RFC 3339 format.", examples=["2025-01-01T12:00:00Z", "2025-01-02T15:30:45-04:00"], ) + resources: list[Reference] = Field( + default_factory=list, + min_length=1, + description="A list of references to resources that provide additional context about the decision points found in this selection.", + examples=[ + [ + { + "uri": "https://example.com/decision_points", + "description": "Documentation for a set of decision points", + }, + { + "uri": "https://example.org/definitions/dp2.json", + "description": "JSON representation of decision point 2", + }, + { + "uri": "https://example.com/ssvc/x_com.example/decision_points.json", + "description": "A JSON file containing extension decision points in the x_com.example namespace", + }, + ], + ], + ) + references: list[Reference] = Field( + default_factory=list, + min_length=1, + description="A list of references to resources that provide additional context about the specific values selected.", + examples=[ + [ + { + "uri": "https://example.com/report", + "description": "A report on which the selections were based", + }, + ] + ], + ) @model_validator(mode="before") def set_schema_version(cls, data): @@ -130,13 +172,17 @@ def serialize_model(self) -> dict: data["schemaVersion"] = self.schemaVersion if self.target_ids: - data["targetIds"] = self.target_ids + data["target_ids"] = self.target_ids data["selections"] = self.selections # 1. Ensure the datetime object is UTC dt = self.timestamp.astimezone(timezone.utc) # 2. Format as ISO 8601 with 'Z' for UTC and no milliseconds data["timestamp"] = dt.strftime("%Y-%m-%dT%H:%M:%SZ") + if self.resources: + data["resources"] = [resource.model_dump() for resource in self.resources] + if self.references: + data["references"] = [ref.model_dump() for ref in self.references] return data @@ -184,7 +230,16 @@ def main() -> None: a1 = selection_from_decision_point(dp1) a2 = selection_from_decision_point(dp2) selections = MinimalSelectionList( - schemaVersion=SCHEMA_VERSION, selections=[a1, a2], timestamp=datetime.now() + schemaVersion=SCHEMA_VERSION, + selections=[a1, a2], + timestamp=datetime.now(), + target_ids=["CVE-2025-0001", "GHSA-0123-4567-89ab"], + references=[ + Reference( + uri="https://example.com/report", + description="A report on which the selections were based", + ) + ], ) print(selections.model_dump_json(indent=2, exclude_none=True)) From 4d87f7454a7cacbab7959bb848cd56bb7481a893 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Wed, 23 Jul 2025 16:23:18 -0400 Subject: [PATCH 71/99] post-process schema to ensure that "name" and "description" fields are never required --- ...on_Point_Value_Selection-2-0-0.schema.json | 20 +++- src/ssvc/selection.py | 103 +++++++++++++----- 2 files changed, 94 insertions(+), 29 deletions(-) diff --git a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json index 6f7c8fa8..1a1e55b4 100644 --- a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json +++ b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json @@ -103,6 +103,14 @@ "key": { "title": "Key", "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "title": "Description", + "type": "string" } }, "required": [ @@ -115,6 +123,14 @@ "additionalProperties": false, "description": "A minimal selection object that contains the decision point ID and the selected values.\nThis is used to transition from an SSVC decision point to a selection.", "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "title": "Description", + "type": "string" + }, "namespace": { "description": "The namespace of the SSVC object.", "examples": [ @@ -185,6 +201,7 @@ "type": "object" }, "Reference": { + "additionalProperties": false, "description": "A reference to a resource that provides additional context about the decision points or selections.", "properties": { "uri": { @@ -199,8 +216,7 @@ } }, "required": [ - "uri", - "description" + "uri" ], "title": "Reference", "type": "object" diff --git a/src/ssvc/selection.py b/src/ssvc/selection.py index 7581d8a3..ede18646 100644 --- a/src/ssvc/selection.py +++ b/src/ssvc/selection.py @@ -21,7 +21,7 @@ # subject to its own license. # DM24-0278 -from datetime import datetime, timezone +from datetime import datetime from typing import Literal, Optional from pydantic import ( @@ -30,22 +30,37 @@ ConfigDict, Field, field_validator, - model_serializer, model_validator, ) -from ssvc._mixins import _Keyed, _Namespaced, _Timestamped, _Valued, _Versioned +from ssvc._mixins import ( + _Base, + _Keyed, + _Namespaced, + _Timestamped, + _Valued, + _Versioned, +) from ssvc.decision_points.base import DecisionPoint from ssvc.utils.field_specs import TargetIdList SCHEMA_VERSION = "2.0.0" -class MinimalDecisionPointValue(_Keyed, BaseModel): +class MinimalDecisionPointValue(_Base, _Keyed, BaseModel): """A minimal representation of a decision point value.""" + @model_validator(mode="before") + def set_optional_fields(cls, data): + if "name" not in data: + data["name"] = "" + if "description" not in data: + data["description"] = "" + + return data + -class MinimalSelection(_Valued, _Versioned, _Keyed, _Namespaced, BaseModel): +class MinimalSelection(_Valued, _Versioned, _Keyed, _Namespaced, _Base, BaseModel): """ A minimal selection object that contains the decision point ID and the selected values. This is used to transition from an SSVC decision point to a selection. @@ -63,13 +78,44 @@ class MinimalSelection(_Valued, _Versioned, _Keyed, _Namespaced, BaseModel): ], # Example values ) + @model_validator(mode="before") + def set_optional_fields(cls, data): + if "name" not in data: + data["name"] = "" + if "description" not in data: + data["description"] = "" + return data + + def model_json_schema(cls, **kwargs): + schema = super().model_json_schema(**kwargs) + not_required = ["name", "description"] + if "required" in schema and isinstance(schema["required"], list): + # remove description from required list if it exists + schema["required"] = [ + field for field in schema["required"] if field not in not_required + ] + return schema + class Reference(BaseModel): """A reference to a resource that provides additional context about the decision points or selections.""" + model_config = ConfigDict(extra="forbid") + uri: AnyUrl description: str + # override schema generation to ensure that description is not required + def model_json_schema(cls, **kwargs): + schema = super().model_json_schema(**kwargs) + not_required = ["description"] + if "required" in schema and isinstance(schema["required"], list): + # remove description from required list if it exists + schema["required"] = [ + field for field in schema["required"] if field not in not_required + ] + return schema + class MinimalSelectionList(_Timestamped, BaseModel): """ @@ -142,7 +188,6 @@ class MinimalSelectionList(_Timestamped, BaseModel): @model_validator(mode="before") def set_schema_version(cls, data): - # If schemaVersion is missing, add it if "schemaVersion" not in data: data["schemaVersion"] = SCHEMA_VERSION return data @@ -166,26 +211,6 @@ def validate_target_ids(cls, value: Optional[list[str]]) -> Optional[list[str]]: raise ValueError("Each target_id must be a string.") return value - @model_serializer - def serialize_model(self) -> dict: - data = dict() - - data["schemaVersion"] = self.schemaVersion - if self.target_ids: - data["target_ids"] = self.target_ids - data["selections"] = self.selections - - # 1. Ensure the datetime object is UTC - dt = self.timestamp.astimezone(timezone.utc) - # 2. Format as ISO 8601 with 'Z' for UTC and no milliseconds - data["timestamp"] = dt.strftime("%Y-%m-%dT%H:%M:%SZ") - if self.resources: - data["resources"] = [resource.model_dump() for resource in self.resources] - if self.references: - data["references"] = [ref.model_dump() for ref in self.references] - - return data - def add_selection(self, selection: MinimalSelection) -> None: """ Adds a minimal selection to the list. @@ -242,7 +267,7 @@ def main() -> None: ], ) - print(selections.model_dump_json(indent=2, exclude_none=True)) + print(selections.model_dump_json(indent=2, exclude_none=True, exclude_unset=True)) print("# Schema for MinimalSelectionList") schema = MinimalSelectionList.model_json_schema() @@ -259,6 +284,30 @@ def main() -> None: "Decision Point can have multiple selected values when full certainty is not available." ) + non_required_fields = [ + "name", + "description", + "target_ids", + "resources", + "references", + ] + + # remove non-required fields from the required list + if "required" in schema and isinstance(schema["required"], list): + schema["required"] = [ + field for field in schema["required"] if field not in non_required_fields + ] + + # dive in to find all the required lists in the schema + # don't forget the defs + if "$defs" in schema: + for prop in schema["$defs"].values(): + if isinstance(prop, dict) and "required" in prop: + # remove non-required fields from the required list + prop["required"] = [ + r for r in prop["required"] if r not in non_required_fields + ] + # preferred order of fields, just setting for convention preferred_order = [ "$schema", From 1075c55ec04607f775cca6fb02a8e1b2a102551b Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Wed, 23 Jul 2025 16:29:20 -0400 Subject: [PATCH 72/99] move post-processing into the data class itself --- ...on_Point_Value_Selection-2-0-0.schema.json | 1 + src/ssvc/selection.py | 126 ++++++++++-------- 2 files changed, 68 insertions(+), 59 deletions(-) diff --git a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json index 1a1e55b4..1dac219f 100644 --- a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json +++ b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json @@ -1,6 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://certcc.github.io/SSVC/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json", + "title": "Decision Point Value Selection List", "description": "This schema defines the structure for selecting SSVC Decision Points and their evaluated values for a given vulnerability. Each vulnerability can have multiple Decision Points, and each Decision Point can have multiple selected values when full certainty is not available.", "type": "object", "properties": { diff --git a/src/ssvc/selection.py b/src/ssvc/selection.py index ede18646..f224d495 100644 --- a/src/ssvc/selection.py +++ b/src/ssvc/selection.py @@ -220,6 +220,71 @@ def add_selection(self, selection: MinimalSelection) -> None: """ self.selections.append(selection) + # override schema generation to ensure it's the way we want it + @classmethod + def model_json_schema(cls, **kwargs): + schema = super().model_json_schema(**kwargs) + + schema["title"] = "Decision Point Value Selection List" + schema["$schema"] = "https://json-schema.org/draft/2020-12/schema" + schema["$id"] = ( + "https://certcc.github.io/SSVC/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json" + ) + schema["description"] = ( + "This schema defines the structure for selecting SSVC Decision Points and their evaluated values " + "for a given vulnerability. Each vulnerability can have multiple Decision Points, and each " + "Decision Point can have multiple selected values when full certainty is not available." + ) + + non_required_fields = [ + "name", + "description", + "target_ids", + "resources", + "references", + ] + + # remove non-required fields from the required list + if "required" in schema and isinstance(schema["required"], list): + schema["required"] = [ + field + for field in schema["required"] + if field not in non_required_fields + ] + + # dive in to find all the required lists in the schema + # don't forget the defs + if "$defs" in schema: + for prop in schema["$defs"].values(): + if isinstance(prop, dict) and "required" in prop: + # remove non-required fields from the required list + prop["required"] = [ + r for r in prop["required"] if r not in non_required_fields + ] + + # preferred order of fields, just setting for convention + preferred_order = [ + "$schema", + "$id", + "title", + "description", + "schemaVersion", + "type", + "properties", + "required", + "additionalProperties", + "$defs", + ] + + # create a new dict with the preferred order of fields first + ordered_fields = {k: schema[k] for k in preferred_order if k in schema} + # add the rest of the fields in their original order + for k in schema: + if k not in ordered_fields: + ordered_fields[k] = schema[k] + + return ordered_fields + def selection_from_decision_point(decision_point: DecisionPoint) -> MinimalSelection: """ @@ -272,64 +337,7 @@ def main() -> None: print("# Schema for MinimalSelectionList") schema = MinimalSelectionList.model_json_schema() - # add schema extras - schema.pop("title") - schema["$schema"] = "https://json-schema.org/draft/2020-12/schema" - schema["$id"] = ( - "https://certcc.github.io/SSVC/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json" - ) - schema["description"] = ( - "This schema defines the structure for selecting SSVC Decision Points and their evaluated values " - "for a given vulnerability. Each vulnerability can have multiple Decision Points, and each " - "Decision Point can have multiple selected values when full certainty is not available." - ) - - non_required_fields = [ - "name", - "description", - "target_ids", - "resources", - "references", - ] - - # remove non-required fields from the required list - if "required" in schema and isinstance(schema["required"], list): - schema["required"] = [ - field for field in schema["required"] if field not in non_required_fields - ] - - # dive in to find all the required lists in the schema - # don't forget the defs - if "$defs" in schema: - for prop in schema["$defs"].values(): - if isinstance(prop, dict) and "required" in prop: - # remove non-required fields from the required list - prop["required"] = [ - r for r in prop["required"] if r not in non_required_fields - ] - - # preferred order of fields, just setting for convention - preferred_order = [ - "$schema", - "$id", - "title", - "description", - "schemaVersion", - "type", - "properties", - "required", - "additionalProperties", - "$defs", - ] - - # create a new dict with the preferred order of fields first - ordered_fields = {k: schema[k] for k in preferred_order if k in schema} - # add the rest of the fields in their original order - for k in schema: - if k not in ordered_fields: - ordered_fields[k] = schema[k] - - print(json.dumps(ordered_fields, indent=2)) + print(json.dumps(schema, indent=2)) # find local path to this file import os @@ -343,7 +351,7 @@ def main() -> None: with open(schema_path, "w") as f: print(f"Writing schema to {schema_path}") - json.dump(ordered_fields, f, indent=2) + json.dump(schema, f, indent=2) if __name__ == "__main__": From 11793a22701a1773c8ff1a9750aeed6806da101e Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Wed, 23 Jul 2025 16:33:17 -0400 Subject: [PATCH 73/99] rename Selection and SelectionList objects --- ...on_Point_Value_Selection-2-0-0.schema.json | 48 +++++++++---------- src/ssvc/selection.py | 22 ++++----- src/test/test_selections.py | 12 ++--- 3 files changed, 41 insertions(+), 41 deletions(-) diff --git a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json index 1dac219f..a3cc11a0 100644 --- a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json +++ b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json @@ -42,7 +42,7 @@ "selections": { "description": "List of selections made from decision points. Each selection item corresponds to value keys contained in a specific decision point identified by its namespace, key, and version. Note that selection objects are deliberately minimal objects and do not contain the full decision point details.", "items": { - "$ref": "#/$defs/MinimalSelection" + "$ref": "#/$defs/Selection" }, "minItems": 1, "title": "Selections", @@ -120,7 +120,28 @@ "title": "MinimalDecisionPointValue", "type": "object" }, - "MinimalSelection": { + "Reference": { + "additionalProperties": false, + "description": "A reference to a resource that provides additional context about the decision points or selections.", + "properties": { + "uri": { + "format": "uri", + "minLength": 1, + "title": "Uri", + "type": "string" + }, + "description": { + "title": "Description", + "type": "string" + } + }, + "required": [ + "uri" + ], + "title": "Reference", + "type": "object" + }, + "Selection": { "additionalProperties": false, "description": "A minimal selection object that contains the decision point ID and the selected values.\nThis is used to transition from an SSVC decision point to a selection.", "properties": { @@ -198,28 +219,7 @@ "key", "values" ], - "title": "MinimalSelection", - "type": "object" - }, - "Reference": { - "additionalProperties": false, - "description": "A reference to a resource that provides additional context about the decision points or selections.", - "properties": { - "uri": { - "format": "uri", - "minLength": 1, - "title": "Uri", - "type": "string" - }, - "description": { - "title": "Description", - "type": "string" - } - }, - "required": [ - "uri" - ], - "title": "Reference", + "title": "Selection", "type": "object" } } diff --git a/src/ssvc/selection.py b/src/ssvc/selection.py index f224d495..1a560264 100644 --- a/src/ssvc/selection.py +++ b/src/ssvc/selection.py @@ -60,7 +60,7 @@ def set_optional_fields(cls, data): return data -class MinimalSelection(_Valued, _Versioned, _Keyed, _Namespaced, _Base, BaseModel): +class Selection(_Valued, _Versioned, _Keyed, _Namespaced, _Base, BaseModel): """ A minimal selection object that contains the decision point ID and the selected values. This is used to transition from an SSVC decision point to a selection. @@ -117,7 +117,7 @@ def model_json_schema(cls, **kwargs): return schema -class MinimalSelectionList(_Timestamped, BaseModel): +class SelectionList(_Timestamped, BaseModel): """ A down-selection of SSVC Decision Points that represent an evaluation at a specific time of a Vulnerability evaluation. """ @@ -139,7 +139,7 @@ class MinimalSelectionList(_Timestamped, BaseModel): ], min_length=1, ) - selections: list[MinimalSelection] = Field( + selections: list[Selection] = Field( ..., description="List of selections made from decision points. Each selection item corresponds to " "value keys contained in a specific decision point identified by its namespace, key, and version. " @@ -211,12 +211,12 @@ def validate_target_ids(cls, value: Optional[list[str]]) -> Optional[list[str]]: raise ValueError("Each target_id must be a string.") return value - def add_selection(self, selection: MinimalSelection) -> None: + def add_selection(self, selection: Selection) -> None: """ Adds a minimal selection to the list. Args: - selection (MinimalSelection): The minimal selection to add. + selection (Selection): The minimal selection to add. """ self.selections.append(selection) @@ -286,7 +286,7 @@ def model_json_schema(cls, **kwargs): return ordered_fields -def selection_from_decision_point(decision_point: DecisionPoint) -> MinimalSelection: +def selection_from_decision_point(decision_point: DecisionPoint) -> Selection: """ Converts a decision point to a minimal selection object. @@ -294,7 +294,7 @@ def selection_from_decision_point(decision_point: DecisionPoint) -> MinimalSelec decision_point (DecisionPoint): The decision point to convert. Returns: - MinimalSelection: The resulting minimal selection object. + Selection: The resulting minimal selection object. """ data = { "namespace": decision_point.namespace, @@ -303,7 +303,7 @@ def selection_from_decision_point(decision_point: DecisionPoint) -> MinimalSelec "values": [{"key": val.key} for val in decision_point.values], } - return MinimalSelection(**data) + return Selection(**data) def main() -> None: @@ -319,7 +319,7 @@ def main() -> None: a1 = selection_from_decision_point(dp1) a2 = selection_from_decision_point(dp2) - selections = MinimalSelectionList( + selections = SelectionList( schemaVersion=SCHEMA_VERSION, selections=[a1, a2], timestamp=datetime.now(), @@ -334,8 +334,8 @@ def main() -> None: print(selections.model_dump_json(indent=2, exclude_none=True, exclude_unset=True)) - print("# Schema for MinimalSelectionList") - schema = MinimalSelectionList.model_json_schema() + print("# Schema for SelectionList") + schema = SelectionList.model_json_schema() print(json.dumps(schema, indent=2)) diff --git a/src/test/test_selections.py b/src/test/test_selections.py index f2a836a2..58d54fd1 100644 --- a/src/test/test_selections.py +++ b/src/test/test_selections.py @@ -21,25 +21,25 @@ from datetime import datetime from ssvc import selection -from ssvc.selection import MinimalDecisionPointValue, MinimalSelectionList +from ssvc.selection import MinimalDecisionPointValue, SelectionList from ssvc.utils.patterns import NS_PATTERN, VERSION_PATTERN class MyTestCase(unittest.TestCase): def setUp(self): - self.s1 = selection.MinimalSelection( + self.s1 = selection.Selection( namespace="x_example.test", key="test_key_1", version="1.0.0", values=[{"key": "value11"}, {"key": "value12"}], ) - self.s2 = selection.MinimalSelection( + self.s2 = selection.Selection( namespace="x_example.test", key="test_key_2", version="1.0.0", values=[{"key": "value21"}, {"key": "value22"}], ) - self.selections = MinimalSelectionList( + self.selections = SelectionList( selections=[self.s1, self.s2], timestamp=datetime.now(), target_ids=["target_id_1", "target_id_2"], @@ -112,10 +112,10 @@ def test_minimal_selection_list_init(self): target_id, str, f"Target ID {target_id} is not a string" ) - # selections is a list of MinimalSelection objects + # selections is a list of Selection objects self.assertIsInstance(self.selections.selections, list) for sel in self.selections.selections: - self.assertIsInstance(sel, selection.MinimalSelection) + self.assertIsInstance(sel, selection.Selection) # timestamp is a datetime object self.assertIsInstance(self.selections.timestamp, datetime) From 3d60391ee31b88231f63603fd39725734be210aa Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Wed, 23 Jul 2025 16:35:33 -0400 Subject: [PATCH 74/99] make selection_from_decision_point into a class method of Selection --- ...on_Point_Value_Selection-2-0-0.schema.json | 2 +- src/ssvc/selection.py | 47 ++++++++++--------- 2 files changed, 26 insertions(+), 23 deletions(-) diff --git a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json index a3cc11a0..e2927937 100644 --- a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json +++ b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json @@ -143,7 +143,7 @@ }, "Selection": { "additionalProperties": false, - "description": "A minimal selection object that contains the decision point ID and the selected values.\nThis is used to transition from an SSVC decision point to a selection.", + "description": "A minimal selection object that contains the decision point ID and the selected values.\nThis is used to transition from an SSVC decision point to a selection.\nOther fields like name and description may be copied from the decision point, but are not required.", "properties": { "name": { "title": "Name", diff --git a/src/ssvc/selection.py b/src/ssvc/selection.py index 1a560264..40df5da9 100644 --- a/src/ssvc/selection.py +++ b/src/ssvc/selection.py @@ -64,6 +64,7 @@ class Selection(_Valued, _Versioned, _Keyed, _Namespaced, _Base, BaseModel): """ A minimal selection object that contains the decision point ID and the selected values. This is used to transition from an SSVC decision point to a selection. + Other fields like name and description may be copied from the decision point, but are not required. """ model_config = ConfigDict(extra="forbid") @@ -78,6 +79,28 @@ class Selection(_Valued, _Versioned, _Keyed, _Namespaced, _Base, BaseModel): ], # Example values ) + # class method to convert a decision point to a selection + @classmethod + def from_decision_point(cls, decision_point: DecisionPoint) -> "Selection": + """ + Converts a decision point to a minimal selection object. + + Args: + decision_point (DecisionPoint): The decision point to convert. + + Returns: + Selection: The resulting minimal selection object. + """ + data = { + "namespace": decision_point.namespace, + "key": decision_point.key, + "version": decision_point.version, + "values": [ + MinimalDecisionPointValue(key=val.key) for val in decision_point.values + ], + } + return cls(**data) + @model_validator(mode="before") def set_optional_fields(cls, data): if "name" not in data: @@ -286,26 +309,6 @@ def model_json_schema(cls, **kwargs): return ordered_fields -def selection_from_decision_point(decision_point: DecisionPoint) -> Selection: - """ - Converts a decision point to a minimal selection object. - - Args: - decision_point (DecisionPoint): The decision point to convert. - - Returns: - Selection: The resulting minimal selection object. - """ - data = { - "namespace": decision_point.namespace, - "key": decision_point.key, - "version": decision_point.version, - "values": [{"key": val.key} for val in decision_point.values], - } - - return Selection(**data) - - def main() -> None: """ Prints example selections and their schema in JSON format. @@ -317,8 +320,8 @@ def main() -> None: from ssvc.decision_points.ssvc.safety_impact import LATEST as dp2 import json - a1 = selection_from_decision_point(dp1) - a2 = selection_from_decision_point(dp2) + a1 = Selection.from_decision_point(dp1) + a2 = Selection.from_decision_point(dp2) selections = SelectionList( schemaVersion=SCHEMA_VERSION, selections=[a1, a2], From 0cceff7335556de94005b6004fb9950f3fc92b25 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Wed, 23 Jul 2025 16:49:27 -0400 Subject: [PATCH 75/99] take advantage of `exclude_none=True` --- src/ssvc/selection.py | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/ssvc/selection.py b/src/ssvc/selection.py index 40df5da9..de7391a9 100644 --- a/src/ssvc/selection.py +++ b/src/ssvc/selection.py @@ -59,6 +59,18 @@ def set_optional_fields(cls, data): return data + @model_validator(mode="after") + def validate_values(cls, data): + """ + If name or description are empty strings, set them to None so that + they are not included in the JSON output when serialized using model_dump_json. + """ + if not data.name: + data.name = None + if not data.description: + data.description = None + return data + class Selection(_Valued, _Versioned, _Keyed, _Namespaced, _Base, BaseModel): """ @@ -81,7 +93,9 @@ class Selection(_Valued, _Versioned, _Keyed, _Namespaced, _Base, BaseModel): # class method to convert a decision point to a selection @classmethod - def from_decision_point(cls, decision_point: DecisionPoint) -> "Selection": + def from_decision_point( + cls, decision_point: DecisionPoint, include_optional: bool = False + ) -> "Selection": """ Converts a decision point to a minimal selection object. @@ -99,6 +113,10 @@ def from_decision_point(cls, decision_point: DecisionPoint) -> "Selection": MinimalDecisionPointValue(key=val.key) for val in decision_point.values ], } + for attr in ("name", "description"): + if hasattr(decision_point, attr): + data[attr] = getattr(decision_point, attr) + return cls(**data) @model_validator(mode="before") @@ -109,6 +127,14 @@ def set_optional_fields(cls, data): data["description"] = "" return data + @model_validator(mode="after") + def validate_values(cls, data): + if not data.name: + data.name = None + if not data.description: + data.description = None + return data + def model_json_schema(cls, **kwargs): schema = super().model_json_schema(**kwargs) not_required = ["name", "description"] From f6f068e6c0ab1c8a652b6bee0b4ecfa08dbb35fe Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Wed, 23 Jul 2025 16:52:22 -0400 Subject: [PATCH 76/99] add newline at end of schema file --- data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json | 2 +- src/ssvc/selection.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json index e2927937..a2e013ec 100644 --- a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json +++ b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json @@ -223,4 +223,4 @@ "type": "object" } } -} \ No newline at end of file +} diff --git a/src/ssvc/selection.py b/src/ssvc/selection.py index de7391a9..371bdb45 100644 --- a/src/ssvc/selection.py +++ b/src/ssvc/selection.py @@ -381,6 +381,7 @@ def main() -> None: with open(schema_path, "w") as f: print(f"Writing schema to {schema_path}") json.dump(schema, f, indent=2) + f.write("\n") # Ensure the file ends with a newline if __name__ == "__main__": From 92274ee654a0a179db5e3adb7de9afd82a96cd2c Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Thu, 24 Jul 2025 09:52:31 -0400 Subject: [PATCH 77/99] add selection schema dumper to doctools.py for commit hook --- src/ssvc/doctools.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/ssvc/doctools.py b/src/ssvc/doctools.py index b345ff26..919a757d 100755 --- a/src/ssvc/doctools.py +++ b/src/ssvc/doctools.py @@ -35,6 +35,7 @@ """ import importlib +import json import logging import os import re @@ -44,6 +45,7 @@ REGISTERED_DECISION_POINTS, ) from ssvc.decision_points.ssvc.base import SsvcDecisionPoint +from ssvc.selection import SelectionList logger = logging.getLogger(__name__) @@ -195,6 +197,14 @@ def dump_json(basename: str, dp: DecisionPoint, jsondir: str, overwrite: bool) - return str(json_file) +def dump_selection_schema(filepath: str): + logger.info(f"Dumping schema to {filepath}") + schema = SelectionList.model_json_schema() + with open(filepath, "w") as f: + json.dump(schema, f, indent=2) + f.write("\n") # newline at end of file + + def main(): # we are going to generate three files for each decision point: # - a markdown table that can be used in the decision point documentation @@ -223,6 +233,8 @@ def main(): overwrite = args.overwrite jsondir = args.jsondir + dp_dir = os.path.join(os.path.abspath(jsondir), "decision_points") + find_modules_to_import("./src/ssvc/decision_points", "ssvc.decision_points") find_modules_to_import("./src/ssvc/outcomes", "ssvc.outcomes") @@ -232,7 +244,14 @@ def main(): # for each decision point: for dp in REGISTERED_DECISION_POINTS: - dump_decision_point(jsondir, dp, overwrite) + dump_decision_point(dp_dir, dp, overwrite) + + # dump the selection schema + schemadir = os.path.abspath(os.path.join(jsondir, "..", "schema", "v2")) + schemafile = os.path.join( + schemadir, "Decision_Point_Value_Selection-2-0-0.schema.json" + ) + dump_selection_schema(schemafile) if __name__ == "__main__": From 54f2a14838ff93d78534fcc3a1d315a36729ccff Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Thu, 24 Jul 2025 10:07:26 -0400 Subject: [PATCH 78/99] add unit tests --- src/ssvc/doctools.py | 1 + src/ssvc/selection.py | 2 +- src/test/test_doctools.py | 16 +++- src/test/test_selections.py | 182 ++++++++++++++++++++++++++++++++++++ 4 files changed, 198 insertions(+), 3 deletions(-) diff --git a/src/ssvc/doctools.py b/src/ssvc/doctools.py index 919a757d..b1707c1b 100755 --- a/src/ssvc/doctools.py +++ b/src/ssvc/doctools.py @@ -187,6 +187,7 @@ def dump_json(basename: str, dp: DecisionPoint, jsondir: str, overwrite: bool) - remove_if_exists(json_file) with EnsureDirExists(dirname): try: + logger.info(f"Writing {json_file}") with open(json_file, "x") as f: f.write(dp.model_dump_json(indent=2)) f.write("\n") # newline at end of file diff --git a/src/ssvc/selection.py b/src/ssvc/selection.py index 371bdb45..a00cb95f 100644 --- a/src/ssvc/selection.py +++ b/src/ssvc/selection.py @@ -352,7 +352,7 @@ def main() -> None: schemaVersion=SCHEMA_VERSION, selections=[a1, a2], timestamp=datetime.now(), - target_ids=["CVE-2025-0001", "GHSA-0123-4567-89ab"], + target_ids=["CVE-1900-0001", "GHSA-0123-4567-89ab"], references=[ Reference( uri="https://example.com/report", diff --git a/src/test/test_doctools.py b/src/test/test_doctools.py index 5b38ca71..a41e9fd4 100644 --- a/src/test/test_doctools.py +++ b/src/test/test_doctools.py @@ -166,8 +166,20 @@ def test_dump_json(self): d = json.load(open(json_file)) self.assertEqual(dp.name, d["name"]) - def test_main(self): - pass + def test_dump_selection_schema(self): + schemafile = os.path.join(self.tempdir.name, "selection_schema.json") + self.assertFalse(os.path.exists(schemafile)) + from ssvc.doctools import dump_selection_schema + + dump_selection_schema(schemafile) + self.assertTrue(os.path.exists(schemafile)) + + # file is loadable json + d = json.load(open(schemafile)) + self.assertIn("title", d) + self.assertEqual(d["title"], "Decision Point Value Selection List") + self.assertIn("type", d) + self.assertEqual(d["type"], "object") if __name__ == "__main__": diff --git a/src/test/test_selections.py b/src/test/test_selections.py index 58d54fd1..f64c6819 100644 --- a/src/test/test_selections.py +++ b/src/test/test_selections.py @@ -120,6 +120,188 @@ def test_minimal_selection_list_init(self): # timestamp is a datetime object self.assertIsInstance(self.selections.timestamp, datetime) + def test_minimal_decision_point_value_validators(self): + """Test the model validators for MinimalDecisionPointValue.""" + # Test set_optional_fields validator + value = MinimalDecisionPointValue(key="test_key") + self.assertIsNone(value.name) + self.assertIsNone(value.description) + + # Test with empty strings + value_empty = MinimalDecisionPointValue(key="test_key", name="", description="") + self.assertIsNone(value_empty.name) + self.assertIsNone(value_empty.description) + + def test_selection_validators(self): + """Test the model validators for Selection.""" + # Test with minimal data + selection_minimal = selection.Selection( + namespace="x_example.test", + key="test_key", + version="1.0.0", + values=[{"key": "value1"}], + ) + self.assertIsNone(selection_minimal.name) + self.assertIsNone(selection_minimal.description) + + # Test with empty strings + selection_empty = selection.Selection( + namespace="x_example.test", + key="test_key", + version="1.0.0", + values=[{"key": "value1"}], + name="", + description="", + ) + self.assertIsNone(selection_empty.name) + self.assertIsNone(selection_empty.description) + + def test_from_decision_point(self): + """Test converting a decision point to a selection.""" + from ssvc.decision_points.ssvc.automatable import LATEST as dp + + selection_obj = selection.Selection.from_decision_point(dp) + + self.assertEqual(selection_obj.namespace, dp.namespace) + self.assertEqual(selection_obj.key, dp.key) + self.assertEqual(selection_obj.version, dp.version) + self.assertEqual(len(selection_obj.values), len(dp.values)) + + for sel_val, dp_val in zip(selection_obj.values, dp.values): + self.assertEqual(sel_val.key, dp_val.key) + + def test_reference_model(self): + """Test the Reference model.""" + ref = selection.Reference( + uri="https://example.com/test", description="Test description" + ) + self.assertEqual(str(ref.uri), "https://example.com/test") + self.assertEqual(ref.description, "Test description") + + def test_selection_list_validators(self): + """Test SelectionList validators.""" + # Test schema version is set automatically + sel_list = SelectionList( + selections=[self.s1], + timestamp=datetime.now(), + ) + self.assertEqual(sel_list.schemaVersion, selection.SCHEMA_VERSION) + + def test_target_ids_validation(self): + """Test target_ids field validation.""" + # Test empty list throws ValueError + with self.assertRaises(ValueError): + SelectionList( + selections=[self.s1], + timestamp=datetime.now(), + target_ids=[], + ) + + # Test None throws ValueError + with self.assertRaises(ValueError): + SelectionList( + selections=[self.s1], + timestamp=datetime.now(), + target_ids=None, + ) + + # absent target_ids leads to empty list + sel_list = SelectionList( + selections=[self.s1], + timestamp=datetime.now(), + ) + self.assertEqual(sel_list.target_ids, []) + + # Test valid target_ids + sel_list_valid = SelectionList( + selections=[self.s1], + timestamp=datetime.now(), + target_ids=["CVE-1900-0001"], + ) + self.assertEqual(sel_list_valid.target_ids, ["CVE-1900-0001"]) + + # Test invalid target_ids (non-string) + with self.assertRaises(ValueError): + SelectionList( + selections=[self.s1], + timestamp=datetime.now(), + target_ids=[123], # Invalid: not a string + ) + + def test_add_selection_method(self): + """Test the add_selection method.""" + initial_count = len(self.selections.selections) + new_selection = selection.Selection( + namespace="x_example.test", + key="new_key", + version="1.0.0", + values=[{"key": "new_value"}], + ) + + self.selections.add_selection(new_selection) + self.assertEqual(len(self.selections.selections), initial_count + 1) + self.assertEqual(self.selections.selections[-1], new_selection) + + def test_selection_list_optional_fields(self): + """Test SelectionList with optional fields.""" + ref = selection.Reference( + uri="https://example.com/resource", description="Test resource" + ) + + sel_list = SelectionList( + selections=[self.s1, self.s2], + timestamp=datetime.now(), + target_ids=["CVE-1900-0001"], + resources=[ref], + references=[ref], + ) + + self.assertEqual(len(sel_list.resources), 1) + self.assertEqual(len(sel_list.references), 1) + self.assertEqual(sel_list.resources[0].uri, ref.uri) + + def test_model_json_schema_customization(self): + """Test that JSON schema is properly customized.""" + schema = SelectionList.model_json_schema() + + # Check schema metadata + self.assertEqual(schema["title"], "Decision Point Value Selection List") + self.assertEqual( + schema["$schema"], "https://json-schema.org/draft/2020-12/schema" + ) + self.assertIn("$id", schema) + self.assertIn("description", schema) + + # Check that optional fields are not in required list + required_fields = schema.get("required", []) + optional_fields = [ + "name", + "description", + "target_ids", + "resources", + "references", + ] + for field in optional_fields: + self.assertNotIn(field, required_fields) + + def test_selection_values_validation(self): + """Test that Selection requires at least one value.""" + with self.assertRaises(ValueError): + selection.Selection( + namespace="x_example.test", + key="test_key", + version="1.0.0", + values=[], # Empty values should raise error + ) + + def test_selection_list_minimum_selections(self): + """Test that SelectionList requires at least one selection.""" + with self.assertRaises(ValueError): + SelectionList( + selections=[], # Empty selections should raise error + timestamp=datetime.now(), + ) + if __name__ == "__main__": unittest.main() From b8e091196e6bf04b8fe4dd3646e89c03cef425c4 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Thu, 24 Jul 2025 10:16:17 -0400 Subject: [PATCH 79/99] fix test that was overly spec'ed to log sequence --- src/ssvc/doctools.py | 11 ++++++++++- src/test/test_doctools.py | 14 +++++++++++--- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/ssvc/doctools.py b/src/ssvc/doctools.py index b1707c1b..3dfca898 100755 --- a/src/ssvc/doctools.py +++ b/src/ssvc/doctools.py @@ -198,7 +198,16 @@ def dump_json(basename: str, dp: DecisionPoint, jsondir: str, overwrite: bool) - return str(json_file) -def dump_selection_schema(filepath: str): +def dump_selection_schema(filepath: str) -> None: + """ + Dump the schema for the SelectionList model to a file. + Args: + filepath: The path to the file to write the schema to. + + Returns: + None + + """ logger.info(f"Dumping schema to {filepath}") schema = SelectionList.model_json_schema() with open(filepath, "w") as f: diff --git a/src/test/test_doctools.py b/src/test/test_doctools.py index a41e9fd4..c669f924 100644 --- a/src/test/test_doctools.py +++ b/src/test/test_doctools.py @@ -146,9 +146,17 @@ def test_dump_json(self): # capture logger output with self.assertLogs() as cm: json_file = dump_json(basename, dp, jsondir, overwrite) - self.assertEqual(_jsonfile, json_file) - # logger warns that the file exists - self.assertIn("already exists", cm.output[0]) + self.assertEqual(_jsonfile, json_file) + # logger warns that the file exists + found = False + for line in cm.output: + if not "WARNING" in line: + continue + # it's a warning log + if "already exists" in line: + found = True + break + self.assertTrue(found, "Expected warning about existing file not found") # should overwrite the file overwrite = True From bd55dd68dc8d87fcc37c4c54cb4a30ef97b65c8e Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Thu, 24 Jul 2025 10:28:14 -0400 Subject: [PATCH 80/99] remove default version from selection object --- .../v2/Decision_Point_Value_Selection-2-0-0.schema.json | 2 +- src/ssvc/selection.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json index a2e013ec..9f2d8a43 100644 --- a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json +++ b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json @@ -172,7 +172,6 @@ "type": "string" }, "version": { - "default": "0.0.1", "description": "The version of the SSVC object. This must be a valid semantic version string.", "examples": [ "1.0.0", @@ -217,6 +216,7 @@ "required": [ "namespace", "key", + "version", "values" ], "title": "Selection", diff --git a/src/ssvc/selection.py b/src/ssvc/selection.py index a00cb95f..bce29030 100644 --- a/src/ssvc/selection.py +++ b/src/ssvc/selection.py @@ -42,7 +42,7 @@ _Versioned, ) from ssvc.decision_points.base import DecisionPoint -from ssvc.utils.field_specs import TargetIdList +from ssvc.utils.field_specs import TargetIdList, VersionString SCHEMA_VERSION = "2.0.0" @@ -81,6 +81,9 @@ class Selection(_Valued, _Versioned, _Keyed, _Namespaced, _Base, BaseModel): model_config = ConfigDict(extra="forbid") + # _Versioned has a default value, but here we don't want to have a default + version: VersionString + values: tuple[MinimalDecisionPointValue, ...] = Field( ..., description="A list of selected value keys from the decision point values.", From cc57bb298158b1f00831b5b208b29b7514ba1b7e Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Thu, 24 Jul 2025 10:41:08 -0400 Subject: [PATCH 81/99] Add & refine docstrings --- ...on_Point_Value_Selection-2-0-0.schema.json | 12 +++--- src/ssvc/selection.py | 37 ++++++++++++++++--- 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json index 9f2d8a43..de646fd0 100644 --- a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json +++ b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json @@ -99,7 +99,7 @@ "additionalProperties": false, "$defs": { "MinimalDecisionPointValue": { - "description": "A minimal representation of a decision point value.", + "description": "A minimal representation of a decision point value.\nIntended to parallel the DecisionPointValue object, but with fewer required fields.\nA decision point value is uniquely identified within a decision point by its key.\nGlobally, the combination of Decision Point namespace, key, and version coupled with the value key\nuniquely identifies a value across all decision points and values.\nOther required fields in the DecisionPointValue object, such as name and description, are optional here.", "properties": { "key": { "title": "Key", @@ -122,12 +122,12 @@ }, "Reference": { "additionalProperties": false, - "description": "A reference to a resource that provides additional context about the decision points or selections.", + "description": "A reference to a resource that provides additional context about the decision points or selections.\nThis object is intentionally minimal and contains only the URL and an optional description.", "properties": { - "uri": { + "urL": { "format": "uri", "minLength": 1, - "title": "Uri", + "title": "Url", "type": "string" }, "description": { @@ -136,14 +136,14 @@ } }, "required": [ - "uri" + "urL" ], "title": "Reference", "type": "object" }, "Selection": { "additionalProperties": false, - "description": "A minimal selection object that contains the decision point ID and the selected values.\nThis is used to transition from an SSVC decision point to a selection.\nOther fields like name and description may be copied from the decision point, but are not required.", + "description": "A minimal selection object that contains the decision point ID and the selected values.\nWhile the Selection object parallels the DecisionPoint object, it is intentionally minimal, with\nfewer required fields and no additional metadata, as it is meant to represent a selection made from a\npreviously defined decision point. The expectation is that a Selection object will usually have\nfewer values than the original decision point, as it represents a specific evaluation\nat a specific time and may therefore rule out some values that were previously considered.\nOther fields like name and description may be copied from the decision point, but are not required.", "properties": { "name": { "title": "Name", diff --git a/src/ssvc/selection.py b/src/ssvc/selection.py index bce29030..b1687d4c 100644 --- a/src/ssvc/selection.py +++ b/src/ssvc/selection.py @@ -48,7 +48,14 @@ class MinimalDecisionPointValue(_Base, _Keyed, BaseModel): - """A minimal representation of a decision point value.""" + """ + A minimal representation of a decision point value. + Intended to parallel the DecisionPointValue object, but with fewer required fields. + A decision point value is uniquely identified within a decision point by its key. + Globally, the combination of Decision Point namespace, key, and version coupled with the value key + uniquely identifies a value across all decision points and values. + Other required fields in the DecisionPointValue object, such as name and description, are optional here. + """ @model_validator(mode="before") def set_optional_fields(cls, data): @@ -75,7 +82,11 @@ def validate_values(cls, data): class Selection(_Valued, _Versioned, _Keyed, _Namespaced, _Base, BaseModel): """ A minimal selection object that contains the decision point ID and the selected values. - This is used to transition from an SSVC decision point to a selection. + While the Selection object parallels the DecisionPoint object, it is intentionally minimal, with + fewer required fields and no additional metadata, as it is meant to represent a selection made from a + previously defined decision point. The expectation is that a Selection object will usually have + fewer values than the original decision point, as it represents a specific evaluation + at a specific time and may therefore rule out some values that were previously considered. Other fields like name and description may be copied from the decision point, but are not required. """ @@ -150,11 +161,14 @@ def model_json_schema(cls, **kwargs): class Reference(BaseModel): - """A reference to a resource that provides additional context about the decision points or selections.""" + """ + A reference to a resource that provides additional context about the decision points or selections. + This object is intentionally minimal and contains only the URL and an optional description. + """ model_config = ConfigDict(extra="forbid") - uri: AnyUrl + urL: AnyUrl description: str # override schema generation to ensure that description is not required @@ -171,7 +185,20 @@ def model_json_schema(cls, **kwargs): class SelectionList(_Timestamped, BaseModel): """ - A down-selection of SSVC Decision Points that represent an evaluation at a specific time of a Vulnerability evaluation. + A list decision point selections that represent an evaluation at a specific time of evaluation. + Individual selections are derived from decision points, and each selection + contains a minimal representation of the decision point and the selected values. + + A SelectionList requires a timestamp in RFC 3339 format, which indicates when the selections were made. + + Optional fields include + + - `target_ids`: If present, a non-empty list of identifiers for the item or items being evaluated. + - `resources`: If present, a non-empty list of references to resources that provide additional context about the decision points + found in this selection. Resources point to documentation, JSON files, or other relevant information that + describe what the decision points are and how they should be interpreted. + - `references`: If present, a non-empty list of references to resources that provide additional context about the specific values selected. + References point to reports, advisories, or other relevant information that describe why the selected values were chosen. """ model_config = ConfigDict(extra="forbid") From a89b250c385f78e059caaad7f67efb7dbd038106 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Thu, 24 Jul 2025 10:46:31 -0400 Subject: [PATCH 82/99] revert uri->url back to uri --- .../v2/Decision_Point_Value_Selection-2-0-0.schema.json | 6 +++--- src/ssvc/selection.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json index de646fd0..d7c1c08d 100644 --- a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json +++ b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json @@ -124,10 +124,10 @@ "additionalProperties": false, "description": "A reference to a resource that provides additional context about the decision points or selections.\nThis object is intentionally minimal and contains only the URL and an optional description.", "properties": { - "urL": { + "uri": { "format": "uri", "minLength": 1, - "title": "Url", + "title": "Uri", "type": "string" }, "description": { @@ -136,7 +136,7 @@ } }, "required": [ - "urL" + "uri" ], "title": "Reference", "type": "object" diff --git a/src/ssvc/selection.py b/src/ssvc/selection.py index b1687d4c..574cfd8f 100644 --- a/src/ssvc/selection.py +++ b/src/ssvc/selection.py @@ -168,7 +168,7 @@ class Reference(BaseModel): model_config = ConfigDict(extra="forbid") - urL: AnyUrl + uri: AnyUrl description: str # override schema generation to ensure that description is not required From 5eb833e8bfd7755f7b3405fff2c70170fdc38690 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Thu, 24 Jul 2025 11:07:58 -0400 Subject: [PATCH 83/99] update schema description to reflect recent developments --- .../v2/Decision_Point_Value_Selection-2-0-0.schema.json | 2 +- src/ssvc/selection.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json index d7c1c08d..d4afed52 100644 --- a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json +++ b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://certcc.github.io/SSVC/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json", "title": "Decision Point Value Selection List", - "description": "This schema defines the structure for selecting SSVC Decision Points and their evaluated values for a given vulnerability. Each vulnerability can have multiple Decision Points, and each Decision Point can have multiple selected values when full certainty is not available.", + "description": "This schema defines the structure for representing selected values from SSVC Decision Points. Each selection list can have multiple selection objects, each representing a decision point, and each selection object can have multiple selected values when full certainty (i.e., a singular value selection) is not available.", "type": "object", "properties": { "timestamp": { diff --git a/src/ssvc/selection.py b/src/ssvc/selection.py index 574cfd8f..fe07103a 100644 --- a/src/ssvc/selection.py +++ b/src/ssvc/selection.py @@ -310,9 +310,10 @@ def model_json_schema(cls, **kwargs): "https://certcc.github.io/SSVC/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json" ) schema["description"] = ( - "This schema defines the structure for selecting SSVC Decision Points and their evaluated values " - "for a given vulnerability. Each vulnerability can have multiple Decision Points, and each " - "Decision Point can have multiple selected values when full certainty is not available." + "This schema defines the structure for representing selected values from SSVC Decision Points. " + "Each selection list can have multiple selection objects, each representing a decision point, and each " + "selection object can have multiple selected values when full certainty (i.e., a singular value selection) " + "is not available." ) non_required_fields = [ From 9faaf9a2d0731ec0b92d8b8dedbd73a0fd54acea Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Thu, 24 Jul 2025 11:24:16 -0400 Subject: [PATCH 84/99] fix a bug in namespace validator that may have prevented extensions to registered namespaces (not starting with x_) --- docs/reference/code/selection.md | 3 +++ mkdocs.yml | 3 ++- src/ssvc/namespaces.py | 10 ++++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 docs/reference/code/selection.md diff --git a/docs/reference/code/selection.md b/docs/reference/code/selection.md new file mode 100644 index 00000000..f1a2c466 --- /dev/null +++ b/docs/reference/code/selection.md @@ -0,0 +1,3 @@ +# Selections + +::: ssvc.selection diff --git a/mkdocs.yml b/mkdocs.yml index 80638cda..95671f67 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -106,10 +106,11 @@ nav: - Target Distribution: 'reference/decision_points/cvss/target_distribution.md' - Code: - Intro: 'reference/code/index.md' + - Namespaces: 'reference/code/namespaces.md' + - Selections: 'reference/code/selection.md' - CSV Analyzer: 'reference/code/analyze_csv.md' - Policy Generator: 'reference/code/policy_generator.md' - Outcomes: 'reference/code/outcomes.md' - - Namespaces: 'reference/code/namespaces.md' - Doctools: 'reference/code/doctools.md' - Learning SSVC: - Tutorials: 'tutorials/index.md' diff --git a/src/ssvc/namespaces.py b/src/ssvc/namespaces.py index 0c1592b3..11baf0b5 100644 --- a/src/ssvc/namespaces.py +++ b/src/ssvc/namespaces.py @@ -74,8 +74,18 @@ def validate(cls, value: str) -> str: """ if value in cls.__members__.values(): + # value is explicitly registered in the enum + return value + if any( + [value.startswith(registered) for registered in cls.__members__.values()] + ) and NS_PATTERN.match(value): + # value is a valid namespace that starts with one of the registered namespaces + # and meets the pattern requirements for extensions + # this allows for custom extensions of registered namespaces without needing to register them in the enum return value if value.startswith(X_PFX) and NS_PATTERN.match(value): + # value starts with the experimental prefix and meets the pattern requirements + # this allows for custom namespaces that are not registered in the enum return value raise ValueError( f"Invalid namespace: {value}. Must be one of {[ns.value for ns in cls]} or start with '{X_PFX}'." From afecc432876686fff2504916b48873cbf86a24cf Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Thu, 24 Jul 2025 11:41:47 -0400 Subject: [PATCH 85/99] refine ns validator --- src/ssvc/namespaces.py | 34 +++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/src/ssvc/namespaces.py b/src/ssvc/namespaces.py index 11baf0b5..58dfec2f 100644 --- a/src/ssvc/namespaces.py +++ b/src/ssvc/namespaces.py @@ -73,22 +73,26 @@ def validate(cls, value: str) -> str: ValueError: if the value is not a valid namespace """ - if value in cls.__members__.values(): - # value is explicitly registered in the enum - return value - if any( - [value.startswith(registered) for registered in cls.__members__.values()] - ) and NS_PATTERN.match(value): - # value is a valid namespace that starts with one of the registered namespaces - # and meets the pattern requirements for extensions - # this allows for custom extensions of registered namespaces without needing to register them in the enum - return value - if value.startswith(X_PFX) and NS_PATTERN.match(value): - # value starts with the experimental prefix and meets the pattern requirements - # this allows for custom namespaces that are not registered in the enum - return value + valid = NS_PATTERN.match(value) + + if valid: + # pattern matches, so we can proceed with further checks + # partition always returns three parts: the part before the separator, the separator itself, and the part after the separator + (base_ns, _, extension) = value.partition("/") + # and we don't care about the extension beyond the pattern match above + # so base_ns is either the full value or the part before the first slash + + if base_ns in cls.__members__.values(): + # base_ns is a registered namespaces + return value + + elif base_ns.startswith(X_PFX): + # base_ns might start with x_ + return value + + # if you got here, the value is not a valid namespace raise ValueError( - f"Invalid namespace: {value}. Must be one of {[ns.value for ns in cls]} or start with '{X_PFX}'." + f"Invalid namespace: '{value}' Must be one of {[ns.value for ns in cls]} or start with '{X_PFX}'." ) From ab2ed9478ef9f56ec93aa2d0778b4b25591fac25 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Mon, 28 Jul 2025 15:16:51 -0400 Subject: [PATCH 86/99] Update data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json Co-authored-by: tschmidtb51 <65305130+tschmidtb51@users.noreply.github.com> --- .../Decision_Point_Value_Selection-2-0-0.schema.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json index d4afed52..d2a96b4b 100644 --- a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json +++ b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json @@ -81,6 +81,16 @@ "description": "A report on which the selections were based", "uri": "https://example.com/report" } + ], + [ + { + "description": "A code section on which the selections were based", + "uri": "https://git.example.com/some-relevant-path/code#L21-42" + }, + { + "description": "A code section on which calls the vulnerable function", + "uri": "https://git.example.com/some-relevant-path/callingcode#L91-16" + } ] ], "items": { From 1e77dcd676fe43d7f18e3fd6803105cffabbceba Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Mon, 28 Jul 2025 15:36:15 -0400 Subject: [PATCH 87/99] Update docs/adr/0012-ssvc-namespaces.md Co-authored-by: tschmidtb51 <65305130+tschmidtb51@users.noreply.github.com> --- docs/adr/0012-ssvc-namespaces.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/0012-ssvc-namespaces.md b/docs/adr/0012-ssvc-namespaces.md index 01e64b6b..a9458ec1 100644 --- a/docs/adr/0012-ssvc-namespaces.md +++ b/docs/adr/0012-ssvc-namespaces.md @@ -70,7 +70,7 @@ interpretation of a decision point in a specific context. !!! example - An ISAO might want to refine the meaning of decision point values for their + An ISAO (Information Sharing and Analyzing Organization) might want to refine the meaning of decision point values for their constituency, and could use `ssvc//example.isao` as the namespace for their collection of extensions. From c36eb645c43ad5bfe5d385a4896c39a5e1dbe4b5 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Mon, 28 Jul 2025 15:36:45 -0400 Subject: [PATCH 88/99] Update src/test/test_namespaces_pattern.py Co-authored-by: tschmidtb51 <65305130+tschmidtb51@users.noreply.github.com> --- src/test/test_namespaces_pattern.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/test/test_namespaces_pattern.py b/src/test/test_namespaces_pattern.py index f24d9019..716aa6f3 100644 --- a/src/test/test_namespaces_pattern.py +++ b/src/test/test_namespaces_pattern.py @@ -88,6 +88,7 @@ def setUp(self): "ab", # too short "x_", # too short after prefix "x_x_some-weird-private-one", # double x_ not allowed + "x_example.test///org.example#fragment", # three slashes in a row (was an mistake in ABNF previously) ] def test_ns_pattern(self): From 538020d8ae25609bd2a053b614c19ce30b2d7f9c Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Mon, 28 Jul 2025 15:37:04 -0400 Subject: [PATCH 89/99] Update src/ssvc/selection.py Co-authored-by: tschmidtb51 <65305130+tschmidtb51@users.noreply.github.com> --- src/ssvc/selection.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ssvc/selection.py b/src/ssvc/selection.py index fe07103a..26c0d29a 100644 --- a/src/ssvc/selection.py +++ b/src/ssvc/selection.py @@ -350,10 +350,10 @@ def model_json_schema(cls, **kwargs): "description", "schemaVersion", "type", - "properties", + "$defs", "required", + "properties", "additionalProperties", - "$defs", ] # create a new dict with the preferred order of fields first From e84a4069a0d4c511880fd4ea687142529c8d50f1 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Mon, 28 Jul 2025 15:38:48 -0400 Subject: [PATCH 90/99] Update docs/reference/code/namespaces.md Co-authored-by: tschmidtb51 <65305130+tschmidtb51@users.noreply.github.com> --- docs/reference/code/namespaces.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/reference/code/namespaces.md b/docs/reference/code/namespaces.md index defe180b..ed2ffc02 100644 --- a/docs/reference/code/namespaces.md +++ b/docs/reference/code/namespaces.md @@ -140,6 +140,7 @@ we expect that this will rarely lead to conflicts in practice. - Unregistered namespaces must use the `x_` prefix. - Following the `x_` prefix, unregistered namespaces must use reverse domain name notation to ensure uniqueness. - Aside from the required `x_` prefix, unregistered namespaces must contain only alphanumeric characters, dots (`.`), and dashes (`-`). + - For any domain using other characters, DNS Punycode must be used !!! warning "Namespace Conflicts" From 8c0f98343b577fa89dc48fef477ae6ac54cb0818 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Mon, 28 Jul 2025 15:39:30 -0400 Subject: [PATCH 91/99] Update docs/reference/code/namespaces.md Co-authored-by: tschmidtb51 <65305130+tschmidtb51@users.noreply.github.com> --- docs/reference/code/namespaces.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/code/namespaces.md b/docs/reference/code/namespaces.md index ed2ffc02..ee54eb91 100644 --- a/docs/reference/code/namespaces.md +++ b/docs/reference/code/namespaces.md @@ -138,7 +138,7 @@ we expect that this will rarely lead to conflicts in practice. Unregistered namespaces must follow the following structure: - Unregistered namespaces must use the `x_` prefix. - - Following the `x_` prefix, unregistered namespaces must use reverse domain name notation to ensure uniqueness. + - Following the `x_` prefix, unregistered namespaces must use reverse domain name notation of a domain under their control to ensure uniqueness. - Aside from the required `x_` prefix, unregistered namespaces must contain only alphanumeric characters, dots (`.`), and dashes (`-`). - For any domain using other characters, DNS Punycode must be used From 9b2a83a4fdbb544c13edd1b3d32610acc94534f8 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Mon, 28 Jul 2025 15:40:12 -0400 Subject: [PATCH 92/99] Update docs/reference/code/namespaces.md Co-authored-by: tschmidtb51 <65305130+tschmidtb51@users.noreply.github.com> --- docs/reference/code/namespaces.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/reference/code/namespaces.md b/docs/reference/code/namespaces.md index ee54eb91..f8f2dbff 100644 --- a/docs/reference/code/namespaces.md +++ b/docs/reference/code/namespaces.md @@ -145,8 +145,8 @@ we expect that this will rarely lead to conflicts in practice. !!! warning "Namespace Conflicts" - Conflicts are possible in the x_ prefix space. - In the previous example, Organizations A and B could both choose to use + Conflicts are possible in the x_ prefix space - especially as the control over a domain may be transferred. + Also in tests, Organizations A and B could both choose to use `x_example.test`, and there are no guarantees of global uniqueness for the decision points in the `x_example.test` namespace. From 9a48b01bbbb7847ca48b9cd53e8a610c018616b9 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Mon, 28 Jul 2025 15:40:39 -0400 Subject: [PATCH 93/99] Update docs/reference/code/namespaces.md Co-authored-by: tschmidtb51 <65305130+tschmidtb51@users.noreply.github.com> --- docs/reference/code/namespaces.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/reference/code/namespaces.md b/docs/reference/code/namespaces.md index f8f2dbff..e32a8edb 100644 --- a/docs/reference/code/namespaces.md +++ b/docs/reference/code/namespaces.md @@ -181,6 +181,12 @@ constituencies or to provide translations of existing decision points. as described above instead of an extension. Extensions are not intended to be used to create new decision points. +!!! question "Why is that important?" + + The way extensions are build enables tools to process the decision points even if + they do not know the defined extension. As long as the tool knows the base + namespace, it can process the decision point. + #### Namespace Extension Structure The first extension segment is reserved for an optional BCP-47 language tag, which may be left empty. From 250824060ea530b208f6ef3b88d9c57703be2b60 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Mon, 28 Jul 2025 15:41:26 -0400 Subject: [PATCH 94/99] Update docs/reference/code/namespaces.md Co-authored-by: tschmidtb51 <65305130+tschmidtb51@users.noreply.github.com> --- docs/reference/code/namespaces.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/code/namespaces.md b/docs/reference/code/namespaces.md index e32a8edb..40c94f09 100644 --- a/docs/reference/code/namespaces.md +++ b/docs/reference/code/namespaces.md @@ -195,7 +195,7 @@ When empty, the default language (`en-US`) is implied. Subsequent extension segments must begin with a reverse domain name notation string, and may contain alphanumeric characters (upper or lower case), dots (`.`), and dashes (`-`). A single fragment identifier (`#`) may be included in an extension segment, but it is optional. -Fragment identifiers can be used to indicate a specific interpretation or context for the extension. +Fragment segments can be used to indicate a specific interpretation or context for the extension. The following diagram illustrates the structure of namespace extensions: ```mermaid From 0cedd6582c3c3fb639c8e6766f54303d4e08249d Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Mon, 28 Jul 2025 15:41:47 -0400 Subject: [PATCH 95/99] Update docs/reference/code/namespaces.md Co-authored-by: tschmidtb51 <65305130+tschmidtb51@users.noreply.github.com> --- docs/reference/code/namespaces.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/code/namespaces.md b/docs/reference/code/namespaces.md index 40c94f09..763a4362 100644 --- a/docs/reference/code/namespaces.md +++ b/docs/reference/code/namespaces.md @@ -196,7 +196,7 @@ Subsequent extension segments must begin with a reverse domain name notation str and may contain alphanumeric characters (upper or lower case), dots (`.`), and dashes (`-`). A single fragment identifier (`#`) may be included in an extension segment, but it is optional. Fragment segments can be used to indicate a specific interpretation or context for the extension. - +Note: Without a fragment segment, all decision points of an organization fall into one bucket, which is in most cases not intended. Therefore, the use of a fragment segment is recommended. The following diagram illustrates the structure of namespace extensions: ```mermaid --- From 6949295d9cd1c1b8fe65e98046af8d21ab43a28d Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Mon, 28 Jul 2025 15:42:13 -0400 Subject: [PATCH 96/99] Update docs/reference/code/namespaces.md Co-authored-by: tschmidtb51 <65305130+tschmidtb51@users.noreply.github.com> --- docs/reference/code/namespaces.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/code/namespaces.md b/docs/reference/code/namespaces.md index 763a4362..55bed10a 100644 --- a/docs/reference/code/namespaces.md +++ b/docs/reference/code/namespaces.md @@ -235,7 +235,7 @@ base_ns -->|/| first - Multiple extension segments are allowed. - If any extension segments are present, the first segment must be a valid BCP-47 language tag or an empty string. - When the first segment is left as an empty string, the default language (`en-US`) is implied. - - Subsequent extension segments must begin with a reverse domain name notation string. + - Subsequent extension segments must begin with a reverse domain name notation string or be a valid, non-empty BCP-47 language tag. - A fragment identifier (`#`) may be included in extension segments, but it is optional. - Extension segments may contain alphanumeric characters (upper or lower case), dots (`.`), and dashes (`-`), and zero or one hash (`#`). - Extensions must not alter the decision point key, version number, or value keys for any decision point they are derived from. From eccf7ca378723fd5dadeccef1a0758792df342d3 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Mon, 28 Jul 2025 15:44:31 -0400 Subject: [PATCH 97/99] Update namespaces.md --- docs/reference/code/namespaces.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/code/namespaces.md b/docs/reference/code/namespaces.md index 55bed10a..42fb50c3 100644 --- a/docs/reference/code/namespaces.md +++ b/docs/reference/code/namespaces.md @@ -294,7 +294,7 @@ segment of the extension. !!! tip "Use BCP-47 Language Tags" Regardless where they appear in the extension strings, BCP-47 language tags - must be for any language-based extension. + must be used for any language-based extension. Note, however that we do not strictly enforce this recommendation in the SSVC codebase outside of the first extension segment. From 74463f1bcf5b7a030705731272790c4b50cac742 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Mon, 28 Jul 2025 15:49:41 -0400 Subject: [PATCH 98/99] forbid extras --- src/ssvc/selection.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ssvc/selection.py b/src/ssvc/selection.py index 26c0d29a..de8b473f 100644 --- a/src/ssvc/selection.py +++ b/src/ssvc/selection.py @@ -57,6 +57,8 @@ class MinimalDecisionPointValue(_Base, _Keyed, BaseModel): Other required fields in the DecisionPointValue object, such as name and description, are optional here. """ + model_config = ConfigDict(extra="forbid") + @model_validator(mode="before") def set_optional_fields(cls, data): if "name" not in data: From 215da4e36ff87e8a53b8f8677bf9f5a3780ddf93 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Mon, 28 Jul 2025 15:56:20 -0400 Subject: [PATCH 99/99] Update docs/reference/code/namespaces.md Co-authored-by: tschmidtb51 <65305130+tschmidtb51@users.noreply.github.com> --- docs/reference/code/namespaces.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/code/namespaces.md b/docs/reference/code/namespaces.md index 42fb50c3..53a0775c 100644 --- a/docs/reference/code/namespaces.md +++ b/docs/reference/code/namespaces.md @@ -295,7 +295,7 @@ segment of the extension. Regardless where they appear in the extension strings, BCP-47 language tags must be used for any language-based extension. - Note, however that we do not strictly enforce this recommendation in the + Note, however that we do not yet strictly enforce this recommendation in the SSVC codebase outside of the first extension segment. !!! example "Translation of a custom extension"