Skip to content

Add NameSpace Enum #749

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 20 commits into from
Mar 20, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/python-app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions data/schema/v1/Decision_Point-1-0-1.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions docs/reference/code/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
3 changes: 3 additions & 0 deletions docs/reference/code/namespaces.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# SSVC Namespaces

::: ssvc.namespaces
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
29 changes: 26 additions & 3 deletions src/ssvc/_mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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:
Expand All @@ -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):
Expand Down
2 changes: 2 additions & 0 deletions src/ssvc/decision_points/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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):
Expand Down
3 changes: 2 additions & 1 deletion src/ssvc/decision_points/cvss/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,12 @@
from pydantic import BaseModel

from ssvc.decision_points.base import SsvcDecisionPoint
from ssvc.namespaces import NameSpace


class CvssDecisionPoint(SsvcDecisionPoint, BaseModel):
"""
Models a single CVSS decision point as a list of values.
"""

namespace: str = "cvss"
namespace: NameSpace = NameSpace.CVSS
108 changes: 108 additions & 0 deletions src/ssvc/namespaces.py
Original file line number Diff line number Diff line change
@@ -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 [email protected] 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()
10 changes: 3 additions & 7 deletions src/test/test_doc_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
),
)

Expand Down
6 changes: 3 additions & 3 deletions src/test/test_dp_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)

Expand Down Expand Up @@ -64,7 +64,7 @@ def test_registry(self):
key="asdfasdf",
description="asdfasdf",
version="1.33.1",
namespace="asdfasdf",
namespace="x_test",
values=self.values,
)

Expand All @@ -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):
Expand Down
55 changes: 45 additions & 10 deletions src/test/test_mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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()
Expand All @@ -94,18 +131,16 @@ 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,
"args": {"version": "1.2.3"},
"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
Expand Down
Loading