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 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..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,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.", - "pattern": "^[a-z0-9-]{3,4}[a-z0-9/\\.-]*$", - "examples": ["ssvc", "cvss", "ssvc-jp", "ssvc/acme", "ssvc/example.com"] + "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"] }, "version": { "type": "string", 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 414c99e1..2e7edfb2 100644 --- a/src/ssvc/_mixins.py +++ b/src/ssvc/_mixins.py @@ -17,9 +17,10 @@ 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 NS_PATTERN, NameSpace from . import _schemaVersion @@ -33,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: @@ -54,7 +55,29 @@ class _Namespaced(BaseModel): Mixin class for namespaced SSVC objects. """ - namespace: str = "ssvc" + # 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=25) + + @field_validator("namespace", mode="before") + @classmethod + 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. + 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/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 diff --git a/src/ssvc/namespaces.py b/src/ssvc/namespaces.py new file mode 100644 index 00000000..74fc921b --- /dev/null +++ b/src/ssvc/namespaces.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python +""" +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 +# - 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 re +from enum import StrEnum, auto + +X_PFX = "x_" +"""The prefix for extension namespaces. Extension namespaces must start with this prefix.""" + +# 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]+){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}([/.-]?[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): + """ + 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. + + 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 + - `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. + # when used in a StrEnum, auto() assigns the lowercase name of the member as the value + SSVC = auto() + CVSS = auto() + + @classmethod + def validate(cls, value: str) -> str: + """ + 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 + + Returns: + the validated namespace value + + Raises: + ValueError: if the value is not a valid namespace + + """ + if value in cls.__members__.values(): + return value + 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 cls]} or start with '{X_PFX}'." + ) + + +def main(): + for ns in NameSpace: + print(ns) + + +if __name__ == "__main__": + main() 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..4db76959 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): @@ -68,12 +70,47 @@ def test_asdict_roundtrip(self): self.assertEqual(obj2.name, "quux") self.assertEqual(obj2.description, "baz") - def test_namespaced_create(self): - obj = _Namespaced() - self.assertEqual(obj.namespace, "ssvc") + def test_namespaced_create_errors(self): + # error if no namespace given + with self.assertRaises(ValidationError): + _Namespaced() + + # 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") + + # 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) - obj = _Namespaced(namespace="quux") - self.assertEqual(obj.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 + ns = f"x_{randint(1000,1000000)}" + obj = _Namespaced(namespace=ns) + self.assertEqual(obj.namespace, ns) def test_versioned_create(self): obj = _Versioned() @@ -94,8 +131,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 +140,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 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()