diff --git a/docs/reference/code/analyze_csv.md b/docs/reference/code/analyze_csv.md
index bb886969..1f47e1ab 100644
--- a/docs/reference/code/analyze_csv.md
+++ b/docs/reference/code/analyze_csv.md
@@ -1,2 +1,4 @@
-::: analyze_csv
+# SSVC CSV Analyzer
+
+::: ssvc.csv_analyzer
diff --git a/requirements.txt b/requirements.txt
index e72e7d93..8cd33e14 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -7,5 +7,7 @@ mkdocs-material-extensions
mkdocstrings
mkdocstrings-python
mkdocs-print-site-plugin
-pandas~=2.1.1
-scikit-learn~=1.3.1
\ No newline at end of file
+dataclasses-json
+pandas
+scikit-learn
+jsonschema
diff --git a/src/pyproject.toml b/src/pyproject.toml
new file mode 100644
index 00000000..83b62dbb
--- /dev/null
+++ b/src/pyproject.toml
@@ -0,0 +1,70 @@
+[build-system]
+# SetupTools
+requires = ["setuptools>66", "setuptools-scm"]
+build-backend = "setuptools.build_meta"
+# Flit
+#requires = ["flit_core >=3.2,<4"]
+#build-backend = "flit_core.buildapi"
+# Hatchling
+#requires = ["hatchling"]
+#build-backend = "hatchling.build"
+# PDM-Backend
+#requires = ["pdm-backend"]
+#build-backend = "pdm.backend"
+
+[project]
+name = "ssvc"
+authors = [
+ { name = "Allen D. Householder", email="adh@cert.org" },
+ { name = "Vijay Sarvepalli", email="vssarvepalli@cert.org"}
+]
+description = "Tools for working with a Stakeholder Specific Vulnerability Categorization (SSVC)"
+readme = {file="README.md", content-type="text/markdown"}
+requires-python = ">=3.8"
+keywords =["ssvc","vulnerability management","vulnerability management"]
+license = {file="LICENSE.md"}
+classifiers = [
+ "Development Status :: 4 - Beta",
+ "License :: OSI Approved :: MIT License",
+ "Programming Language :: Python :: 3",
+ "Topic :: Security",
+ "Topic :: Software Development :: Libraries :: Python Modules",
+]
+dependencies = [
+ "mkdocs","mkdocs-material","mkdocs-material-extensions","mkdocstrings","mkdocstrings-python",
+ "mkdocs-include-markdown-plugin", "pandas","scipy", "dataclasses-json", "jsonschema"
+]
+dynamic = ["version",]
+
+[project.scripts]
+ssvc_csv_analyzer="ssvc.csv_analyzer:main"
+
+[project.urls]
+"Homepage" = "https://certcc.github.io/SSVC"
+"Project" = "https://github.com/CERTCC/SSVC"
+"Bug Tracker" = "https://github.com/CERTCC/SSVC/issues"
+
+[tool.setuptools.packages.find]
+where = ["."] # list of folders that contain the packages (["."] by default)
+include = ["ssvc*"] # package names should match these glob patterns (["*"] by default)
+exclude = ["test*"] # exclude packages matching these glob patterns (empty by default)
+#namespaces = false # to disable scanning PEP 420 namespaces (true by default)
+
+[tool.setuptools_scm]
+version_file = "ssvc/_version.py"
+root = ".."
+relative_to = "pyproject.toml"
+
+
+#[tools.setuptools.dynamic]
+
+[tool.black]
+line-length = 79
+target-version = ['py38', 'py39', 'py310', 'py311']
+
+[tool.pytest.ini_options]
+minversion = "6.0"
+addopts = "-ra -q"
+testpaths = [
+ "test",
+]
\ No newline at end of file
diff --git a/src/ssvc/__init__.py b/src/ssvc/__init__.py
new file mode 100644
index 00000000..03989558
--- /dev/null
+++ b/src/ssvc/__init__.py
@@ -0,0 +1,14 @@
+#!/usr/bin/env python
+'''
+file: __init__.py
+author: adh
+created_at: 9/20/23 10:36 AM
+'''
+
+
+def main():
+ pass
+
+
+if __name__ == '__main__':
+ main()
diff --git a/src/ssvc/_mixins.py b/src/ssvc/_mixins.py
new file mode 100644
index 00000000..48359e5d
--- /dev/null
+++ b/src/ssvc/_mixins.py
@@ -0,0 +1,66 @@
+#!/usr/bin/env python
+"""
+file: _basics
+author: adh
+created_at: 9/20/23 4:51 PM
+"""
+from dataclasses import dataclass, field
+from typing import Optional
+
+from dataclasses_json import config, dataclass_json
+
+
+@dataclass_json
+@dataclass(kw_only=True)
+class _Versioned:
+ """
+ Mixin class for versioned SSVC objects.
+ """
+
+ version: str = "0.0.0"
+
+
+@dataclass_json
+@dataclass(kw_only=True)
+class _Namespaced:
+ """
+ Mixin class for namespaced SSVC objects.
+ """
+
+ namespace: str = "ssvc"
+
+
+@dataclass_json
+@dataclass(kw_only=True)
+class _Keyed:
+ """
+ Mixin class for keyed SSVC objects.
+ """
+
+ key: str
+
+
+def exclude_if_none(value):
+ return value is None
+
+
+@dataclass_json
+@dataclass(kw_only=True)
+class _Base:
+ """
+ Base class for SSVC objects.
+ """
+
+ name: str
+ description: str
+ _comment: Optional[str] = field(
+ default=None, metadata=config(exclude=exclude_if_none)
+ )
+
+
+def main():
+ pass
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/ssvc/_version.py b/src/ssvc/_version.py
new file mode 100644
index 00000000..c157fc07
--- /dev/null
+++ b/src/ssvc/_version.py
@@ -0,0 +1,16 @@
+# file generated by setuptools_scm
+# don't change, don't track in version control
+TYPE_CHECKING = False
+if TYPE_CHECKING:
+ from typing import Tuple, Union
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
+else:
+ VERSION_TUPLE = object
+
+version: str
+__version__: str
+__version_tuple__: VERSION_TUPLE
+version_tuple: VERSION_TUPLE
+
+__version__ = version = '2.1.2.dev56+g4e67e02.d20231010'
+__version_tuple__ = version_tuple = (2, 1, 2, 'dev56', 'g4e67e02.d20231010')
diff --git a/src/ssvc/csv_analyzer.py b/src/ssvc/csv_analyzer.py
new file mode 100644
index 00000000..0ddc9525
--- /dev/null
+++ b/src/ssvc/csv_analyzer.py
@@ -0,0 +1,235 @@
+#!/usr/bin/env python
+"""
+This module provides a script for analyzing an SSVC tree csv file.
+
+```shell
+usage: csv_analyzer.py [-h] [--outcol OUTCOL] [--permutation] csvfile
+
+Analyze an SSVC tree csv file
+
+positional arguments:
+ csvfile the csv file to analyze
+
+options:
+ -h, --help show this help message and exit
+ --outcol OUTCOL the name of the outcome column
+ --permutation use permutation importance instead of drop column importance
+```
+
+Example:
+ Given a `test.csv` file like this:
+ ```csv
+ row,Exploitation,Exposure,Automatable,Human Impact,Priority
+ 1,none,small,no,low,defer
+ 2,none,small,no,medium,defer
+ 3,none,small,no,high,scheduled
+ ...
+ ```
+ Analyze the csv file:
+ ```shell
+ $ python csv_analyzer.py test.csv
+
+ Feature Importance after Dropping Each Feature in test.csv
+ feature feature_importance
+ 0 exploitation_ 0.347222
+ 1 human_impact_ 0.291667
+ 2 automatable_ 0.180556
+ 3 exposure_ 0.166667
+ ```
+
+ Higher values imply more important features.
+ """
+
+import argparse
+import re
+import sys
+
+import pandas as pd
+import sklearn.inspection
+from sklearn.base import clone
+from sklearn.tree import DecisionTreeClassifier
+
+
+def _col_norm(c: str) -> str:
+ """
+ Normalize a column name
+
+ Args:
+ c: the column name to normalize
+
+ Returns:
+ the normalized column name
+ """
+ new_col = re.sub("[^0-9a-zA-Z]+", "_", c)
+ new_col = new_col.lower()
+ return new_col
+
+
+def _imp_df(column_names: list, importances: list) -> pd.DataFrame:
+ """
+ Create a dataframe of feature importances
+
+ Args:
+ column_names: the names of the columns
+ importances: the feature importances
+
+ Returns:
+ a dataframe of feature importances
+ """
+ df = (
+ pd.DataFrame({"feature": column_names, "feature_importance": importances})
+ .sort_values("feature_importance", ascending=False)
+ .reset_index(drop=True)
+ )
+ return df
+
+
+def _drop_col_feat_imp(
+ model: DecisionTreeClassifier,
+ X_train: pd.DataFrame,
+ y_train: pd.DataFrame,
+ random_state: int = 99,
+) -> pd.DataFrame:
+ # based on https://gist.github.com/erykml/6854134220276b1a50862aa486a44192#file-drop_col_feat_imp-py
+ # clone the model to have the exact same specification as the one initially trained
+ model_clone = clone(model)
+ # set random_state for comparability
+ model_clone.random_state = random_state
+ # training and scoring the benchmark model
+ model_clone.fit(X_train, y_train)
+ benchmark_score = model_clone.score(X_train, y_train)
+ # list for storing feature importances
+ importances = []
+
+ # iterating over all columns and storing feature importance (difference between benchmark and new model)
+ for col in X_train.columns:
+ model_clone = clone(model)
+ model_clone.random_state = random_state
+ model_clone.fit(X_train.drop(col, axis=1), y_train)
+ drop_col_score = model_clone.score(X_train.drop(col, axis=1), y_train)
+ importances.append(benchmark_score - drop_col_score)
+
+ importances_df = _imp_df(X_train.columns, importances)
+ return importances_df
+
+
+def _split_data(df: pd.DataFrame, target: str) -> (pd.DataFrame, pd.DataFrame):
+ """
+ Split a dataframe into features and target
+
+ Args:
+ df: the dataframe to split
+ target: the name of the target column
+
+ Returns:
+ a tuple of (features, target)
+ """
+
+ # construct feature list
+ features = [c for c in df.columns if c != target]
+ y = df[target]
+ X = df[features]
+ return X, y
+
+
+def _clean_table(df: pd.DataFrame) -> pd.DataFrame:
+ """
+ Clean up a dataframe, normalizing column names and dropping columns we don't need
+
+ Args:
+ df: the dataframe to clean
+
+ Returns:
+ the cleaned dataframe
+ """
+ # normalize data
+ df = df.rename(columns=_col_norm)
+ # drop columns we don't need
+ drop_cols = [
+ "row",
+ ]
+ df = df.drop(columns=drop_cols, errors="ignore")
+ return df
+
+
+def _perm_feat_imp(model, x, y):
+ model.random_state = 99
+ model.fit(x, y)
+ # analyze tree
+ results = sklearn.inspection.permutation_importance(model, x, y)
+ imp = results["importances_mean"]
+
+ imp = _imp_df(x.columns, imp)
+ return imp
+
+
+def _parse_args(args) -> argparse.Namespace:
+ # parse command line
+ parser = argparse.ArgumentParser(description="Analyze an SSVC tree csv file")
+ parser.add_argument(
+ "csvfile", metavar="csvfile", type=str, help="the csv file to analyze"
+ )
+ parser.add_argument(
+ "--outcol",
+ dest="outcol",
+ type=str,
+ help="the name of the outcome column",
+ default="priority",
+ )
+ # use permutation or drop column importance?
+ # default is drop column
+ parser.add_argument(
+ "--permutation",
+ dest="permutation",
+ action="store_true",
+ help="use permutation importance instead of drop column importance",
+ default=False,
+ )
+ return parser.parse_args(args)
+
+
+def main():
+ args = _parse_args(sys.argv[1:])
+
+ # read csv
+ df = pd.read_csv(args.csvfile)
+ df = _clean_table(df)
+
+ # check for target column
+ target = args.outcol
+ if target not in df.columns:
+ print(
+ f"Column '{target}' not found in {list(df.columns)}.\nPlease specify --outcol=
and try again."
+ )
+ exit(1)
+
+ X, y = _split_data(df, target)
+
+ # turn features into ordinals
+ # this assumes that every column is an ordinal label
+ # and that the ordinals are sorted in ascending order
+ cols = []
+ for c in X.columns:
+ newcol = f"{c}_"
+ cols.append(newcol)
+ codes = list(enumerate(X[c].unique()))
+ mapper = {v: k for (k, v) in codes}
+ X[newcol] = X[c].replace(mapper)
+ X2 = X[cols]
+
+ # construct tree
+ dt = DecisionTreeClassifier(random_state=99, criterion="entropy")
+
+ if args.permutation:
+ imp = _perm_feat_imp(dt, X2, y)
+ print(f"Feature Permutation Importance for {args.csvfile}")
+ else:
+ # drop columns and re-run
+ imp = _drop_col_feat_imp(dt, X2, y)
+ print(f"Drop Column Feature Importance for {args.csvfile}")
+
+ print(imp)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/ssvc/decision_points/__init__.py b/src/ssvc/decision_points/__init__.py
new file mode 100644
index 00000000..c4f79b7c
--- /dev/null
+++ b/src/ssvc/decision_points/__init__.py
@@ -0,0 +1,20 @@
+#!/usr/bin/env python
+"""
+The ssvc.decision_points package provides a set of decision points for use in SSVC decision functions.
+
+Decision points are the basic building blocks of SSVC decision functions. Individual decision points describe a
+single aspect of the input to a decision function. Decision points should have the following characteristics:
+
+- A name or label
+- A description
+- A version (a semantic version string)
+- A namespace (a short, unique string): For example, "ssvc" or "cvss" to indicate the source of the decision point
+- A key (a short, unique string) that can be used to identify the decision point in a shorthand way
+- A short enumeration of possible values
+
+In turn, each value should have the following characteristics:
+- A name or label
+- A description
+- A key (a short, unique string) that can be used to identify the value in a shorthand way
+"""
+from .base import SsvcDecisionPoint, SsvcDecisionPointValue
diff --git a/src/ssvc/decision_points/automatable.py b/src/ssvc/decision_points/automatable.py
new file mode 100644
index 00000000..4068acfe
--- /dev/null
+++ b/src/ssvc/decision_points/automatable.py
@@ -0,0 +1,37 @@
+#!/usr/bin/env python
+"""
+file: automatable
+author: adh
+created_at: 9/21/23 10:37 AM
+"""
+from ssvc.decision_points.base import SsvcDecisionPoint, SsvcDecisionPointValue
+
+
+AUT_NO = SsvcDecisionPointValue(
+ name="No",
+ key="N",
+ description="Attackers cannot reliably automate steps 1-4 of the kill chain for this vulnerability. "
+ "These steps are (1) reconnaissance, (2) weaponization, (3) delivery, and (4) exploitation.",
+)
+AUT_YES = SsvcDecisionPointValue(
+ name="Yes",
+ key="Y",
+ description="Attackers can reliably automate steps 1-4 of the kill chain.",
+)
+
+
+AUTOMATABLE_1 = SsvcDecisionPoint(
+ name="Automatable",
+ description="Can an attacker reliably automate creating exploitation events for this vulnerability?",
+ key="A",
+ version="1.0.0",
+ values=(AUT_NO, AUT_YES),
+)
+
+
+def main():
+ print(AUTOMATABLE_1.to_json(indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/ssvc/decision_points/base.py b/src/ssvc/decision_points/base.py
new file mode 100644
index 00000000..c148f841
--- /dev/null
+++ b/src/ssvc/decision_points/base.py
@@ -0,0 +1,107 @@
+#!/usr/bin/env python
+"""
+file: decisionpoints
+author: adh
+created_at: 9/20/23 10:07 AM
+"""
+
+import logging
+from dataclasses import dataclass, field
+from typing import ClassVar, Dict, Tuple
+
+from dataclasses_json import config, dataclass_json
+
+from ssvc._mixins import _Base, _Keyed, _Namespaced, _Versioned
+
+logger = logging.getLogger(__name__)
+logger.setLevel(logging.DEBUG)
+
+
+class _DecisionPoints:
+ """
+ A collection of SSVC decision points.
+ """
+
+ registry: ClassVar[Dict[str, "SsvcDecisionPoint"]] = {}
+
+ def __iter__(self):
+ return iter(self.registry.values())
+
+
+REGISTERED_DECISION_POINTS = _DecisionPoints()
+
+
+@dataclass_json
+@dataclass(kw_only=True)
+class SsvcDecisionPointValue(_Base, _Keyed):
+ """
+ Models a single value option for a decision point.
+ """
+
+ pass
+
+
+@dataclass_json
+@dataclass(kw_only=True)
+class SsvcDecisionPoint(_Base, _Keyed, _Versioned, _Namespaced):
+ """
+ Models a single decision point as a list of values.
+ """
+
+ values: Tuple[SsvcDecisionPointValue]
+
+ # this is only for our own use in Python land, exclude it from serialization
+ _fullname: str = field(
+ init=False, repr=False, default=None, metadata=config(exclude=lambda x: True)
+ )
+
+ def __post_init__(self):
+ self._fullname = f"{self.namespace} {self.name} v{self.version}"
+ logging.debug(f"Add {self._fullname} to registry")
+ REGISTERED_DECISION_POINTS.registry[self._fullname] = self
+
+ def to_table(self):
+ rows = []
+ rows.append(f"{self.description}")
+ rows.append("")
+
+ headings = ["Value", "Key", "Description"]
+
+ def make_row(items):
+ return "| " + " | ".join(items) + " |"
+
+ rows.append(make_row(headings))
+ rows.append(make_row(["---" for _ in headings]))
+
+ for value in self.values:
+ rows.append(make_row([value.name, value.key, value.description]))
+
+ return "\n".join(rows)
+
+
+def main():
+ dp = SsvcDecisionPoint(
+ _comment="This is an optional comment that will be included in the object.",
+ name="Exploitation",
+ description="Is there an exploit available?",
+ key="E",
+ version="1.0.0",
+ values=(
+ SsvcDecisionPointValue(
+ name="None", key="N", description="No exploit available"
+ ),
+ SsvcDecisionPointValue(
+ name="PoC",
+ key="P",
+ description="Proof of concept exploit available",
+ ),
+ SsvcDecisionPointValue(
+ name="Active", key="A", description="Active exploitation observed"
+ ),
+ ),
+ )
+ print(dp.to_json(indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/ssvc/decision_points/exploitation.py b/src/ssvc/decision_points/exploitation.py
new file mode 100644
index 00000000..050ea51e
--- /dev/null
+++ b/src/ssvc/decision_points/exploitation.py
@@ -0,0 +1,53 @@
+#!/usr/bin/env python
+"""
+file: exploitation
+author: adh
+created_at: 9/20/23 11:41 AM
+"""
+from ssvc.decision_points.base import SsvcDecisionPoint, SsvcDecisionPointValue
+
+ACTIVE = SsvcDecisionPointValue(
+ name="Active",
+ key="A",
+ description="Shared, observable, reliable evidence that the exploit is being"
+ " used in the wild by real attackers; there is credible public reporting.",
+)
+
+POC = SsvcDecisionPointValue(
+ name="PoC",
+ key="P",
+ description="One of the following cases is true: (1) private evidence of exploitation is attested but not shared; "
+ "(2) widespread hearsay attests to exploitation; (3) typical public PoC in places such as Metasploit"
+ " or ExploitDB; or (4) the vulnerability has a well-known method of exploitation.",
+)
+
+EXP_NONE = SsvcDecisionPointValue(
+ name="None",
+ key="N",
+ description="There is no evidence of active exploitation and no public proof of concept (PoC) of how to exploit the vulnerability.",
+)
+
+
+def _strip_spaces(s):
+ return " ".join([x.strip() for x in s.splitlines()])
+
+
+EXPLOITATION_1 = SsvcDecisionPoint(
+ name="Exploitation",
+ description="The present state of exploitation of the vulnerability.",
+ key="E",
+ version="1.0.0",
+ values=(
+ EXP_NONE,
+ POC,
+ ACTIVE,
+ ),
+)
+
+
+def main():
+ print(EXPLOITATION_1.to_json(indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/ssvc/decision_points/human_impact.py b/src/ssvc/decision_points/human_impact.py
new file mode 100644
index 00000000..355f524a
--- /dev/null
+++ b/src/ssvc/decision_points/human_impact.py
@@ -0,0 +1,52 @@
+#!/usr/bin/env python
+"""
+file: human_impact
+author: adh
+created_at: 9/21/23 10:49 AM
+"""
+from ssvc.decision_points.base import SsvcDecisionPoint, SsvcDecisionPointValue
+
+VERY_HIGH = SsvcDecisionPointValue(
+ name="Very High",
+ key="VH",
+ description="Safety=Catastrophic OR Mission=Mission Failure",
+)
+
+HIGH = SsvcDecisionPointValue(
+ name="High",
+ key="H",
+ description="Safety=Hazardous, Mission=None/Degraded/Crippled/MEF Failure OR Safety=Major, Mission=MEF Failure",
+)
+
+MEDIUM = SsvcDecisionPointValue(
+ name="Medium",
+ key="M",
+ description="Safety=None/Minor, Mission=MEF Failure OR Safety=Major, Mission=None/Degraded/Crippled",
+)
+
+LOW = SsvcDecisionPointValue(
+ name="Low",
+ key="L",
+ description="Safety=None/Minor, Mission=None/Degraded/Crippled",
+)
+
+HUMAN_IMPACT_1 = SsvcDecisionPoint(
+ name="Human Impact",
+ description="Human Impact",
+ key="HI",
+ version="1.0.0",
+ values=(
+ LOW,
+ MEDIUM,
+ HIGH,
+ VERY_HIGH,
+ ),
+)
+
+
+def main():
+ print(HUMAN_IMPACT_1.to_json(indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/ssvc/decision_points/mission_impact.py b/src/ssvc/decision_points/mission_impact.py
new file mode 100644
index 00000000..d0f29808
--- /dev/null
+++ b/src/ssvc/decision_points/mission_impact.py
@@ -0,0 +1,75 @@
+#!/usr/bin/env python
+"""
+file: mission_impact
+author: adh
+created_at: 9/21/23 10:20 AM
+"""
+from copy import deepcopy
+
+from ssvc.decision_points.base import SsvcDecisionPoint, SsvcDecisionPointValue
+
+
+MISSION_FAILURE = SsvcDecisionPointValue(
+ name="Mission Failure",
+ key="MF",
+ description="Multiple or all mission essential functions fail; ability to recover those functions degraded; organization’s ability to deliver its overall mission fails",
+)
+
+MEF_FAILURE = SsvcDecisionPointValue(
+ name="MEF Failure",
+ key="MEF",
+ description="Any one mission essential function fails for period of time longer than acceptable; overall mission of the organization degraded but can still be accomplished for a time",
+)
+
+MEF_CRIPPLED = SsvcDecisionPointValue(
+ name="MEF Support Crippled",
+ key="MSC",
+ description="Activities that directly support essential functions are crippled; essential functions continue for a time",
+)
+
+
+MI_NED = SsvcDecisionPointValue(
+ name="Non-Essential Degraded",
+ key="NED",
+ description="Degradation of non-essential functions; chronic degradation would eventually harm essential functions",
+)
+
+MI_NONE = SsvcDecisionPointValue(
+ name="None", key="N", description="Little to no impact"
+)
+
+# combine MI_NONE and MI_NED into a single value
+DEGRADED = SsvcDecisionPointValue(
+ name="Degraded",
+ key="D",
+ description="Little to no impact up to degradation of non-essential functions; chronic degradation would eventually harm essential functions",
+)
+
+
+MISSION_IMPACT_1 = SsvcDecisionPoint(
+ name="Mission Impact",
+ description="Impact on Mission Essential Functions of the Organization",
+ key="MI",
+ version="1.0.0",
+ values=(
+ MI_NONE,
+ MI_NED,
+ MEF_CRIPPLED,
+ MEF_FAILURE,
+ MISSION_FAILURE,
+ ),
+)
+
+# SSVC v2.1 combined None and Non-Essential Degraded into a single value
+MISSION_IMPACT_2 = deepcopy(MISSION_IMPACT_1)
+MISSION_IMPACT_2.version = "2.0.0"
+MISSION_IMPACT_2.values = (DEGRADED, MEF_CRIPPLED, MEF_FAILURE, MISSION_FAILURE)
+
+
+def main():
+ print(MISSION_IMPACT_1.to_json(indent=2))
+ print(MISSION_IMPACT_2.to_json(indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/ssvc/decision_points/public_safety_impact.py b/src/ssvc/decision_points/public_safety_impact.py
new file mode 100644
index 00000000..6a1b6104
--- /dev/null
+++ b/src/ssvc/decision_points/public_safety_impact.py
@@ -0,0 +1,36 @@
+#!/usr/bin/env python
+"""
+file: public_safety_impact
+author: adh
+created_at: 9/21/23 10:43 AM
+"""
+from ssvc.decision_points.base import SsvcDecisionPoint, SsvcDecisionPointValue
+
+SIGNIFICANT = SsvcDecisionPointValue(
+ name="Significant",
+ description="Safety impact of Major, Hazardous, or Catastrophic.",
+ key="S",
+)
+
+MINIMAL = SsvcDecisionPointValue(
+ name="Minimal", description="Safety impact of None or Minor.", key="M"
+)
+
+PUBLIC_SAFETY_IMPACT_1 = SsvcDecisionPoint(
+ name="Public Safety Impact",
+ description="A coarse-grained representation of impact to public safety.",
+ key="PSI",
+ version="1.0.0",
+ values=(
+ MINIMAL,
+ SIGNIFICANT,
+ ),
+)
+
+
+def main():
+ print(PUBLIC_SAFETY_IMPACT_1.to_json(indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/ssvc/decision_points/public_value_added.py b/src/ssvc/decision_points/public_value_added.py
new file mode 100644
index 00000000..31385d57
--- /dev/null
+++ b/src/ssvc/decision_points/public_value_added.py
@@ -0,0 +1,42 @@
+#!/usr/bin/env python
+"""
+file: public_value_added
+author: adh
+created_at: 9/21/23 11:27 AM
+"""
+
+from ssvc.decision_points.base import SsvcDecisionPoint, SsvcDecisionPointValue
+
+LIMITED = SsvcDecisionPointValue(
+ name="Limited",
+ key="L",
+ description="Minimal value added to the existing public information because existing information is already high quality and in multiple outlets.",
+)
+
+AMPLIATIVE = SsvcDecisionPointValue(
+ name="Ampliative",
+ key="A",
+ description="Amplifies and/or augments the existing public information about the vulnerability, for example, adds additional detail, addresses or corrects errors in other public information, draws further attention to the vulnerability, etc.",
+)
+
+PRECEDENCE = SsvcDecisionPointValue(
+ name="Precedence",
+ key="P",
+ description="The publication would be the first publicly available, or be coincident with the first publicly available.",
+)
+
+PUBLIC_VALUE_ADDED_1 = SsvcDecisionPoint(
+ name="Public Value Added",
+ description="How much value would a publication from the coordinator benefit the broader community?",
+ key="PVA",
+ version="1.0.0",
+ values=(PRECEDENCE, AMPLIATIVE, LIMITED),
+)
+
+
+def main():
+ print(PUBLIC_VALUE_ADDED_1.to_json(indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/ssvc/decision_points/report_credibility.py b/src/ssvc/decision_points/report_credibility.py
new file mode 100644
index 00000000..38722be5
--- /dev/null
+++ b/src/ssvc/decision_points/report_credibility.py
@@ -0,0 +1,39 @@
+#!/usr/bin/env python
+"""
+file: report_credibility
+author: adh
+created_at: 9/21/23 11:24 AM
+"""
+
+from ssvc.decision_points.base import SsvcDecisionPoint, SsvcDecisionPointValue
+
+NOT_CREDIBLE = SsvcDecisionPointValue(
+ name="Not Credible",
+ key="NC",
+ description="The report is not credible.",
+)
+
+CREDIBLE = SsvcDecisionPointValue(
+ name="Credible",
+ key="C",
+ description="The report is credible.",
+)
+
+REPORT_CREDIBILITY_1 = SsvcDecisionPoint(
+ name="Report Credibility",
+ description="Is the report credible?",
+ key="RC",
+ version="1.0.0",
+ values=(
+ CREDIBLE,
+ NOT_CREDIBLE,
+ ),
+)
+
+
+def main():
+ print(REPORT_CREDIBILITY_1.to_json(indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/ssvc/decision_points/report_public.py b/src/ssvc/decision_points/report_public.py
new file mode 100644
index 00000000..4141ae89
--- /dev/null
+++ b/src/ssvc/decision_points/report_public.py
@@ -0,0 +1,38 @@
+#!/usr/bin/env python
+"""
+file: report_public
+author: adh
+created_at: 9/21/23 11:15 AM
+"""
+from ssvc.decision_points.base import SsvcDecisionPoint, SsvcDecisionPointValue
+
+YES = SsvcDecisionPointValue(
+ name="Yes",
+ key="Y",
+ description="A public report of the vulnerability exists.",
+)
+
+NO = SsvcDecisionPointValue(
+ name="No",
+ key="N",
+ description="No public report of the vulnerability exists.",
+)
+
+REPORT_PUBLIC_1 = SsvcDecisionPoint(
+ name="Report Public",
+ description="Is a viable report of the details of the vulnerability already publicly available?",
+ key="RP",
+ version="1.0.0",
+ values=(
+ NO,
+ YES,
+ ),
+)
+
+
+def main():
+ print(REPORT_PUBLIC_1.to_json(indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/ssvc/decision_points/safety_impact.py b/src/ssvc/decision_points/safety_impact.py
new file mode 100644
index 00000000..42466d05
--- /dev/null
+++ b/src/ssvc/decision_points/safety_impact.py
@@ -0,0 +1,87 @@
+#!/usr/bin/env python
+"""
+file: safety_impact
+author: adh
+created_at: 9/21/23 10:05 AM
+"""
+from copy import deepcopy
+
+from ssvc.decision_points.base import SsvcDecisionPoint, SsvcDecisionPointValue
+
+CATASTROPHIC = SsvcDecisionPointValue(
+ name="Catastrophic",
+ key="C",
+ description="Any one or more of these conditions hold. "
+ "Physical harm: Multiple immediate fatalities (Emergency response probably cannot save the victims.) "
+ "Operator resiliency: Operator incapacitated (includes fatality or otherwise incapacitated). "
+ "System resiliency: Total loss of whole cyber-physical system, of which the software is a part. "
+ "Environment: Extreme externalities (immediate public health threat, environmental damage leading to small ecosystem collapse, etc.) imposed on other parties. "
+ "Financial: Social systems (elections, financial grid, etc.) supported by the software collapse. "
+ "Psychological: N/A.",
+)
+
+HAZARDOUS = SsvcDecisionPointValue(
+ name="Hazardous",
+ key="H",
+ description="Any one or more of these conditions hold. "
+ "Physical harm: Serious or fatal injuries, where fatalities are plausibly preventable via emergency services or other measures. "
+ "Operator resiliency: Actions that would keep the system in a safe state are beyond system operator capabilities, resulting in adverse conditions; OR great physical distress to system operators such that they cannot be expected to operate the system properly. "
+ "System resiliency: Parts of the cyber-physical system break; system’s ability to recover lost functionality remains intact. "
+ "Environment: Serious externalities (threat to life as well as property, widespread environmental damage, measurable public health risks, etc.) imposed on other parties. "
+ "Financial: Socio-technical system (elections, financial grid, etc.) of which the affected component is a part is actively destabilized and enters unsafe state. "
+ "Psychological: N/A.",
+)
+
+MAJOR = SsvcDecisionPointValue(
+ name="Major",
+ key="J",
+ description="Any one or more of these conditions hold. "
+ "Physical harm: Physical distress and injuries for users (not operators) of the system. "
+ "Operator resiliency: Requires action by system operator to maintain safe system state as a result of exploitation of the "
+ "vulnerability where operator actions would be within their capabilities but the actions require their full attention and effort; OR significant distraction or discomfort to operators; OR causes significant occupational safety hazard. "
+ "System resiliency: System safety margin effectively eliminated but no actual harm; OR failure of system functional capabilities that support safe operation. "
+ "Environment: Major externalities (property damage, environmental damage, etc.) imposed on other parties. "
+ "Financial: Financial losses that likely lead to bankruptcy of multiple persons. "
+ "Psychological: Widespread emotional or psychological harm, sufficient to be cause for counselling or therapy, to populations of people.",
+)
+
+MINOR = SsvcDecisionPointValue(
+ name="Minor",
+ key="M",
+ description="Any one or more of these conditions hold. "
+ "Physical harm: Physical discomfort for users (not operators) of the system. "
+ "Operator resiliency: Requires action by system operator to maintain safe system state as a result of exploitation of the "
+ "vulnerability where operator actions would be well within expected operator abilities; OR causes a minor occupational safety hazard. "
+ "System resiliency: Small reduction in built-in system safety margins; OR small reduction in system functional capabilities that support safe operation. "
+ "Environment Minor externalities (property damage, environmental damage, etc.) imposed on other parties. "
+ "Financial Financial losses, which are not readily absorbable, to multiple persons. "
+ "Psychological: Emotional or psychological harm, sufficient to be cause for counselling or therapy, to multiple persons.",
+)
+
+SAF_NONE = SsvcDecisionPointValue(
+ name="None",
+ key="N",
+ description="The effect is below the threshold for all aspects described in Minor.",
+)
+
+SAFETY_IMPACT_1 = SsvcDecisionPoint(
+ name="Safety Impact",
+ description="The safety impact of the vulnerability.",
+ key="SI",
+ version="1.0.0",
+ values=(
+ SAF_NONE,
+ MINOR,
+ MAJOR,
+ HAZARDOUS,
+ CATASTROPHIC,
+ ),
+)
+
+
+def main():
+ print(SAFETY_IMPACT_1.to_json(indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/ssvc/decision_points/supplier_cardinality.py b/src/ssvc/decision_points/supplier_cardinality.py
new file mode 100644
index 00000000..ebde9d27
--- /dev/null
+++ b/src/ssvc/decision_points/supplier_cardinality.py
@@ -0,0 +1,38 @@
+#!/usr/bin/env python
+"""
+file: supplier_cardinality
+author: adh
+created_at: 9/21/23 11:20 AM
+"""
+from ssvc.decision_points.base import SsvcDecisionPoint, SsvcDecisionPointValue
+
+MULTIPLE = SsvcDecisionPointValue(
+ name="Multiple",
+ key="M",
+ description="There are multiple suppliers of the vulnerable component.",
+)
+
+ONE = SsvcDecisionPointValue(
+ name="One",
+ key="O",
+ description="There is only one supplier of the vulnerable component.",
+)
+
+SUPPLIER_CARDINALITY_1 = SsvcDecisionPoint(
+ name="Supplier Cardinality",
+ description="How many suppliers are responsible for the vulnerable component and its remediation or mitigation plan?",
+ key="SC",
+ version="1.0.0",
+ values=(
+ ONE,
+ MULTIPLE,
+ ),
+)
+
+
+def main():
+ print(SUPPLIER_CARDINALITY_1.to_json(indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/ssvc/decision_points/supplier_contacted.py b/src/ssvc/decision_points/supplier_contacted.py
new file mode 100644
index 00000000..eff08419
--- /dev/null
+++ b/src/ssvc/decision_points/supplier_contacted.py
@@ -0,0 +1,38 @@
+#!/usr/bin/env python
+"""
+file: supplier_contacted
+author: adh
+created_at: 9/21/23 11:17 AM
+"""
+from ssvc.decision_points.base import SsvcDecisionPoint, SsvcDecisionPointValue
+
+YES = SsvcDecisionPointValue(
+ name="Yes",
+ key="Y",
+ description="The supplier has been contacted.",
+)
+
+NO = SsvcDecisionPointValue(
+ name="No",
+ key="N",
+ description="The supplier has not been contacted.",
+)
+
+SUPPLIER_CONTACTED_1 = SsvcDecisionPoint(
+ name="Supplier Contacted",
+ description="Has the reporter made a good-faith effort to contact the supplier of the vulnerable component using a quality contact method?",
+ key="SC",
+ version="1.0.0",
+ values=(
+ NO,
+ YES,
+ ),
+)
+
+
+def main():
+ print(SUPPLIER_CONTACTED_1.to_json(indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/ssvc/decision_points/supplier_engagement.py b/src/ssvc/decision_points/supplier_engagement.py
new file mode 100644
index 00000000..69380931
--- /dev/null
+++ b/src/ssvc/decision_points/supplier_engagement.py
@@ -0,0 +1,39 @@
+#!/usr/bin/env python
+"""
+file: supplier_engagement
+author: adh
+created_at: 9/21/23 11:22 AM
+"""
+
+from ssvc.decision_points.base import SsvcDecisionPoint, SsvcDecisionPointValue
+
+UNRESPONSIVE = SsvcDecisionPointValue(
+ name="Unresponsive",
+ key="U",
+ description="The supplier is not responding to the reporter’s contact effort and not actively participating in the coordination effort.",
+)
+
+ACTIVE = SsvcDecisionPointValue(
+ name="Active",
+ key="A",
+ description="The supplier is responding to the reporter’s contact effort and actively participating in the coordination effort.",
+)
+
+SUPPLIER_ENGAGEMENT_1 = SsvcDecisionPoint(
+ name="Supplier Engagement",
+ description="Is the supplier responding to the reporter’s contact effort and actively participating in the coordination effort?",
+ key="SE",
+ version="1.0.0",
+ values=(
+ ACTIVE,
+ UNRESPONSIVE,
+ ),
+)
+
+
+def main():
+ print(SUPPLIER_ENGAGEMENT_1.to_json(indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/ssvc/decision_points/supplier_involvement.py b/src/ssvc/decision_points/supplier_involvement.py
new file mode 100644
index 00000000..afc3ce07
--- /dev/null
+++ b/src/ssvc/decision_points/supplier_involvement.py
@@ -0,0 +1,46 @@
+#!/usr/bin/env python
+"""
+file: supplier_involvement
+author: adh
+created_at: 9/21/23 11:28 AM
+"""
+
+from ssvc.decision_points.base import SsvcDecisionPoint, SsvcDecisionPointValue
+
+UNCOOPERATIVE = SsvcDecisionPointValue(
+ name="Uncooperative/Unresponsive",
+ key="UU",
+ description="The supplier has not responded, declined to generate a remediation, or no longer exists.",
+)
+
+COOPERATIVE = SsvcDecisionPointValue(
+ name="Cooperative",
+ key="C",
+ description="The supplier is actively generating a patch or fix; they may or may not have provided a mitigation or work-around in the mean time.",
+)
+
+FIX_READY = SsvcDecisionPointValue(
+ name="Fix Ready",
+ key="FR",
+ description="The supplier has provided a patch or fix.",
+)
+
+SUPPLIER_INVOLVEMENT_1 = SsvcDecisionPoint(
+ name="Supplier Involvement",
+ description="What is the state of the supplier’s work on addressing the vulnerability?",
+ key="SI",
+ version="1.0.0",
+ values=(
+ FIX_READY,
+ COOPERATIVE,
+ UNCOOPERATIVE,
+ ),
+)
+
+
+def main():
+ print(SUPPLIER_INVOLVEMENT_1.to_json(indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/ssvc/decision_points/system_exposure.py b/src/ssvc/decision_points/system_exposure.py
new file mode 100644
index 00000000..c5a1978b
--- /dev/null
+++ b/src/ssvc/decision_points/system_exposure.py
@@ -0,0 +1,71 @@
+#!/usr/bin/env python
+"""
+file: exposure
+author: adh
+created_at: 9/21/23 10:16 AM
+"""
+from copy import deepcopy
+from ssvc.decision_points.base import SsvcDecisionPoint, SsvcDecisionPointValue
+
+EXP_UNAVOIDABLE = SsvcDecisionPointValue(
+ name="Unavoidable",
+ key="U",
+ description="Internet or another widely accessible network where access cannot plausibly be restricted or "
+ "controlled (e.g., DNS servers, web servers, VOIP servers, email servers)",
+)
+
+EXP_CONTROLLED = SsvcDecisionPointValue(
+ name="Controlled",
+ key="C",
+ description="Networked service with some access restrictions or mitigations already in place (whether locally or on the network). "
+ "A successful mitigation must reliably interrupt the adversary’s attack, which requires the attack is detectable "
+ "both reliably and quickly enough to respond. Controlled covers the situation in which a vulnerability can be "
+ "exploited through chaining it with other vulnerabilities. The assumption is that the number of steps in the "
+ "attack path is relatively low; if the path is long enough that it is implausible for an adversary to reliably "
+ "execute it, then exposure should be small.",
+)
+
+EXP_SMALL = SsvcDecisionPointValue(
+ name="Small",
+ key="S",
+ description="Local service or program; highly controlled network",
+)
+
+
+SYSTEM_EXPOSURE_1 = SsvcDecisionPoint(
+ name="System Exposure",
+ description="The Accessible Attack Surface of the Affected System or Service",
+ key="EXP",
+ version="1.0.0",
+ values=(
+ EXP_SMALL,
+ EXP_CONTROLLED,
+ EXP_UNAVOIDABLE,
+ ),
+)
+
+# EXP_OPEN is just a rename of EXP_UNAVOIDABLE
+EXP_OPEN = deepcopy(EXP_UNAVOIDABLE)
+EXP_OPEN.name = "Open"
+EXP_OPEN.key = "O"
+
+SYSTEM_EXPOSURE_1_0_1 = SsvcDecisionPoint(
+ name="System Exposure",
+ description="The Accessible Attack Surface of the Affected System or Service",
+ key="EXP",
+ version="1.0.1",
+ values=(
+ EXP_SMALL,
+ EXP_CONTROLLED,
+ EXP_OPEN,
+ ),
+)
+
+
+def main():
+ print(SYSTEM_EXPOSURE_1.to_json(indent=2))
+ print(SYSTEM_EXPOSURE_1_0_1.to_json(indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/ssvc/decision_points/technical_impact.py b/src/ssvc/decision_points/technical_impact.py
new file mode 100644
index 00000000..da042f62
--- /dev/null
+++ b/src/ssvc/decision_points/technical_impact.py
@@ -0,0 +1,38 @@
+#!/usr/bin/env python
+"""
+file: technical_impact
+author: adh
+created_at: 9/21/23 9:49 AM
+"""
+from ssvc.decision_points.base import SsvcDecisionPoint, SsvcDecisionPointValue
+
+TOTAL = SsvcDecisionPointValue(
+ name="Total",
+ key="T",
+ description="The exploit gives the adversary total control over the behavior of the software, or it gives total disclosure of all information on the system that contains the vulnerability.",
+)
+
+PARTIAL = SsvcDecisionPointValue(
+ name="Partial",
+ key="P",
+ description="The exploit gives the adversary limited control over, or information exposure about, the behavior of the software that contains the vulnerability. Or the exploit gives the adversary an importantly low stochastic opportunity for total control.",
+)
+
+TECHNICAL_IMPACT_1 = SsvcDecisionPoint(
+ name="Technical Impact",
+ description="The technical impact of the vulnerability.",
+ key="TI",
+ version="1.0.0",
+ values=(
+ PARTIAL,
+ TOTAL,
+ ),
+)
+
+
+def main():
+ print(TECHNICAL_IMPACT_1.to_json(indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/ssvc/decision_points/utility.py b/src/ssvc/decision_points/utility.py
new file mode 100644
index 00000000..eeb3c591
--- /dev/null
+++ b/src/ssvc/decision_points/utility.py
@@ -0,0 +1,74 @@
+#!/usr/bin/env python
+"""
+file: utility
+author: adh
+created_at: 9/21/23 9:55 AM
+"""
+from copy import deepcopy
+
+from ssvc.decision_points.base import SsvcDecisionPoint, SsvcDecisionPointValue
+
+SUPER_EFFECTIVE_2 = SsvcDecisionPointValue(
+ name="Super Effective",
+ key="S",
+ description="Yes to automatable and concentrated value",
+)
+
+EFFICIENT_2 = SsvcDecisionPointValue(
+ name="Efficient",
+ key="E",
+ description="Yes to automatable and diffuse value OR No to automatable and concentrated value",
+)
+
+LABORIOUS_2 = SsvcDecisionPointValue(
+ name="Laborious", key="L", description="No to automatable and diffuse value"
+)
+
+SUPER_EFFECTIVE = SsvcDecisionPointValue(
+ name="Super Effective",
+ key="S",
+ description="Rapid virulence and concentrated value",
+)
+
+EFFICIENT = SsvcDecisionPointValue(
+ name="Efficient",
+ key="E",
+ description="Rapid virulence and diffuse value OR Slow virulence and concentrated value",
+)
+
+LABORIOUS = SsvcDecisionPointValue(
+ name="Laborious", key="L", description="Slow virulence and diffuse value"
+)
+
+UTILITY_1 = SsvcDecisionPoint(
+ name="Utility",
+ description="The Usefulness of the Exploit to the Adversary",
+ key="U",
+ version="1.0.0",
+ values=(
+ LABORIOUS,
+ EFFICIENT,
+ SUPER_EFFECTIVE,
+ ),
+)
+
+UTILITY_1_0_1 = SsvcDecisionPoint(
+ name="Utility",
+ description="The Usefulness of the Exploit to the Adversary",
+ key="U",
+ version="1.0.1",
+ values=(
+ LABORIOUS_2,
+ EFFICIENT_2,
+ SUPER_EFFECTIVE_2,
+ ),
+)
+
+
+def main():
+ print(UTILITY_1.to_json(indent=2))
+ print(UTILITY_1_0_1.to_json(indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/ssvc/decision_points/value_density.py b/src/ssvc/decision_points/value_density.py
new file mode 100644
index 00000000..eac48a13
--- /dev/null
+++ b/src/ssvc/decision_points/value_density.py
@@ -0,0 +1,38 @@
+#!/usr/bin/env python
+"""
+file: value_density
+author: adh
+created_at: 9/21/23 10:01 AM
+"""
+from ssvc.decision_points.base import SsvcDecisionPoint, SsvcDecisionPointValue
+
+CONCENTRATED = SsvcDecisionPointValue(
+ name="Concentrated",
+ key="C",
+ description="The system that contains the vulnerable component is rich in resources. Heuristically, such systems are often the direct responsibility of “system operators” rather than users.",
+)
+
+DIFFUSE = SsvcDecisionPointValue(
+ name="Diffuse",
+ key="D",
+ description="The system that contains the vulnerable component has limited resources. That is, the resources that the adversary will gain control over with a single exploitation event are relatively small.",
+)
+
+VALUE_DENSITY_1 = SsvcDecisionPoint(
+ name="Value Density",
+ description="The concentration of value in the target",
+ key="VD",
+ version="1.0.0",
+ values=(
+ DIFFUSE,
+ CONCENTRATED,
+ ),
+)
+
+
+def main():
+ print(VALUE_DENSITY_1.to_json(indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/ssvc/decision_points/virulence.py b/src/ssvc/decision_points/virulence.py
new file mode 100644
index 00000000..289263b0
--- /dev/null
+++ b/src/ssvc/decision_points/virulence.py
@@ -0,0 +1,38 @@
+#!/usr/bin/env python
+"""
+file: virulence
+author: adh
+created_at: 9/21/23 9:58 AM
+"""
+from ssvc.decision_points.base import SsvcDecisionPoint, SsvcDecisionPointValue
+
+RAPID = SsvcDecisionPointValue(
+ name="Rapid",
+ key="R",
+ description="Steps 1-4 of the of the kill chain can be reliably automated. If the vulnerability allows remote code execution or command injection, the default response should be rapid.",
+)
+
+SLOW = SsvcDecisionPointValue(
+ name="Slow",
+ key="S",
+ description="Steps 1-4 of the kill chain cannot be reliably automated for this vulnerability for some reason. These steps are reconnaissance, weaponization, delivery, and exploitation. Example reasons for why a step may not be reliably automatable include (1) the vulnerable component is not searchable or enumerable on the network, (2) weaponization may require human direction for each target, (3) delivery may require channels that widely deployed network security configurations block, and (3) exploitation may be frustrated by adequate exploit-prevention techniques enabled by default; ASLR is an example of an exploit-prevention tool.",
+)
+
+VIRULENCE_1 = SsvcDecisionPoint(
+ name="Virulence",
+ description="The speed at which the vulnerability can be exploited.",
+ key="V",
+ version="1.0.0",
+ values=(
+ SLOW,
+ RAPID,
+ ),
+)
+
+
+def main():
+ print(VIRULENCE_1.to_json(indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/ssvc/dp_groups/__init__.py b/src/ssvc/dp_groups/__init__.py
new file mode 100644
index 00000000..a3a2a181
--- /dev/null
+++ b/src/ssvc/dp_groups/__init__.py
@@ -0,0 +1,14 @@
+#!/usr/bin/env python
+"""
+file: __init__.py
+author: adh
+created_at: 9/20/23 4:47 PM
+"""
+
+
+def main():
+ pass
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/ssvc/dp_groups/base.py b/src/ssvc/dp_groups/base.py
new file mode 100644
index 00000000..706bec3c
--- /dev/null
+++ b/src/ssvc/dp_groups/base.py
@@ -0,0 +1,63 @@
+#!/usr/bin/env python
+"""
+file: base
+author: adh
+created_at: 9/20/23 4:47 PM
+"""
+from dataclasses import dataclass
+from typing import Tuple
+
+from dataclasses_json import dataclass_json
+
+from ssvc._mixins import _Base, _Versioned
+from ssvc.decision_points.base import SsvcDecisionPoint
+
+
+@dataclass_json
+@dataclass(kw_only=True)
+class SsvcDecisionPointGroup(_Base, _Versioned):
+ """
+ Models a group of decision points.
+ """
+
+ decision_points: Tuple[SsvcDecisionPoint]
+
+
+def get_all_decision_points_from(
+ glist: list[SsvcDecisionPointGroup],
+) -> Tuple[SsvcDecisionPoint]:
+ """
+ Given a list of SsvcDecisionPointGroup objects, return a list of all
+ the unique SsvcDecisionPoint objects contained in those groups.
+
+ Args:
+ groups (list): A list of SsvcDecisionPointGroup objects.
+
+ Returns:
+ list: A list of SsvcDecisionPoint objects.
+ """
+ dps = []
+ seen = set()
+
+ for group in glist:
+ for dp in group.decision_points:
+ if dp in dps:
+ # skip duplicates
+ continue
+ key = (dp.name, dp.version)
+ if key in seen:
+ # skip duplicates
+ continue
+ # keep non-duplicates
+ dps.append(dp)
+ seen.add(key)
+
+ return tuple(dps)
+
+
+def main():
+ pass
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/ssvc/dp_groups/coordinator_publication.py b/src/ssvc/dp_groups/coordinator_publication.py
new file mode 100644
index 00000000..63d3a34a
--- /dev/null
+++ b/src/ssvc/dp_groups/coordinator_publication.py
@@ -0,0 +1,39 @@
+#!/usr/bin/env python
+"""
+file: coordinator_publication
+author: adh
+created_at: 9/21/23 11:40 AM
+"""
+from ssvc.decision_points.exploitation import EXPLOITATION_1
+from ssvc.decision_points.public_value_added import PUBLIC_VALUE_ADDED_1
+from ssvc.decision_points.supplier_involvement import SUPPLIER_INVOLVEMENT_1
+from ssvc.dp_groups.base import SsvcDecisionPointGroup
+
+
+COORDINATOR_PUBLICATION_1 = SsvcDecisionPointGroup(
+ name="Coordinator Publication",
+ description="The decision points used by the coordinator during publication.",
+ version="1.0.0",
+ decision_points=(
+ SUPPLIER_INVOLVEMENT_1,
+ EXPLOITATION_1,
+ PUBLIC_VALUE_ADDED_1,
+ ),
+)
+"""
+Added in SSVC v2, the Coordinator Publication v1.0.0 decision points are used by the coordinator during the publication process.
+
+It includes decision points:
+
+- Supplier Involvement v1.0.0
+- Exploitation v1.0.0
+- Public Value Added v1.0.0
+"""
+
+
+def main():
+ print(COORDINATOR_PUBLICATION_1.to_json(indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/ssvc/dp_groups/coordinator_triage.py b/src/ssvc/dp_groups/coordinator_triage.py
new file mode 100644
index 00000000..6d6f352b
--- /dev/null
+++ b/src/ssvc/dp_groups/coordinator_triage.py
@@ -0,0 +1,60 @@
+#!/usr/bin/env python
+"""
+file: coordinator_triage
+author: adh
+created_at: 9/21/23 11:40 AM
+"""
+from ssvc.decision_points.automatable import AUTOMATABLE_1
+from ssvc.decision_points.public_safety_impact import PUBLIC_SAFETY_IMPACT_1
+from ssvc.decision_points.report_credibility import REPORT_CREDIBILITY_1
+from ssvc.decision_points.report_public import REPORT_PUBLIC_1
+from ssvc.decision_points.safety_impact import SAFETY_IMPACT_1
+from ssvc.decision_points.supplier_cardinality import SUPPLIER_CARDINALITY_1
+from ssvc.decision_points.supplier_contacted import SUPPLIER_CONTACTED_1
+from ssvc.decision_points.supplier_engagement import SUPPLIER_ENGAGEMENT_1
+from ssvc.decision_points.utility import UTILITY_1_0_1
+from ssvc.decision_points.value_density import VALUE_DENSITY_1
+from ssvc.dp_groups.base import SsvcDecisionPointGroup
+
+
+COORDINATOR_TRIAGE_1 = SsvcDecisionPointGroup(
+ name="Coordinator Triage",
+ description="The decision points used by the coordinator during triage.",
+ version="1.0.0",
+ decision_points=(
+ REPORT_PUBLIC_1,
+ SUPPLIER_CONTACTED_1,
+ REPORT_CREDIBILITY_1,
+ SUPPLIER_CARDINALITY_1,
+ SUPPLIER_ENGAGEMENT_1,
+ UTILITY_1_0_1,
+ AUTOMATABLE_1,
+ VALUE_DENSITY_1,
+ PUBLIC_SAFETY_IMPACT_1,
+ SAFETY_IMPACT_1,
+ ),
+)
+"""
+Added in SSVC v2, the Coordinator Triage v1.0.0 decision points are used by the coordinator during the intake and triage process.
+
+It includes decision points:
+
+- Report Public v1.0.0
+- Supplier Contacted v1.0.0
+- Report Credibility v1.0.0
+- Supplier Cardinality v1.0.0
+- Supplier Engagement v1.0.0
+- Utility v1.0.1, which depends on
+ - Value Density v1.0.0
+ - Automatable v1.0.0
+- Public Safety Impact v1.0.0. which depends on
+ - Safety Impact v1.0.0
+"""
+
+
+def main():
+ print(COORDINATOR_TRIAGE_1.to_json(indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/ssvc/dp_groups/deployer.py b/src/ssvc/dp_groups/deployer.py
new file mode 100644
index 00000000..e375510e
--- /dev/null
+++ b/src/ssvc/dp_groups/deployer.py
@@ -0,0 +1,117 @@
+#!/usr/bin/env python
+"""
+file: deployer
+author: adh
+created_at: 9/21/23 11:40 AM
+"""
+
+from ssvc.decision_points.automatable import AUTOMATABLE_1
+from ssvc.decision_points.exploitation import EXPLOITATION_1
+from ssvc.decision_points.human_impact import HUMAN_IMPACT_1
+from ssvc.decision_points.mission_impact import MISSION_IMPACT_1, MISSION_IMPACT_2
+from ssvc.decision_points.safety_impact import SAFETY_IMPACT_1
+from ssvc.decision_points.system_exposure import (
+ SYSTEM_EXPOSURE_1,
+ SYSTEM_EXPOSURE_1_0_1,
+)
+from ssvc.decision_points.utility import UTILITY_1_0_1
+from ssvc.decision_points.value_density import VALUE_DENSITY_1
+from ssvc.dp_groups.base import SsvcDecisionPointGroup
+
+PATCH_APPLIER_1 = SsvcDecisionPointGroup(
+ name="SSVC Patch Applier",
+ description="The decision points used by the patch applier.",
+ version="1.0.0",
+ decision_points=[
+ EXPLOITATION_1,
+ SYSTEM_EXPOSURE_1,
+ MISSION_IMPACT_1,
+ SAFETY_IMPACT_1,
+ ],
+)
+"""
+In SSVC v1, Patch Applier v1 represents the decision points used by the patch applier.
+It includes decision points:
+
+- Exploitation v1.0.0
+- System Exposure v1.0.0
+- Mission Impact v1.0.0
+- Safety Impact v1.0.0.
+"""
+
+
+# alias for forward compatibility
+DEPLOYER_1 = PATCH_APPLIER_1
+
+# SSVC v2
+DEPLOYER_2 = SsvcDecisionPointGroup(
+ name="SSVC Deployer",
+ description="The decision points used by the deployer.",
+ version="2.0.0",
+ decision_points=[
+ EXPLOITATION_1,
+ SYSTEM_EXPOSURE_1_0_1,
+ MISSION_IMPACT_1,
+ SAFETY_IMPACT_1,
+ UTILITY_1_0_1,
+ AUTOMATABLE_1,
+ VALUE_DENSITY_1,
+ HUMAN_IMPACT_1,
+ ],
+)
+"""
+Deployer v2.0.0 is renamed from Patch Applier v1.0.0.
+It includes decision points:
+
+- Exploitation v1.0.0
+- System Exposure v1.0.1
+- Human Impact v1.0.0 (consolidate Mission Impact v1.0.0 and Safety Impact v1.0.0)
+ - Safety Impact v1.0.0
+ - Mission Impact v1.0.0
+- Utility v1.0.1 (consolidate Automatable v1.0.0 and Value Density v1.0.0)
+ - Automatable v1.0.0
+ - Value Density v1.0.0
+
+Changes from Patch Applier v1.0.0:
+- System Exposure v1.0.0 -> v1.0.1
+- Utility v1.0.1 is added, which depends on Automatable v1.0.0 and Value Density v1.0.0
+- Human Impact v1.0.0 is added, which depends on Mission Impact v1.0.0 and Safety Impact v1.0.0
+"""
+
+DEPLOYER_3 = SsvcDecisionPointGroup(
+ name="SSVC Deployer",
+ description="The decision points used by the deployer.",
+ version="3.0.0",
+ decision_points=(
+ EXPLOITATION_1,
+ SYSTEM_EXPOSURE_1_0_1,
+ MISSION_IMPACT_2,
+ SAFETY_IMPACT_1,
+ AUTOMATABLE_1,
+ HUMAN_IMPACT_1,
+ ),
+)
+"""
+In SSVC 2.1, Deployer 3.0.0 includes decision points:
+
+- Exploitation 1.0.0
+- System Exposure 1.0.1
+- Automatable 1.0.0
+- Human Impact 1.0.0
+ - Safety Impact 1.0.0
+ - Mission Impact 2.0.0
+
+Changes from v2.0.0:
+- removes Utility v1.0.1 in favor of Automatable v1.0.0.
+- Mission Impact v1.0.0 -> v2.0.0
+"""
+
+
+def main():
+ print(PATCH_APPLIER_1.to_json(indent=2))
+ print(DEPLOYER_2.to_json(indent=2))
+ print(DEPLOYER_3.to_json(indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/ssvc/dp_groups/supplier.py b/src/ssvc/dp_groups/supplier.py
new file mode 100644
index 00000000..126ec913
--- /dev/null
+++ b/src/ssvc/dp_groups/supplier.py
@@ -0,0 +1,87 @@
+#!/usr/bin/env python
+"""
+file: supplier
+author: adh
+created_at: 9/21/23 11:41 AM
+"""
+
+from ssvc.decision_points.automatable import AUTOMATABLE_1
+from ssvc.decision_points.exploitation import EXPLOITATION_1
+from ssvc.decision_points.safety_impact import SAFETY_IMPACT_1
+from ssvc.decision_points.technical_impact import TECHNICAL_IMPACT_1
+from ssvc.decision_points.utility import UTILITY_1, UTILITY_1_0_1
+from ssvc.decision_points.value_density import VALUE_DENSITY_1
+from ssvc.decision_points.virulence import VIRULENCE_1
+from ssvc.dp_groups.base import SsvcDecisionPointGroup
+
+PATCH_DEVELOPER_1 = SsvcDecisionPointGroup(
+ name="SSVC Patch Developer",
+ description="The decision points used by the patch developer.",
+ version="1.0.0",
+ decision_points=(
+ EXPLOITATION_1,
+ UTILITY_1,
+ TECHNICAL_IMPACT_1,
+ VIRULENCE_1,
+ VALUE_DENSITY_1,
+ SAFETY_IMPACT_1,
+ ),
+)
+"""
+In SSVC v1, Patch Developer v1 represents the decision points used by the patch developer.
+
+It includes decision points:
+
+- Exploitation v1.0.0
+- Utility v1.0.0
+ - Virulence v1.0.0
+ - Value Density v1.0.0
+- Technical Impact v1.0.0
+- Safety Impact v1.0.0
+"""
+
+# alias for forward compatibility
+SUPPLIER_1 = PATCH_DEVELOPER_1
+
+# SSVC v2 renamed to SSVC Supplier
+SUPPLIER_2 = SsvcDecisionPointGroup(
+ name="SSVC Supplier",
+ description="The decision points used by the supplier.",
+ version="2.0.0",
+ decision_points=[
+ EXPLOITATION_1,
+ UTILITY_1_0_1,
+ TECHNICAL_IMPACT_1,
+ AUTOMATABLE_1,
+ VALUE_DENSITY_1,
+ SAFETY_IMPACT_1,
+ ],
+)
+"""
+In SSVC v2, Supplier v2 represents the decision points used by the supplier.
+It includes decision points:
+
+- Exploitation v1.0.0
+- Utility v1.0.1
+ - Automatable v1.0.0
+ - Value Density v1.0.0
+- Technical Impact v1.0.0
+- Public Safety Impact v1.0.0
+ - Safety Impact v1.0.0
+
+Changes from Patch Developer v1:
+
+- Name change from Patch Developer v1 -> Supplier v2
+- Utility v1.0.0 -> v1.0.1
+- Virulence v1.0.0 replaced by Automatable v1.0.0
+- Public Safety Impact v1.0.0 added, which subsumes Safety Impact v1.0.0
+"""
+
+
+def main():
+ print(PATCH_DEVELOPER_1.to_json(indent=2))
+ print(SUPPLIER_2.to_json(indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/ssvc/dp_groups/v1.py b/src/ssvc/dp_groups/v1.py
new file mode 100644
index 00000000..049b34db
--- /dev/null
+++ b/src/ssvc/dp_groups/v1.py
@@ -0,0 +1,28 @@
+#!/usr/bin/env python
+"""
+file: v1
+author: adh
+created_at: 9/21/23 9:52 AM
+"""
+from ssvc.dp_groups.base import SsvcDecisionPointGroup, get_all_decision_points_from
+
+# convenience imports
+from ssvc.dp_groups.deployer import PATCH_APPLIER_1 # noqa
+from ssvc.dp_groups.supplier import PATCH_DEVELOPER_1 # noqa
+
+GROUPS = [PATCH_APPLIER_1, PATCH_DEVELOPER_1]
+
+SSVCv1 = SsvcDecisionPointGroup(
+ name="SSVCv1",
+ description="The first version of the SSVC.",
+ version="1.0.0",
+ decision_points=get_all_decision_points_from(GROUPS),
+)
+
+
+def main():
+ print(SSVCv1.to_json(indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/ssvc/dp_groups/v2.py b/src/ssvc/dp_groups/v2.py
new file mode 100644
index 00000000..636217e0
--- /dev/null
+++ b/src/ssvc/dp_groups/v2.py
@@ -0,0 +1,36 @@
+#!/usr/bin/env python
+"""
+file: v2
+author: adh
+created_at: 9/21/23 10:31 AM
+"""
+
+from ssvc.dp_groups.base import SsvcDecisionPointGroup, get_all_decision_points_from
+
+# convenience imports
+from ssvc.dp_groups.coordinator_publication import COORDINATOR_PUBLICATION_1 # noqa
+from ssvc.dp_groups.coordinator_triage import COORDINATOR_TRIAGE_1 # noqa
+from ssvc.dp_groups.deployer import DEPLOYER_2 # noqa
+from ssvc.dp_groups.supplier import SUPPLIER_2 # noqa
+
+GROUPS = [COORDINATOR_PUBLICATION_1, COORDINATOR_TRIAGE_1, DEPLOYER_2, SUPPLIER_2]
+
+
+SSVCv2 = SsvcDecisionPointGroup(
+ name="SSVCv2",
+ description="The second version of the SSVC.",
+ version="2.0.0",
+ decision_points=get_all_decision_points_from(GROUPS),
+)
+
+
+def main():
+ print(SSVCv2.to_json(indent=2))
+ print(SUPPLIER_2.to_json(indent=2))
+ print(DEPLOYER_2.to_json(indent=2))
+ print(COORDINATOR_TRIAGE_1.to_json(indent=2))
+ print(COORDINATOR_PUBLICATION_1.to_json(indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/ssvc/dp_groups/v2_1.py b/src/ssvc/dp_groups/v2_1.py
new file mode 100644
index 00000000..f2409de5
--- /dev/null
+++ b/src/ssvc/dp_groups/v2_1.py
@@ -0,0 +1,35 @@
+#!/usr/bin/env python
+"""
+file: v2_1
+author: adh
+created_at: 9/21/23 11:45 AM
+"""
+
+from ssvc.dp_groups.base import SsvcDecisionPointGroup, get_all_decision_points_from
+
+# convenience imports
+from ssvc.dp_groups.coordinator_publication import COORDINATOR_PUBLICATION_1
+from ssvc.dp_groups.coordinator_triage import COORDINATOR_TRIAGE_1
+from ssvc.dp_groups.deployer import DEPLOYER_3
+from ssvc.dp_groups.supplier import SUPPLIER_2
+
+GROUPS = [COORDINATOR_PUBLICATION_1, COORDINATOR_TRIAGE_1, DEPLOYER_3, SUPPLIER_2]
+
+
+SSVCv2_1 = SsvcDecisionPointGroup(
+ name="SSVCv2.1",
+ description="The second version of the SSVC.",
+ version="2.1.0",
+ decision_points=get_all_decision_points_from(GROUPS),
+)
+
+
+def main():
+ for group in GROUPS:
+ print(group.to_json(indent=2))
+ print()
+ print(SSVCv2_1.to_json(indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/test/__init__.py b/src/test/__init__.py
index e69de29b..5c4aa405 100644
--- a/src/test/__init__.py
+++ b/src/test/__init__.py
@@ -0,0 +1,14 @@
+#!/usr/bin/env python
+"""
+file: __init__.py
+author: adh
+created_at: 9/27/23 3:56 PM
+"""
+
+
+def main():
+ pass
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/test/test_analyze_csv.py b/src/test/test_analyze_csv.py
index 115c460d..742ee25f 100644
--- a/src/test/test_analyze_csv.py
+++ b/src/test/test_analyze_csv.py
@@ -1,10 +1,8 @@
-import contextlib
-import io
import unittest
import pandas as pd
-import analyze_csv as acsv
+from ssvc import csv_analyzer as acsv
class MyTestCase(unittest.TestCase):
diff --git a/src/test/test_dp_base.py b/src/test/test_dp_base.py
new file mode 100644
index 00000000..cb1ecaac
--- /dev/null
+++ b/src/test/test_dp_base.py
@@ -0,0 +1,63 @@
+import unittest
+import ssvc.decision_points.base as base
+
+
+class MyTestCase(unittest.TestCase):
+ def setUp(self) -> None:
+ self.value = base.SsvcDecisionPointValue(
+ name="foo", key="bar", description="baz"
+ )
+
+ self.dp = base.SsvcDecisionPoint(
+ name="foo",
+ key="bar",
+ description="baz",
+ version="1.0.0",
+ namespace="ns",
+ values=(self.value,),
+ )
+
+ def test_ssvc_value(self):
+ obj = self.value
+ # should have name, key, description
+ self.assertEqual(obj.name, "foo")
+ self.assertEqual(obj.key, "bar")
+ self.assertEqual(obj.description, "baz")
+
+ # should not have namespace, version
+ self.assertFalse(hasattr(obj, "namespace"))
+ self.assertFalse(hasattr(obj, "version"))
+
+ def test_ssvc_decision_point(self):
+ obj = self.dp
+ # should have name, key, description, values, version, namespace
+ self.assertEqual(obj.name, "foo")
+ self.assertEqual(obj.key, "bar")
+ self.assertEqual(obj.description, "baz")
+ self.assertEqual(obj.version, "1.0.0")
+ self.assertEqual(obj.namespace, "ns")
+ self.assertEqual(len(obj.values), 1)
+
+ def test_ssvc_value_json_roundtrip(self):
+ obj = self.value
+
+ json = obj.to_json()
+ self.assertIsInstance(json, str)
+ self.assertGreater(len(json), 0)
+
+ obj2 = base.SsvcDecisionPointValue.from_json(json)
+ self.assertEqual(obj, obj2)
+
+ def test_ssvc_decision_point_json_roundtrip(self):
+ obj = self.dp
+
+ json = obj.to_json()
+ self.assertIsInstance(json, str)
+ self.assertGreater(len(json), 0)
+
+ obj2 = base.SsvcDecisionPoint.from_json(json)
+ self.assertEqual(obj, obj2)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/src/test/test_mixins.py b/src/test/test_mixins.py
new file mode 100644
index 00000000..53e7c517
--- /dev/null
+++ b/src/test/test_mixins.py
@@ -0,0 +1,155 @@
+import unittest
+from dataclasses import dataclass
+
+from dataclasses_json import dataclass_json
+
+from ssvc._mixins import _Base, _Keyed, _Versioned, _Namespaced
+
+
+class TestMixins(unittest.TestCase):
+ def setUp(self) -> None:
+ self.obj = _Base(name="foo", description="baz")
+
+ def test_ssvc_base_create(self):
+ obj = _Base(name="foo", description="baz")
+ self.assertEqual(obj.name, "foo")
+ self.assertEqual(obj.description, "baz")
+
+ # empty
+ self.assertRaises(TypeError, _Base)
+ # no name
+ self.assertRaises(TypeError, _Base, description="baz")
+ # no description
+ self.assertRaises(TypeError, _Base, name="foo")
+
+ def test_json_roundtrip(self):
+ obj = self.obj
+ json = obj.to_json()
+ # is it a string?
+ self.assertIsInstance(json, str)
+ # does it look right?
+ self.assertEqual(json, '{"name": "foo", "description": "baz"}')
+
+ # modify the raw json string
+ json = json.replace("foo", "quux")
+ self.assertEqual(json, '{"name": "quux", "description": "baz"}')
+
+ # does it load?
+ obj2 = _Base.from_json(json)
+ self.assertEqual(obj2.name, "quux")
+ self.assertEqual(obj2.description, "baz")
+
+ def test_asdict_roundtrip(self):
+ from dataclasses import asdict
+
+ obj = self.obj
+ d = asdict(obj)
+
+ self.assertIsInstance(d, dict)
+ self.assertEqual(d["name"], "foo")
+ self.assertEqual(d["description"], "baz")
+
+ # modify the dict
+ d["name"] = "quux"
+
+ # does it load?
+ obj2 = _Base(**d)
+ self.assertEqual(obj2.name, "quux")
+ 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")
+
+ def test_versioned_create(self):
+ obj = _Versioned()
+ self.assertEqual(obj.version, "0.0.0")
+
+ obj = _Versioned(version="1.2.3")
+ self.assertEqual(obj.version, "1.2.3")
+
+ def test_keyed_create(self):
+ obj = _Keyed(key="foo")
+ self.assertEqual(obj.key, "foo")
+
+ self.assertRaises(TypeError, _Keyed)
+
+ def test_mixin_combos(self):
+ # We need to test all the combinations
+ mixins = [
+ {"class": _Keyed, "args": {"key": "fizz"}, "has_default": False},
+ {"class": _Namespaced, "args": {"namespace": "buzz"}, "has_default": True},
+ {"class": _Versioned, "args": {"version": "1.2.3"}, "has_default": True},
+ ]
+ 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
+ ]
+
+ import itertools
+
+ max_len = len(mixins)
+ for i in range(max_len):
+ for combo in itertools.combinations(mixins, i + 1):
+ classes = [x["class"] for x in combo]
+ args = {k: v for x in combo for k, v in x["args"].items()}
+
+ # create an object with the mixins
+ @dataclass_json
+ @dataclass(kw_only=True)
+ class Foo(_Base, *classes):
+ pass
+
+ # make sure it breaks if we leave out a required arg
+ for k in args.keys():
+ args_copy = args.copy()
+ del args_copy[k]
+
+ if k in keys_with_defaults:
+ # expect success
+ obj = Foo(name="foo", description="baz", **args_copy)
+ # make sure the key is defaulted
+ self.assertEqual(getattr(Foo, k), getattr(obj, k))
+ else:
+ self.assertRaises(
+ TypeError, Foo, name="foo", description="baz", **args_copy
+ )
+
+ # instantiate the object
+ obj = Foo(name="foo", description="baz", **args)
+ self.assertEqual(obj.name, "foo")
+ self.assertEqual(obj.description, "baz")
+ # make sure the args are set
+ for k, v in args.items():
+ self.assertEqual(getattr(obj, k), v)
+
+ # test json roundtrip
+ json = obj.to_json()
+ # is it a string?
+ self.assertIsInstance(json, str)
+ # does it look right?
+ self.assertIn('"name": "foo"', json)
+ self.assertIn('"description": "baz"', json)
+ for k, v in args.items():
+ self.assertIn(f'"{k}": "{v}"', json)
+ # change the name and description
+ json = json.replace("foo", "quux")
+ json = json.replace("baz", "fizz")
+ # does it load?
+ obj2 = Foo.from_json(json)
+ self.assertEqual(obj2.name, "quux")
+ self.assertEqual(obj2.description, "fizz")
+ # make sure the args are set
+ for k, v in args.items():
+ self.assertEqual(getattr(obj2, k), v)
+ # make sure unchanged attributes match from the original object
+ for k in args.keys():
+ self.assertEqual(getattr(obj2, k), getattr(obj, k))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/src/test/test_schema.py b/src/test/test_schema.py
new file mode 100644
index 00000000..90b68006
--- /dev/null
+++ b/src/test/test_schema.py
@@ -0,0 +1,76 @@
+import json
+import logging
+import unittest
+
+import jsonschema
+
+import ssvc.decision_points # noqa F401
+from ssvc.decision_points.base import REGISTERED_DECISION_POINTS
+
+# importing these causes the decision points to register themselves
+from ssvc.dp_groups.v1 import SSVCv1 # noqa
+from ssvc.dp_groups.v2 import SSVCv2 # noqa
+from ssvc.dp_groups.v2_1 import SSVCv2_1 # noqa
+
+
+def find_schema(basepath: str) -> str:
+ import os
+
+ for pfx in (".", "..", "../.."):
+ path = os.path.join(pfx, basepath)
+ if os.path.exists(path):
+ return path
+ raise FileNotFoundError(f"Could not find {basepath}")
+
+
+class MyTestCase(unittest.TestCase):
+ def setUp(self) -> None:
+ logger = logging.getLogger()
+ logger.setLevel(logging.DEBUG)
+ hdlr = logging.StreamHandler()
+ logger.addHandler(hdlr)
+ self.logger = logger
+
+ def test_decision_point_validation(self):
+ # path relative to top level of repo
+ schema_file = find_schema("data/schema/Decision_Point.schema.json")
+ schema = json.load(open(schema_file))
+
+ decision_points = list(REGISTERED_DECISION_POINTS)
+ self.assertGreater(len(decision_points), 0)
+
+ for dp in decision_points:
+ exp = None
+ as_json = dp.to_json()
+ loaded = json.loads(as_json)
+
+ try:
+ jsonschema.validate(loaded, schema)
+ except jsonschema.exceptions.ValidationError as e:
+ exp = e
+
+ self.assertIsNone(exp, f"Validation failed for {dp.name} {dp.version}")
+ self.logger.debug(
+ f"Validation passed for ({dp.namespace}) {dp.name} v{dp.version}"
+ )
+
+ def test_decision_point_group_validation(self):
+ schema_file = find_schema("data/schema/Decision_Point_Group.schema.json")
+ schema = json.load(open(schema_file))
+
+ for dpg in (SSVCv1, SSVCv2, SSVCv2_1):
+ exp = None
+ as_json = dpg.to_json()
+ loaded = json.loads(as_json)
+
+ try:
+ jsonschema.validate(loaded, schema)
+ except jsonschema.exceptions.ValidationError as e:
+ exp = e
+
+ self.assertIsNone(exp, f"Validation failed for {dpg.name} {dpg.version}")
+ self.logger.debug(f"Validation passed for {dpg.name} v{dpg.version}")
+
+
+if __name__ == "__main__":
+ unittest.main()