From 3c983c9df64c8d5a4e7178c11d49b39835b38300 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Tue, 18 Mar 2025 15:52:54 -0400 Subject: [PATCH 01/19] add a namespace Enum along with a pydantic dataclass validator to enforce it Valid = str in enum OR str.startswith("x_") --- src/ssvc/namespaces.py | 55 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 src/ssvc/namespaces.py diff --git a/src/ssvc/namespaces.py b/src/ssvc/namespaces.py new file mode 100644 index 00000000..0dc30fbc --- /dev/null +++ b/src/ssvc/namespaces.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python +""" +Provides a namespace enum +""" +# Copyright (c) 2025 Carnegie Mellon University and Contributors. +# - see Contributors.md for a full list of Contributors +# - see ContributionInstructions.md for information on how you can Contribute to this project +# Stakeholder Specific Vulnerability Categorization (SSVC) is +# licensed under a MIT (SEI)-style license, please see LICENSE.md distributed +# with this Software or contact permission@sei.cmu.edu for full terms. +# Created, in part, with funding and support from the United States Government +# (see Acknowledgments file). This program may include and/or can make use of +# certain third party source code, object code, documentation and other files +# (“Third Party Software”). See LICENSE.md for more details. +# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the +# U.S. Patent and Trademark Office by Carnegie Mellon University + +from enum import StrEnum, auto + +# extensions / experimental namespaces should start with the following prefix +# this is to avoid conflicts with official namespaces +X_PFX = "x_" + + +class NameSpace(StrEnum): + # auto() is used to automatically assign values to the members. + # when used in a StrEnum, auto() assigns the lowercase name of the member as the value + SSVC = auto() + CVSS = auto() + + +class NamespaceValidator: + """Custom type for validating namespaces.""" + + @classmethod + def validate(cls, value: str) -> str: + if value in NameSpace.__members__.values(): + return value + if value.startswith(X_PFX): + return value + raise ValueError( + f"Invalid namespace: {value}. Must be one of {[ns.value for ns in NameSpace]} or start with '{X_PFX}'." + ) + + def __get_validators__(cls): + yield cls.validate + + +def main(): + for ns in NameSpace: + print(ns) + + +if __name__ == "__main__": + main() From 3a44a447e2bf911d182ef0e86e33e9741843a42d Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Tue, 18 Mar 2025 15:53:25 -0400 Subject: [PATCH 02/19] add validator to _Namespaced mixin class --- src/ssvc/_mixins.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/ssvc/_mixins.py b/src/ssvc/_mixins.py index 414c99e1..446ee5c7 100644 --- a/src/ssvc/_mixins.py +++ b/src/ssvc/_mixins.py @@ -20,6 +20,7 @@ from pydantic import BaseModel, ConfigDict, field_validator from semver import Version +from ssvc.namespaces import NamespaceValidator from . import _schemaVersion @@ -54,7 +55,12 @@ class _Namespaced(BaseModel): Mixin class for namespaced SSVC objects. """ - namespace: str = "ssvc" + namespace: str + + @field_validator("namespace", mode="before") + @classmethod + def validate_namespace(cls, value): + return NamespaceValidator.validate(value) class _Keyed(BaseModel): From 34ead88a254bf2cb5e5d4eefeeb6faaf1e43ddf9 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Tue, 18 Mar 2025 15:54:05 -0400 Subject: [PATCH 03/19] refactor base classes to use NameSpace enum values --- src/ssvc/decision_points/base.py | 2 ++ src/ssvc/decision_points/cvss/base.py | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/ssvc/decision_points/base.py b/src/ssvc/decision_points/base.py index 869e3263..dd79f041 100644 --- a/src/ssvc/decision_points/base.py +++ b/src/ssvc/decision_points/base.py @@ -21,6 +21,7 @@ from pydantic import BaseModel from ssvc._mixins import _Base, _Keyed, _Namespaced, _Versioned +from ssvc.namespaces import NameSpace logger = logging.getLogger(__name__) @@ -66,6 +67,7 @@ class SsvcDecisionPoint(_Base, _Keyed, _Versioned, _Namespaced, BaseModel): Models a single decision point as a list of values. """ + namespace: str = NameSpace.SSVC values: list[SsvcDecisionPointValue] = [] def __iter__(self): diff --git a/src/ssvc/decision_points/cvss/base.py b/src/ssvc/decision_points/cvss/base.py index 9a935991..1fc721ac 100644 --- a/src/ssvc/decision_points/cvss/base.py +++ b/src/ssvc/decision_points/cvss/base.py @@ -18,6 +18,7 @@ from pydantic import BaseModel from ssvc.decision_points.base import SsvcDecisionPoint +from ssvc.namespaces import NameSpace class CvssDecisionPoint(SsvcDecisionPoint, BaseModel): @@ -25,4 +26,4 @@ class CvssDecisionPoint(SsvcDecisionPoint, BaseModel): Models a single CVSS decision point as a list of values. """ - namespace: str = "cvss" + namespace: NameSpace = NameSpace.CVSS From 8acba47e070adb8b2bdba6f229a154a0996d5c44 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Tue, 18 Mar 2025 15:54:46 -0400 Subject: [PATCH 04/19] add optional "x_" prefix as valid namespace pattern --- data/schema/v1/Decision_Point-1-0-1.schema.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/schema/v1/Decision_Point-1-0-1.schema.json b/data/schema/v1/Decision_Point-1-0-1.schema.json index 019accee..30849621 100644 --- a/data/schema/v1/Decision_Point-1-0-1.schema.json +++ b/data/schema/v1/Decision_Point-1-0-1.schema.json @@ -48,7 +48,7 @@ "namespace": { "type": "string", "description": "Namespace (a short, unique string): For example, \"ssvc\" or \"cvss\" to indicate the source of the decision point. See SSVC Documentation for details.", - "pattern": "^[a-z0-9-]{3,4}[a-z0-9/\\.-]*$", + "pattern": "^(x_)?[a-z0-9-]{3,4}[a-z0-9/\\.-]*$", "examples": ["ssvc", "cvss", "ssvc-jp", "ssvc/acme", "ssvc/example.com"] }, "version": { From 5208b696773675a9e2ddc490a2907adb86fec39f Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Tue, 18 Mar 2025 15:55:16 -0400 Subject: [PATCH 05/19] update unit tests --- src/test/test_doc_helpers.py | 10 +++------- src/test/test_dp_base.py | 6 +++--- src/test/test_mixins.py | 36 ++++++++++++++++++++++++++---------- 3 files changed, 32 insertions(+), 20 deletions(-) diff --git a/src/test/test_doc_helpers.py b/src/test/test_doc_helpers.py index 76b217f4..fbbb7f45 100644 --- a/src/test/test_doc_helpers.py +++ b/src/test/test_doc_helpers.py @@ -20,18 +20,14 @@ class MyTestCase(unittest.TestCase): def setUp(self): self.dp = SsvcDecisionPoint( - namespace="test", + namespace="x_test", name="test name", description="test description", key="TK", version="1.0.0", values=( - SsvcDecisionPointValue( - name="A", key="A", description="A Definition" - ), - SsvcDecisionPointValue( - name="B", key="B", description="B Definition" - ), + SsvcDecisionPointValue(name="A", key="A", description="A Definition"), + SsvcDecisionPointValue(name="B", key="B", description="B Definition"), ), ) diff --git a/src/test/test_dp_base.py b/src/test/test_dp_base.py index c6b580e6..a386b94c 100644 --- a/src/test/test_dp_base.py +++ b/src/test/test_dp_base.py @@ -34,7 +34,7 @@ def setUp(self) -> None: key="bar", description="baz", version="1.0.0", - namespace="name1", + namespace="x_test", values=tuple(self.values), ) @@ -64,7 +64,7 @@ def test_registry(self): key="asdfasdf", description="asdfasdf", version="1.33.1", - namespace="asdfasdf", + namespace="x_test", values=self.values, ) @@ -90,7 +90,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, "name1") + self.assertEqual(obj.namespace, "x_test") self.assertEqual(len(self.values), len(obj.values)) def test_ssvc_value_json_roundtrip(self): diff --git a/src/test/test_mixins.py b/src/test/test_mixins.py index f86ae5c1..19261599 100644 --- a/src/test/test_mixins.py +++ b/src/test/test_mixins.py @@ -12,10 +12,12 @@ # U.S. Patent and Trademark Office by Carnegie Mellon University import unittest +from random import randint from pydantic import BaseModel, ValidationError from ssvc._mixins import _Base, _Keyed, _Namespaced, _Versioned +from ssvc.namespaces import NameSpace class TestMixins(unittest.TestCase): @@ -69,11 +71,27 @@ def test_asdict_roundtrip(self): self.assertEqual(obj2.description, "baz") def test_namespaced_create(self): - obj = _Namespaced() - self.assertEqual(obj.namespace, "ssvc") - - obj = _Namespaced(namespace="quux") - self.assertEqual(obj.namespace, "quux") + # error if no namespace given + with self.assertRaises(ValidationError): + _Namespaced() + + # use the official namespace values + for ns in NameSpace: + obj = _Namespaced(namespace=ns) + self.assertEqual(obj.namespace, ns) + + # error if namespace is not in the enum + # and it doesn't start with x_ + self.assertNotIn("quux", NameSpace) + with self.assertRaises(ValidationError): + _Namespaced(namespace="quux") + + # 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)}" + obj = _Namespaced(namespace=ns) + self.assertEqual(obj.namespace, ns) def test_versioned_create(self): obj = _Versioned() @@ -94,8 +112,8 @@ def test_mixin_combos(self): {"class": _Keyed, "args": {"key": "fizz"}, "has_default": False}, { "class": _Namespaced, - "args": {"namespace": "buzz"}, - "has_default": True, + "args": {"namespace": "x_test"}, + "has_default": False, }, { "class": _Versioned, @@ -103,9 +121,7 @@ def test_mixin_combos(self): "has_default": True, }, ] - keys_with_defaults = [ - x["args"].keys() for x in mixins if x["has_default"] - ] + keys_with_defaults = [x["args"].keys() for x in mixins if x["has_default"]] # flatten the list keys_with_defaults = [ item for sublist in keys_with_defaults for item in sublist From 9c36947648fab524825751c276d5d46ffa65962a Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Tue, 18 Mar 2025 16:00:34 -0400 Subject: [PATCH 06/19] add docstrings --- src/ssvc/namespaces.py | 17 +++++++++++++++++ src/test/test_mixins.py | 13 +++++++------ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/ssvc/namespaces.py b/src/ssvc/namespaces.py index 0dc30fbc..058d711e 100644 --- a/src/ssvc/namespaces.py +++ b/src/ssvc/namespaces.py @@ -23,6 +23,10 @@ class NameSpace(StrEnum): + """ + Defines the official namespaces for SSVC. + """ + # auto() is used to automatically assign values to the members. # when used in a StrEnum, auto() assigns the lowercase name of the member as the value SSVC = auto() @@ -34,6 +38,19 @@ class NamespaceValidator: @classmethod def validate(cls, value: str) -> str: + """ + Validate the namespace value. The value must be one of the official namespaces or start with 'x_'. + + Args: + value: a string representing a namespace + + Returns: + the validated namespace value + + Raises: + ValueError: if the value is not a valid namespace + + """ if value in NameSpace.__members__.values(): return value if value.startswith(X_PFX): diff --git a/src/test/test_mixins.py b/src/test/test_mixins.py index 19261599..864e78f5 100644 --- a/src/test/test_mixins.py +++ b/src/test/test_mixins.py @@ -70,22 +70,23 @@ def test_asdict_roundtrip(self): self.assertEqual(obj2.name, "quux") self.assertEqual(obj2.description, "baz") - def test_namespaced_create(self): + def test_namespaced_create_errors(self): # error if no namespace given with self.assertRaises(ValidationError): _Namespaced() - # use the official namespace values - for ns in NameSpace: - obj = _Namespaced(namespace=ns) - self.assertEqual(obj.namespace, ns) - # error if namespace is not in the enum # and it doesn't start with x_ self.assertNotIn("quux", NameSpace) with self.assertRaises(ValidationError): _Namespaced(namespace="quux") + def test_namespaced_create(self): + # use the official namespace values + for ns in NameSpace: + obj = _Namespaced(namespace=ns) + self.assertEqual(obj.namespace, ns) + # custom namespaces are allowed as long as they start with x_ for _ in range(100): # we're just fuzzing some random strings here From d49afbf5316f401fad430aad3feb9cabb8548897 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Tue, 18 Mar 2025 16:18:52 -0400 Subject: [PATCH 07/19] bump python test version to 3.12 --- .github/workflows/python-app.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index ecbe9b4c..eda4f001 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -21,10 +21,10 @@ jobs: - uses: actions/checkout@v4 with: fetch-tags: true - - name: Set up Python 3.10 + - name: Set up Python 3.12 uses: actions/setup-python@v5 with: - python-version: "3.10" + python-version: "3.12" - name: Install dependencies run: | python -m pip install --upgrade pip From da21986dd6cd56063b83dc297b449dac1810366a Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Wed, 19 Mar 2025 11:18:20 -0400 Subject: [PATCH 08/19] update the regex pattern for namespaces, add validation to pydantic field --- data/schema/v1/Decision_Point-1-0-1.schema.json | 2 +- src/ssvc/_mixins.py | 6 ++++-- src/ssvc/namespaces.py | 12 ++++++++++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/data/schema/v1/Decision_Point-1-0-1.schema.json b/data/schema/v1/Decision_Point-1-0-1.schema.json index 30849621..0d7a8809 100644 --- a/data/schema/v1/Decision_Point-1-0-1.schema.json +++ b/data/schema/v1/Decision_Point-1-0-1.schema.json @@ -48,7 +48,7 @@ "namespace": { "type": "string", "description": "Namespace (a short, unique string): For example, \"ssvc\" or \"cvss\" to indicate the source of the decision point. See SSVC Documentation for details.", - "pattern": "^(x_)?[a-z0-9-]{3,4}[a-z0-9/\\.-]*$", + "pattern": "^(x_)?[a-z0-9]{3,4}([a-z0-9]*[/.-]?[a-z0-9]*[a-z0-9])*$", "examples": ["ssvc", "cvss", "ssvc-jp", "ssvc/acme", "ssvc/example.com"] }, "version": { diff --git a/src/ssvc/_mixins.py b/src/ssvc/_mixins.py index 446ee5c7..71bf1f66 100644 --- a/src/ssvc/_mixins.py +++ b/src/ssvc/_mixins.py @@ -17,7 +17,7 @@ from typing import Optional -from pydantic import BaseModel, ConfigDict, field_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator from semver import Version from ssvc.namespaces import NamespaceValidator @@ -55,7 +55,9 @@ class _Namespaced(BaseModel): Mixin class for namespaced SSVC objects. """ - namespace: str + # 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) @field_validator("namespace", mode="before") @classmethod diff --git a/src/ssvc/namespaces.py b/src/ssvc/namespaces.py index 058d711e..208e69e6 100644 --- a/src/ssvc/namespaces.py +++ b/src/ssvc/namespaces.py @@ -15,12 +15,24 @@ # Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the # U.S. Patent and Trademark Office by Carnegie Mellon University +import re from enum import StrEnum, auto # extensions / experimental namespaces should start with the following prefix # this is to avoid conflicts with official namespaces X_PFX = "x_" +# pattern to match +# `^(x_)`: `x_` prefix is optional +# `[a-z0-9]{3,4}`: must start with 3-4 alphanumeric characters +# `([a-z0-9]+[/.-]?[a-z0-9]*)*[a-z0-9]`: remainder can contain alphanumeric characters, +# periods, hyphens, and forward slashes +# `[/.-]?`: only one punctuation character is allowed between alphanumeric characters +# `[a-z0-9]*`: but an arbitrary number of alphanumeric characters can be between punctuation characters +# `([a-z0-9]+[/.-]?[a-z0-9]*)*` and the total number of punctuation characters is not limited +# `[a-z0-9]$`: the string must end with an alphanumeric character +NS_PATTERN = re.compile(r"^(x_)?[a-z0-9]{3,4}([a-z0-9]*[/.-]?[a-z0-9]*[a-z0-9])*$") + class NameSpace(StrEnum): """ From b57c735f06ca9dd262f80f113cde7260f57e35fc Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Wed, 19 Mar 2025 11:18:44 -0400 Subject: [PATCH 09/19] refactor namespace validation methods --- src/ssvc/_mixins.py | 19 +++++++++++++++++-- src/ssvc/namespaces.py | 19 ++++++------------- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/src/ssvc/_mixins.py b/src/ssvc/_mixins.py index 71bf1f66..de117379 100644 --- a/src/ssvc/_mixins.py +++ b/src/ssvc/_mixins.py @@ -20,7 +20,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator from semver import Version -from ssvc.namespaces import NamespaceValidator +from ssvc.namespaces import NS_PATTERN, NameSpace from . import _schemaVersion @@ -62,7 +62,22 @@ class _Namespaced(BaseModel): @field_validator("namespace", mode="before") @classmethod def validate_namespace(cls, value): - return NamespaceValidator.validate(value) + """ + Validate the namespace field. + The value will have already been checked against the pattern in the field definition. + The value must be one of the official namespaces or start with 'x_'. + + Args: + value: a string representing a namespace + + Returns: + the validated namespace value + + Raises: + ValueError: if the value is not a valid namespace + """ + + return NameSpace.validate(value) class _Keyed(BaseModel): diff --git a/src/ssvc/namespaces.py b/src/ssvc/namespaces.py index 208e69e6..135c3b24 100644 --- a/src/ssvc/namespaces.py +++ b/src/ssvc/namespaces.py @@ -44,17 +44,13 @@ class NameSpace(StrEnum): SSVC = auto() CVSS = auto() - -class NamespaceValidator: - """Custom type for validating namespaces.""" - @classmethod - def validate(cls, value: str) -> str: + def validate(cls, value): """ - Validate the namespace value. The value must be one of the official namespaces or start with 'x_'. + Validate the namespace value. Args: - value: a string representing a namespace + value: the namespace value to validate Returns: the validated namespace value @@ -63,17 +59,14 @@ def validate(cls, value: str) -> str: ValueError: if the value is not a valid namespace """ - if value in NameSpace.__members__.values(): + if value in cls.__members__.values(): return value - if value.startswith(X_PFX): + if value.startswith(X_PFX) and NS_PATTERN.match(value): return value raise ValueError( - f"Invalid namespace: {value}. Must be one of {[ns.value for ns in NameSpace]} or start with '{X_PFX}'." + f"Invalid namespace: {value}. Must be one of {[ns.value for ns in cls]} or start with '{X_PFX}'." ) - def __get_validators__(cls): - yield cls.validate - def main(): for ns in NameSpace: From 4c5e9cde4539a1a064e7d393fad987aabb369da0 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Wed, 19 Mar 2025 11:18:55 -0400 Subject: [PATCH 10/19] add unit tests --- src/test/test_namespaces.py | 79 +++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 src/test/test_namespaces.py diff --git a/src/test/test_namespaces.py b/src/test/test_namespaces.py new file mode 100644 index 00000000..3866c8cc --- /dev/null +++ b/src/test/test_namespaces.py @@ -0,0 +1,79 @@ +# Copyright (c) 2025 Carnegie Mellon University and Contributors. +# - see Contributors.md for a full list of Contributors +# - see ContributionInstructions.md for information on how you can Contribute to this project +# Stakeholder Specific Vulnerability Categorization (SSVC) is +# licensed under a MIT (SEI)-style license, please see LICENSE.md distributed +# with this Software or contact permission@sei.cmu.edu for full terms. +# Created, in part, with funding and support from the United States Government +# (see Acknowledgments file). This program may include and/or can make use of +# certain third party source code, object code, documentation and other files +# (“Third Party Software”). See LICENSE.md for more details. +# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the +# U.S. Patent and Trademark Office by Carnegie Mellon University + +import unittest + +from ssvc.namespaces import NS_PATTERN, NameSpace + + +class MyTestCase(unittest.TestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + def test_ns_pattern(self): + should_match = [ + "foo", + "foo.bar", + "foo.bar.baz", + "foo/bar/baz/quux", + "foo.bar/baz.quux", + ] + should_match.extend([f"x_{ns}" for ns in should_match]) + + for ns in should_match: + with self.subTest(ns=ns): + self.assertTrue(NS_PATTERN.match(ns), ns) + + should_not_match = [ + "", + "ab", + ".foo", + "foo..bar", + "foo/bar//baz", + "foo/bar/baz/", + "(&(&" "foo\\bar", + "foo|bar|baz", + ] + + should_not_match.extend([f"_{ns}" for ns in should_not_match]) + + for ns in should_not_match: + with self.subTest(ns=ns): + self.assertFalse(NS_PATTERN.match(ns)) + + def test_namspace_enum(self): + for ns in NameSpace: + self.assertEqual(ns.name.lower(), ns.value) + + # make sure we have an SSVC namespace with the correct value + self.assertIn("SSVC", NameSpace.__members__) + values = [ns.value for ns in NameSpace] + self.assertIn("ssvc", values) + + def test_namespace_validator(self): + for ns in NameSpace: + self.assertTrue(NameSpace.validate(ns.value)) + + for ns in ["foo", "bar", "baz", "quux"]: + with self.assertRaises(ValueError): + NameSpace.validate(ns) + + for ns in ["x_foo", "x_bar", "x_baz", "x_quux"]: + self.assertEqual(ns, NameSpace.validate(ns)) + + +if __name__ == "__main__": + unittest.main() From d8f5a88df96d4c857e4b68e93005a3ddaa922332 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Wed, 19 Mar 2025 12:41:57 -0400 Subject: [PATCH 11/19] simplify regex to avoid inefficiencies --- data/schema/v1/Decision_Point-1-0-1.schema.json | 2 +- src/ssvc/namespaces.py | 11 +++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/data/schema/v1/Decision_Point-1-0-1.schema.json b/data/schema/v1/Decision_Point-1-0-1.schema.json index 0d7a8809..43fd8bfc 100644 --- a/data/schema/v1/Decision_Point-1-0-1.schema.json +++ b/data/schema/v1/Decision_Point-1-0-1.schema.json @@ -48,7 +48,7 @@ "namespace": { "type": "string", "description": "Namespace (a short, unique string): For example, \"ssvc\" or \"cvss\" to indicate the source of the decision point. See SSVC Documentation for details.", - "pattern": "^(x_)?[a-z0-9]{3,4}([a-z0-9]*[/.-]?[a-z0-9]*[a-z0-9])*$", + "pattern": "^(x_)?[a-z0-9]{3,4}([/.-]?[a-z0-9]+)*$", "examples": ["ssvc", "cvss", "ssvc-jp", "ssvc/acme", "ssvc/example.com"] }, "version": { diff --git a/src/ssvc/namespaces.py b/src/ssvc/namespaces.py index 135c3b24..725ec00e 100644 --- a/src/ssvc/namespaces.py +++ b/src/ssvc/namespaces.py @@ -25,13 +25,12 @@ # pattern to match # `^(x_)`: `x_` prefix is optional # `[a-z0-9]{3,4}`: must start with 3-4 alphanumeric characters -# `([a-z0-9]+[/.-]?[a-z0-9]*)*[a-z0-9]`: remainder can contain alphanumeric characters, -# periods, hyphens, and forward slashes # `[/.-]?`: only one punctuation character is allowed between alphanumeric characters -# `[a-z0-9]*`: but an arbitrary number of alphanumeric characters can be between punctuation characters -# `([a-z0-9]+[/.-]?[a-z0-9]*)*` and the total number of punctuation characters is not limited -# `[a-z0-9]$`: the string must end with an alphanumeric character -NS_PATTERN = re.compile(r"^(x_)?[a-z0-9]{3,4}([a-z0-9]*[/.-]?[a-z0-9]*[a-z0-9])*$") +# `[a-z0-9]+`: at least one alphanumeric character is required after the punctuation character +# `([/.-]?[a-z0-9]+)*`: zero or more occurrences of the punctuation character followed by at least one alphanumeric character +# `$`: end of the string +# last character must be alphanumeric +NS_PATTERN = re.compile(r"^(x_)?[a-z0-9]{3,4}([/.-]?[a-z0-9]+)*$") class NameSpace(StrEnum): From e5fe103a5306585eeba082bc5060179adc271c93 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Wed, 19 Mar 2025 14:08:19 -0400 Subject: [PATCH 12/19] add length requirements to namespace patterns and fields --- .../schema/v1/Decision_Point-1-0-1.schema.json | 2 +- src/ssvc/_mixins.py | 2 +- src/ssvc/namespaces.py | 3 ++- src/test/test_mixins.py | 18 ++++++++++++++++++ 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/data/schema/v1/Decision_Point-1-0-1.schema.json b/data/schema/v1/Decision_Point-1-0-1.schema.json index 43fd8bfc..eddfe163 100644 --- a/data/schema/v1/Decision_Point-1-0-1.schema.json +++ b/data/schema/v1/Decision_Point-1-0-1.schema.json @@ -48,7 +48,7 @@ "namespace": { "type": "string", "description": "Namespace (a short, unique string): For example, \"ssvc\" or \"cvss\" to indicate the source of the decision point. See SSVC Documentation for details.", - "pattern": "^(x_)?[a-z0-9]{3,4}([/.-]?[a-z0-9]+)*$", + "pattern": "^(?=.{3,25}$)(x_)?[a-z0-9]{3,4}([/.-]?[a-z0-9]+)*$", "examples": ["ssvc", "cvss", "ssvc-jp", "ssvc/acme", "ssvc/example.com"] }, "version": { diff --git a/src/ssvc/_mixins.py b/src/ssvc/_mixins.py index de117379..1c8a4a24 100644 --- a/src/ssvc/_mixins.py +++ b/src/ssvc/_mixins.py @@ -57,7 +57,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) + namespace: str = Field(pattern=NS_PATTERN, min_length=3, max_length=25) @field_validator("namespace", mode="before") @classmethod diff --git a/src/ssvc/namespaces.py b/src/ssvc/namespaces.py index 725ec00e..fcb48c80 100644 --- a/src/ssvc/namespaces.py +++ b/src/ssvc/namespaces.py @@ -23,6 +23,7 @@ X_PFX = "x_" # pattern to match +# `(?=.{3,25}$)`: 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 @@ -30,7 +31,7 @@ # `([/.-]?[a-z0-9]+)*`: zero or more occurrences of the punctuation character followed by at least one alphanumeric character # `$`: end of the string # last character must be alphanumeric -NS_PATTERN = re.compile(r"^(x_)?[a-z0-9]{3,4}([/.-]?[a-z0-9]+)*$") +NS_PATTERN = re.compile(r"^(?=.{3,25}$)(x_)?[a-z0-9]{3,4}([/.-]?[a-z0-9]+)*$") class NameSpace(StrEnum): diff --git a/src/test/test_mixins.py b/src/test/test_mixins.py index 864e78f5..4db76959 100644 --- a/src/test/test_mixins.py +++ b/src/test/test_mixins.py @@ -81,6 +81,24 @@ def test_namespaced_create_errors(self): with self.assertRaises(ValidationError): _Namespaced(namespace="quux") + # error if namespace starts with x_ but is too short + with self.assertRaises(ValidationError): + _Namespaced(namespace="x_") + + # error if namespace starts with x_ but is too long + for i in range(100): + shortest = "x_aaa" + ns = shortest + "a" * i + with self.subTest(ns=ns): + # length limit set in the NS_PATTERN regex + if len(ns) <= 25: + # expect success on shorter than limit + _Namespaced(namespace=ns) + else: + # expect failure on longer than limit + with self.assertRaises(ValidationError): + _Namespaced(namespace=ns) + def test_namespaced_create(self): # use the official namespace values for ns in NameSpace: From dd7efec9a8423a99c0283816bb21dbac35eee5cf Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Wed, 19 Mar 2025 14:34:26 -0400 Subject: [PATCH 13/19] refactor regex again --- data/schema/v1/Decision_Point-1-0-1.schema.json | 2 +- src/ssvc/namespaces.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/data/schema/v1/Decision_Point-1-0-1.schema.json b/data/schema/v1/Decision_Point-1-0-1.schema.json index eddfe163..98ab1f4c 100644 --- a/data/schema/v1/Decision_Point-1-0-1.schema.json +++ b/data/schema/v1/Decision_Point-1-0-1.schema.json @@ -48,7 +48,7 @@ "namespace": { "type": "string", "description": "Namespace (a short, unique string): For example, \"ssvc\" or \"cvss\" to indicate the source of the decision point. See SSVC Documentation for details.", - "pattern": "^(?=.{3,25}$)(x_)?[a-z0-9]{3,4}([/.-]?[a-z0-9]+)*$", + "pattern": "^(?=.{3,25}$)(x_)?[a-z0-9]{3,4}([/.-]?[a-z0-9]+){0,22}$", "examples": ["ssvc", "cvss", "ssvc-jp", "ssvc/acme", "ssvc/example.com"] }, "version": { diff --git a/src/ssvc/namespaces.py b/src/ssvc/namespaces.py index fcb48c80..64dcf2f7 100644 --- a/src/ssvc/namespaces.py +++ b/src/ssvc/namespaces.py @@ -22,16 +22,17 @@ # this is to avoid conflicts with official namespaces X_PFX = "x_" + # pattern to match # `(?=.{3,25}$)`: 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]+)*`: zero or more occurrences of the punctuation character followed by at least one alphanumeric 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 -# last character must be alphanumeric -NS_PATTERN = re.compile(r"^(?=.{3,25}$)(x_)?[a-z0-9]{3,4}([/.-]?[a-z0-9]+)*$") +NS_PATTERN = re.compile(r"^(?=.{3,25}$)(x_)?[a-z0-9]{3,4}([/.-]?[a-z0-9]+){0,22}$") class NameSpace(StrEnum): From 3b7f34a8ef133242eadeca34a88955d42df1f7f6 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Wed, 19 Mar 2025 14:45:03 -0400 Subject: [PATCH 14/19] add docstrings --- src/ssvc/namespaces.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/ssvc/namespaces.py b/src/ssvc/namespaces.py index 64dcf2f7..f931a7ac 100644 --- a/src/ssvc/namespaces.py +++ b/src/ssvc/namespaces.py @@ -38,6 +38,21 @@ class NameSpace(StrEnum): """ 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. + Limited punctuation characters (/.-) are allowed between alphanumeric characters, but only one at a time. + + Examples: + + - `ssvc` is *valid* because it is present in the enum + - `custom` is *invalid* because it does not start with the experimental prefix and is not in the enum + - `x_custom` is *valid* because it starts with the experimental prefix and meets the pattern requirements + - `x_custom/extension` is *valid* because it starts with the experimental prefix and meets the pattern requirements + - `x_custom/extension/with/multiple/segments` is *invalid* because it exceeds the maximum length + - `x_custom//extension` is *invalid* because it has multiple punctuation characters in a row + - `x_custom.extension.` is *invalid* because it does not end with an alphanumeric character + - `x_custom.extension.9` is *valid* because it meets the pattern requirements """ # auto() is used to automatically assign values to the members. From 643f193008fe3deff2d24451997bb84a0910467b Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Wed, 19 Mar 2025 15:10:30 -0400 Subject: [PATCH 15/19] add docs, update docstrings --- .../v1/Decision_Point-1-0-1.schema.json | 2 +- docs/reference/code/index.md | 1 + docs/reference/code/namespaces.md | 3 ++ mkdocs.yml | 1 + src/ssvc/_mixins.py | 4 +-- src/ssvc/namespaces.py | 31 ++++++++++++++----- 6 files changed, 31 insertions(+), 11 deletions(-) create mode 100644 docs/reference/code/namespaces.md diff --git a/data/schema/v1/Decision_Point-1-0-1.schema.json b/data/schema/v1/Decision_Point-1-0-1.schema.json index 98ab1f4c..54aeaa29 100644 --- a/data/schema/v1/Decision_Point-1-0-1.schema.json +++ b/data/schema/v1/Decision_Point-1-0-1.schema.json @@ -48,7 +48,7 @@ "namespace": { "type": "string", "description": "Namespace (a short, unique string): For example, \"ssvc\" or \"cvss\" to indicate the source of the decision point. See SSVC Documentation for details.", - "pattern": "^(?=.{3,25}$)(x_)?[a-z0-9]{3,4}([/.-]?[a-z0-9]+){0,22}$", + "pattern": "^(?=.{3,25}$)(x_)?[a-z0-9]{3}([/.-]?[a-z0-9]+){0,22}$", "examples": ["ssvc", "cvss", "ssvc-jp", "ssvc/acme", "ssvc/example.com"] }, "version": { diff --git a/docs/reference/code/index.md b/docs/reference/code/index.md index 8f2f47ad..0d36bea8 100644 --- a/docs/reference/code/index.md +++ b/docs/reference/code/index.md @@ -6,4 +6,5 @@ These include: - [CSV Analyzer](analyze_csv.md) - [Policy Generator](policy_generator.md) - [Outcomes](outcomes.md) +- [Namespaces](namespaces.md) - [Doctools](doctools.md) diff --git a/docs/reference/code/namespaces.md b/docs/reference/code/namespaces.md new file mode 100644 index 00000000..bc7ed7b4 --- /dev/null +++ b/docs/reference/code/namespaces.md @@ -0,0 +1,3 @@ +# SSVC Namespaces + +::: ssvc.namespaces diff --git a/mkdocs.yml b/mkdocs.yml index 2e47540c..b7f686c3 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -112,6 +112,7 @@ nav: - 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' - Calculator: 'ssvc-calc/index.md' - About: diff --git a/src/ssvc/_mixins.py b/src/ssvc/_mixins.py index 1c8a4a24..2e7edfb2 100644 --- a/src/ssvc/_mixins.py +++ b/src/ssvc/_mixins.py @@ -34,7 +34,7 @@ class _Versioned(BaseModel): @field_validator("version") @classmethod - def validate_version(cls, value): + def validate_version(cls, value: str) -> str: """ Validate the version field. Args: @@ -61,7 +61,7 @@ class _Namespaced(BaseModel): @field_validator("namespace", mode="before") @classmethod - def validate_namespace(cls, value): + def validate_namespace(cls, value: str) -> str: """ Validate the namespace field. The value will have already been checked against the pattern in the field definition. diff --git a/src/ssvc/namespaces.py b/src/ssvc/namespaces.py index f931a7ac..74fc921b 100644 --- a/src/ssvc/namespaces.py +++ b/src/ssvc/namespaces.py @@ -1,6 +1,8 @@ #!/usr/bin/env python """ -Provides a namespace enum +SSVC objects use namespaces to distinguish between objects that arise from different +stakeholders or analytical category sources. This module defines the official namespaces +for SSVC and provides a method to validate namespace values. """ # Copyright (c) 2025 Carnegie Mellon University and Contributors. # - see Contributors.md for a full list of Contributors @@ -18,10 +20,8 @@ import re from enum import StrEnum, auto -# extensions / experimental namespaces should start with the following prefix -# this is to avoid conflicts with official namespaces X_PFX = "x_" - +"""The prefix for extension namespaces. Extension namespaces must start with this prefix.""" # pattern to match # `(?=.{3,25}$)`: 3-25 characters long @@ -32,7 +32,20 @@ # `([/.-]?[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,25}$)(x_)?[a-z0-9]{3,4}([/.-]?[a-z0-9]+){0,22}$") +NS_PATTERN = re.compile(r"^(?=.{3,25}$)(x_)?[a-z0-9]{3}([/.-]?[a-z0-9]+){0,22}$") +"""The regular expression pattern for validating namespaces. + +Note: + Namespace values must + + - be 3-25 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 + - end with an alphanumeric character + + See examples in the `NameSpace` enum. +""" class NameSpace(StrEnum): @@ -43,7 +56,8 @@ class NameSpace(StrEnum): Namespaces must be 3-25 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. - Examples: + Example: + Following are examples of valid and invalid namespace values: - `ssvc` is *valid* because it is present in the enum - `custom` is *invalid* because it does not start with the experimental prefix and is not in the enum @@ -61,9 +75,10 @@ class NameSpace(StrEnum): CVSS = auto() @classmethod - def validate(cls, value): + def validate(cls, value: str) -> str: """ - Validate the namespace value. + Validate the namespace value. Valid values are members of the enum or start with the experimental prefix and + meet the specified pattern requirements. Args: value: the namespace value to validate From b02d228e4566753cea6d1683eb9b13ff2324ad2c Mon Sep 17 00:00:00 2001 From: Vijay Sarvepalli Date: Wed, 19 Mar 2025 15:39:16 -0400 Subject: [PATCH 16/19] Update Decision_Point-1-0-1.schema.json Modify Namespace information and examples as wel.. --- data/schema/v1/Decision_Point-1-0-1.schema.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/data/schema/v1/Decision_Point-1-0-1.schema.json b/data/schema/v1/Decision_Point-1-0-1.schema.json index 54aeaa29..9a864ed0 100644 --- a/data/schema/v1/Decision_Point-1-0-1.schema.json +++ b/data/schema/v1/Decision_Point-1-0-1.schema.json @@ -47,9 +47,9 @@ }, "namespace": { "type": "string", - "description": "Namespace (a short, unique string): For example, \"ssvc\" or \"cvss\" to indicate the source of the decision point. See SSVC Documentation for details.", + "description": "Namespace (a short, unique string): The value must be one of the official namespaces, currenlty \"ssvc\", \"cvss\", \"nciss\" OR can start with 'x_' for private namespaces. See SSVC Documentation for details.", "pattern": "^(?=.{3,25}$)(x_)?[a-z0-9]{3}([/.-]?[a-z0-9]+){0,22}$", - "examples": ["ssvc", "cvss", "ssvc-jp", "ssvc/acme", "ssvc/example.com"] + "examples": ["ssvc", "cvss", "nciss", "x_text"] }, "version": { "type": "string", From 02bf023ec31ee3932489e4b06ce628b9e40a3926 Mon Sep 17 00:00:00 2001 From: Vijay Sarvepalli Date: Wed, 19 Mar 2025 15:42:53 -0400 Subject: [PATCH 17/19] Update Decision_Point-1-0-1.schema.json Matching x_custom/extension as examples for schema docs. --- data/schema/v1/Decision_Point-1-0-1.schema.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/schema/v1/Decision_Point-1-0-1.schema.json b/data/schema/v1/Decision_Point-1-0-1.schema.json index 9a864ed0..8e4d1732 100644 --- a/data/schema/v1/Decision_Point-1-0-1.schema.json +++ b/data/schema/v1/Decision_Point-1-0-1.schema.json @@ -49,7 +49,7 @@ "type": "string", "description": "Namespace (a short, unique string): The value must be one of the official namespaces, currenlty \"ssvc\", \"cvss\", \"nciss\" OR can start with 'x_' for private namespaces. See SSVC Documentation for details.", "pattern": "^(?=.{3,25}$)(x_)?[a-z0-9]{3}([/.-]?[a-z0-9]+){0,22}$", - "examples": ["ssvc", "cvss", "nciss", "x_text"] + "examples": ["ssvc", "cvss", "nciss", "x_custom","x_custom/extension"] }, "version": { "type": "string", From 8b482752cfe7fce6731ad9b32bcda4657ea31cef Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Wed, 19 Mar 2025 15:47:58 -0400 Subject: [PATCH 18/19] we shouldn't mention nciss yet as it's still a draft PR --- data/schema/v1/Decision_Point-1-0-1.schema.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/schema/v1/Decision_Point-1-0-1.schema.json b/data/schema/v1/Decision_Point-1-0-1.schema.json index 8e4d1732..549c9458 100644 --- a/data/schema/v1/Decision_Point-1-0-1.schema.json +++ b/data/schema/v1/Decision_Point-1-0-1.schema.json @@ -49,7 +49,7 @@ "type": "string", "description": "Namespace (a short, unique string): The value must be one of the official namespaces, currenlty \"ssvc\", \"cvss\", \"nciss\" OR can start with 'x_' for private namespaces. See SSVC Documentation for details.", "pattern": "^(?=.{3,25}$)(x_)?[a-z0-9]{3}([/.-]?[a-z0-9]+){0,22}$", - "examples": ["ssvc", "cvss", "nciss", "x_custom","x_custom/extension"] + "examples": ["ssvc", "cvss", "x_custom","x_custom/extension"] }, "version": { "type": "string", From 2e229b26641f5422e9c423ad04fbe81a599bdfc6 Mon Sep 17 00:00:00 2001 From: "Allen D. Householder" Date: Wed, 19 Mar 2025 15:48:48 -0400 Subject: [PATCH 19/19] missed an nciss --- data/schema/v1/Decision_Point-1-0-1.schema.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/schema/v1/Decision_Point-1-0-1.schema.json b/data/schema/v1/Decision_Point-1-0-1.schema.json index 549c9458..0d1faf9c 100644 --- a/data/schema/v1/Decision_Point-1-0-1.schema.json +++ b/data/schema/v1/Decision_Point-1-0-1.schema.json @@ -47,7 +47,7 @@ }, "namespace": { "type": "string", - "description": "Namespace (a short, unique string): The value must be one of the official namespaces, currenlty \"ssvc\", \"cvss\", \"nciss\" OR can start with 'x_' for private namespaces. See SSVC Documentation for details.", + "description": "Namespace (a short, unique string): The value must be one of the official namespaces, currenlty \"ssvc\", \"cvss\" OR can start with 'x_' for private namespaces. See SSVC Documentation for details.", "pattern": "^(?=.{3,25}$)(x_)?[a-z0-9]{3}([/.-]?[a-z0-9]+){0,22}$", "examples": ["ssvc", "cvss", "x_custom","x_custom/extension"] },