diff --git a/data/schema/current/Decision_Point_Value_Selection.schema.json b/data/schema/current/Decision_Point_Value_Selection.schema.json index b708b5d7..4aed9da4 120000 --- a/data/schema/current/Decision_Point_Value_Selection.schema.json +++ b/data/schema/current/Decision_Point_Value_Selection.schema.json @@ -1 +1 @@ -../v1/Decision_Point_Value_Selection-1-0-1.schema.json \ No newline at end of file +../v2/Decision_Point_Value_Selection-2-0-0.schema.json \ No newline at end of file diff --git a/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json new file mode 100644 index 00000000..d2a96b4b --- /dev/null +++ b/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json @@ -0,0 +1,236 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://certcc.github.io/SSVC/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json", + "title": "Decision Point Value Selection List", + "description": "This schema defines the structure for representing selected values from SSVC Decision Points. Each selection list can have multiple selection objects, each representing a decision point, and each selection object can have multiple selected values when full certainty (i.e., a singular value selection) is not available.", + "type": "object", + "properties": { + "timestamp": { + "description": "Timestamp of the selections, in RFC 3339 format.", + "examples": [ + "2025-01-01T12:00:00Z", + "2025-01-02T15:30:45-04:00" + ], + "format": "date-time", + "title": "Timestamp", + "type": "string" + }, + "schemaVersion": { + "const": "2.0.0", + "description": "The schema version of this selection list.", + "title": "Schemaversion", + "type": "string" + }, + "target_ids": { + "description": "Optional list of identifiers for the item or items (vulnerabilities, reports, advisories, systems, assets, etc.) being evaluated by these selections.", + "examples": [ + [ + "CVE-1900-0000" + ], + [ + "VU#999999", + "GHSA-0123-4567-89ab" + ] + ], + "items": { + "type": "string" + }, + "minItems": 1, + "title": "Target Ids", + "type": "array" + }, + "selections": { + "description": "List of selections made from decision points. Each selection item corresponds to value keys contained in a specific decision point identified by its namespace, key, and version. Note that selection objects are deliberately minimal objects and do not contain the full decision point details.", + "items": { + "$ref": "#/$defs/Selection" + }, + "minItems": 1, + "title": "Selections", + "type": "array" + }, + "resources": { + "description": "A list of references to resources that provide additional context about the decision points found in this selection.", + "examples": [ + [ + { + "description": "Documentation for a set of decision points", + "uri": "https://example.com/decision_points" + }, + { + "description": "JSON representation of decision point 2", + "uri": "https://example.org/definitions/dp2.json" + }, + { + "description": "A JSON file containing extension decision points in the x_com.example namespace", + "uri": "https://example.com/ssvc/x_com.example/decision_points.json" + } + ] + ], + "items": { + "$ref": "#/$defs/Reference" + }, + "minItems": 1, + "title": "Resources", + "type": "array" + }, + "references": { + "description": "A list of references to resources that provide additional context about the specific values selected.", + "examples": [ + [ + { + "description": "A report on which the selections were based", + "uri": "https://example.com/report" + } + ], + [ + { + "description": "A code section on which the selections were based", + "uri": "https://git.example.com/some-relevant-path/code#L21-42" + }, + { + "description": "A code section on which calls the vulnerable function", + "uri": "https://git.example.com/some-relevant-path/callingcode#L91-16" + } + ] + ], + "items": { + "$ref": "#/$defs/Reference" + }, + "minItems": 1, + "title": "References", + "type": "array" + } + }, + "required": [ + "timestamp", + "schemaVersion", + "selections" + ], + "additionalProperties": false, + "$defs": { + "MinimalDecisionPointValue": { + "description": "A minimal representation of a decision point value.\nIntended to parallel the DecisionPointValue object, but with fewer required fields.\nA decision point value is uniquely identified within a decision point by its key.\nGlobally, the combination of Decision Point namespace, key, and version coupled with the value key\nuniquely identifies a value across all decision points and values.\nOther required fields in the DecisionPointValue object, such as name and description, are optional here.", + "properties": { + "key": { + "title": "Key", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "title": "Description", + "type": "string" + } + }, + "required": [ + "key" + ], + "title": "MinimalDecisionPointValue", + "type": "object" + }, + "Reference": { + "additionalProperties": false, + "description": "A reference to a resource that provides additional context about the decision points or selections.\nThis object is intentionally minimal and contains only the URL and an optional description.", + "properties": { + "uri": { + "format": "uri", + "minLength": 1, + "title": "Uri", + "type": "string" + }, + "description": { + "title": "Description", + "type": "string" + } + }, + "required": [ + "uri" + ], + "title": "Reference", + "type": "object" + }, + "Selection": { + "additionalProperties": false, + "description": "A minimal selection object that contains the decision point ID and the selected values.\nWhile the Selection object parallels the DecisionPoint object, it is intentionally minimal, with\nfewer required fields and no additional metadata, as it is meant to represent a selection made from a\npreviously defined decision point. The expectation is that a Selection object will usually have\nfewer values than the original decision point, as it represents a specific evaluation\nat a specific time and may therefore rule out some values that were previously considered.\nOther fields like name and description may be copied from the decision point, but are not required.", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "title": "Description", + "type": "string" + }, + "namespace": { + "description": "The namespace of the SSVC object.", + "examples": [ + "ssvc", + "cisa", + "x_com.example//com.example#private", + "ssvc/de-DE/example.organization#reference-arch-1" + ], + "maxLength": 1000, + "minLength": 3, + "pattern": "^(?=.{3,1000}$)(?:x_(?!.*[.-]{2,})[a-z][a-z0-9]+(?:[.-][a-z0-9]+)*|(?!.*[.-]{2,})[a-z][a-z0-9]+(?:[.-][a-z0-9]+)*)(?:(?:/(([A-Za-z]{2,3}(-[A-Za-z]{3}(-[A-Za-z]{3}){0,2})?|[A-Za-z]{4,8})(-[A-Za-z]{4})?(-([A-Za-z]{2}|[0-9]{3}))?(-([A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3}))*(-[A-WY-Za-wy-z0-9](-[A-Za-z0-9]{2,8})+)*(-[Xx](-[A-Za-z0-9]{1,8})+)?|[Xx](-[A-Za-z0-9]{1,8})+|[Ii]-[Dd][Ee][Ff][Aa][Uu][Ll][Tt]|[Ii]-[Mm][Ii][Nn][Gg][Oo])/|//)(?!.*[.-]{2,})[a-zA-Z][a-zA-Z0-9]*(?:[.-][a-zA-Z0-9]+)*(?:#[a-zA-Z0-9]+(?:[.-][a-zA-Z0-9]+)*)?(?:/(?!.*[.-]{2,})[a-zA-Z][a-zA-Z0-9]*(?:[.-][a-zA-Z0-9]+)*(?:#[a-zA-Z0-9]+(?:[.-][a-zA-Z0-9]+)*)?)*)?$", + "title": "Namespace", + "type": "string" + }, + "key": { + "title": "Key", + "type": "string" + }, + "version": { + "description": "The version of the SSVC object. This must be a valid semantic version string.", + "examples": [ + "1.0.0", + "2.1.3" + ], + "minLength": 5, + "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$", + "title": "Version", + "type": "string" + }, + "values": { + "description": "A list of selected value keys from the decision point values.", + "examples": [ + [ + { + "key": "N" + }, + { + "key": "Y" + } + ], + [ + { + "key": "A" + }, + { + "key": "B" + }, + { + "key": "C" + } + ] + ], + "items": { + "$ref": "#/$defs/MinimalDecisionPointValue" + }, + "minItems": 1, + "title": "Values", + "type": "array" + } + }, + "required": [ + "namespace", + "key", + "version", + "values" + ], + "title": "Selection", + "type": "object" + } + } +} diff --git a/docs/adr/0012-ssvc-namespaces.md b/docs/adr/0012-ssvc-namespaces.md new file mode 100644 index 00000000..a9458ec1 --- /dev/null +++ b/docs/adr/0012-ssvc-namespaces.md @@ -0,0 +1,121 @@ +--- +status: "accepted" +date: 2025-07-22 +deciders: @ahouseholer @sei-vsarvepalli +consulted: @tschmidtb51 +--- +# Use of Namespaces in SSVC objects + +## Context and Problem Statement + +We need to include decision points and other objects that are not directly +defined by the SSVC project team. For example, CVSS vector elements are a +rich source of structured data that can be used to inform SSVC decisions and +modeled as SSVC decision point objects. However, the +[FIRST CVSS SIG](https://www.first.org/cvss) owns the definition of CVSS vector +elements. So we need a way to describe these objects in SSVC format +without making them part of the SSVC specification. + + +## Decision Drivers + +- Need to include decision points based on data, objects, standards, and other + definitions that are not part of the SSVC specification. +- Need to clearly distinguish between objects managed by the SSVC project and + objects provided for convenience by the SSVC project, but whose semantics are + defined by other projects or standards. + +## Considered Options + +- One big pile of objects (effectively no namespaces) +- Use namespaces to distinguish between SSVC project objects and other objects + +## Decision Outcome + +Chosen option: "Use namespaces", because + +- Clearly distinguishes between SSVC project objects and objects derived from other sources +- Allows for extension of SSVC objects with additional data from other sources +- Allows for extensions for langauages, translation, localization, etc. + +Specifically, we intend to use: + +**Registered namespaces** for objects that we create and maintain (even if they are +based on other sources). + +!!! example + + We use the `ssvc` namespace for all SSVC objects that are part of the + main project. We use the `cvss` namespace to contain CVSS vector elements. + +**Unregistered namespaces** for objects that we do not create or maintain, but +that others may want for their own use. Unregistered namespaces must start with +an `x_` prefix followed by a reverse domain name, such as `x_org.example`. +Unregistered namespaces are intended for experimental or private use. + +!!! example + + A government agency might create a set of decision points for internal use + using the `x_example.agency` namespace. This allows them to use SSVC objects + of their own design alongside existig SSVC objects without needing to + register their namespace with the SSVC project. + +**Namespace extensions** for objects that are derived from other objects in an +registered or unregistered namespace. Extensions are not intended to be used to +introduce new objects, but rather to refine existing objects with additional data +or semantics. +Namespace extensions can be used for refining the meaning of decision point +values for a specific constituency, or adding additional nuance to +interpretation of a decision point in a specific context. + +!!! example + + An ISAO (Information Sharing and Analyzing Organization) might want to refine the meaning of decision point values for their + constituency, and could use `ssvc//example.isao` as the namespace for their + collection of extensions. + +### Consequences + +#### Positive Consequences + +- SSVC users can customize SSVC objects with additional refinements using extensions +- SSVC users can create their own SSVC objects in an unregistered namespace for + their own use, and share them with others +- Facilitates language translation and localization of SSVC objects to specific + constituencies + + +#### Negative Consequences + +- Registered namespaces must be managed and maintained +- Potential for confusion if unregistered namespaces are used without care or + violating the naming conventions + + +### Confirmation + +- Regular expressions are used in the SSVC specification in both python objects + and JSON schema to validate the namespace format. +- Object validators can be used to ensure that namespaces are correctly formatted + and that registered namespaces are used for objects that are part of the SSVC + specification. + + +## Pros and Cons of the Options + +### One big pile of objects + +We started out with all objects having no namespaces, which meant that +all objects were effectively part of the SSVC specification. This was problematic +because it made it difficult to distinguish between objects that were part of the +SSVC specification under our control and objects that were derived from other sources. + +- Good, because it was simple and easy to understand +- Bad, because it made it difficult to distinguish between SSVC project objects and + objects based on specifications we neither created nor maintained + + + +## More Information + +- [SSVC Namespace Documentation](../reference/code/namespaces.md) diff --git a/docs/adr/index.md b/docs/adr/index.md index e99a6286..e613b442 100644 --- a/docs/adr/index.md +++ b/docs/adr/index.md @@ -23,6 +23,7 @@ the decision records that have been made. - [0009 - Outcomes are Ordered Sets](0009-outcomes-are-ordered-sets.md) - [0010 - Outcome Sets are separate from Decision Point Groups](0010-outcome-sets-are-separate-from-decision-point-groups.md) - [0011 - Correspondence between Automatable v2.0.0, Value Density v1.0.0, and CVSS v4](0011-automatable-and-value-density-and-CVSSv4.md) +- [0012 - SSVC Namespaces](0012-ssvc-namespaces.md) ## Rejected Records diff --git a/docs/reference/code/namespaces.md b/docs/reference/code/namespaces.md index bc7ed7b4..53a0775c 100644 --- a/docs/reference/code/namespaces.md +++ b/docs/reference/code/namespaces.md @@ -1,3 +1,421 @@ # SSVC Namespaces +We use namespaces in SSVC to organize the various components of the framework. +The bulk of our work is done in the `ssvc` namespace, which contains the core +decision points for SSVC. + +!!! question "Why does SSVC need namespaces?" + + We want to provide a clear way to differentiate between decision points we + developed as part of the SSVC project, and those that are derived from work + done by other projects. This helps us maintain clarity in our codebase and + to avoid confusion when integrating with other systems or libraries. + +## Namespace Structure + +Namespaces are structured as follows: + +```mermaid +--- +title: SSVC Namespace Structure +--- +flowchart LR + base_ns[Base Namespace] + exts[Extensions] + base_ns -->|/| exts +``` + +A namespace consists of a base namespace and optional extensions. + +### Base Namespace + +The base namespace can be either registered or unregistered. +The following diagram illustrates the structure of the base namespace: + +```mermaid +--- +title: Base Namespace Structure +--- +flowchart LR + +subgraph base_ns[Base Namespace] + direction LR + subgraph unregistered[Unregistered Namespace] + direction LR + xpfx[x_] + reverse_ns[Reverse Domain Name Notation] + xpfx --> reverse_ns + end + subgraph registered[Registered Namespace] + direction LR + base_registered[Registered Base Namespace] + end + registered ~~~|OR| unregistered +end +``` + + +!!! info inline end "Current Registered Namespaces" + + The SSVC project currently has a set of registered namespaces that are + intended to be used as part of the framework. These namespaces are defined + in the `ssvc.namespaces` module and can be accessed via the `NameSpace` enum. + Current registered namespaces are: + + ```python exec="true" idprefix="" + from ssvc.namespaces import NameSpace + + for ns in NameSpace: + print(f"- {ns.value}") + ``` + +#### Registered Namespace + +Registered namespaces are those that are explicitly defined in the SSVC project. +A list of the current registered namespaces can be found in the sidebar. + +Registered namespaces are intended to be used as follows: + +- Objects in the `ssvc` namespace are managed by the SSVC + project team. We have complete control over these ones. +- Objects in other explicitly registered namespaces are provided for convenience, + but the SSVC team is not responsible for modifying the content or semantics of + those decision points. + +!!! note "Registered Non-`ssvc` Namespaces" + + We use namespaces other than `ssvc` to indicate decision points that are based + externally defined standards, specifications, or relevant projects. + We expect for decision points in these namespaces to be technically compatible + with SSVC, but we do not claim any ownership or responsibility for the + underlying specifications or their semantic content. + Objects in these namespaces are provided for the convenience + of SSVC users to allow them to use these decision points in their SSVC + decision models without needing to implement them from scratch. + + While we are happy to resolve technical issues with these decision points as + technically implemented in the SSVC project, all suggestions for changes to the + underlying specifications or semantic content should be directed to the + maintainers of the respective projects or standards. + +!!! example "The `cvss` namespace" + + We wanted to allow SSVC users to include Common Vulnerability Scoring System + (CVSS) vector elements as [decision points](../decision_points/cvss/index.md) + in their SSVC decision models. + So we created the `cvss` namespace to contain + [decision points](../decision_points/cvss/index.md) that are + based on various versions of the CVSS. These + [decision points](../decision_points/cvss/index.md) are provided + as part of the SSVC project for convenience, but we do not maintain the + underlying CVSS specifications, their semantic content or their implementations. + Suggestions for changes to the CVSS specifications should be directed to the + [FIRST CVSS Special Interest Group](https://www.first.org/cvss/) (SIG). + + + +!!! example "Potential Standards-based namespaces" + + We may in the future add namespaces when needed to reflect different standards + bodies like `nist`, `iso-iec`, `ietf`, `oasis`, etc. + +!!! question "How do I request a new registered namespace?" + + If you have a suggestion for a new registered namespace, please open an + issue in the [SSVC GitHub repository](https://github.com/CERTCC/SSVC/issues) + and provide a brief description of the namespace and its intended use. + +#### Unregistered Namespace + +Unregistered namespaces are those that are not explicitly defined in the SSVC project. +Because unregistered namespaces are not managed by the SSVC project team, +there is no strict guarantee of uniqueness across different users or organizations. +However, because we require unregistered namespaces to use reverse domain name notation, +we expect that this will rarely lead to conflicts in practice. + +!!! info "Unregistered Namespace Requirements" + + Unregistered namespaces must follow the following structure: + + - Unregistered namespaces must use the `x_` prefix. + - Following the `x_` prefix, unregistered namespaces must use reverse domain name notation of a domain under their control to ensure uniqueness. + - Aside from the required `x_` prefix, unregistered namespaces must contain only alphanumeric characters, dots (`.`), and dashes (`-`). + - For any domain using other characters, DNS Punycode must be used + + +!!! warning "Namespace Conflicts" + + Conflicts are possible in the x_ prefix space - especially as the control over a domain may be transferred. + Also in tests, Organizations A and B could both choose to use + `x_example.test`, and there are no guarantees of global uniqueness for the + decision points in the `x_example.test` namespace. + + +!!! tip "Test Namespace" + + The `x_example.test` namespace is used for testing purposes and is not intended for production use. + It is used to test the SSVC framework and its components, and may contain decision points that are not fully implemented or tested. + +### Namespace Extensions + +Namespace extensions allow users to extend the SSVC namespaces to clarify existing decision points +from a base namespace. +Extensions are optional and may be used to refine or clarify existing decision points. +Extensions allow SSVC users to create decision points that are specific to their +constituencies or to provide translations of existing decision points. + +!!! info "Namespace Extension Requirements" + + Extensions must follow the following requirements: + + - Extensions must not alter the decision point key, version number, or value keys + for any decision point they are derived from. + - Extensions must not alter the meaning of existing values, or add values to + existing decision points in the parent namespace. + - Extensions may reduce the set of values for a decision point in the parent + namespace, but must not add new values. + +!!! question "What if I want to create a new decision point?" + + If you want to create a new decision point, please use a private/experimental namespace + as described above instead of an extension. + Extensions are not intended to be used to create new decision points. + +!!! question "Why is that important?" + + The way extensions are build enables tools to process the decision points even if + they do not know the defined extension. As long as the tool knows the base + namespace, it can process the decision point. + +#### Namespace Extension Structure + +The first extension segment is reserved for an optional BCP-47 language tag, which may be left empty. +When empty, the default language (`en-US`) is implied. + +Subsequent extension segments must begin with a reverse domain name notation string, +and may contain alphanumeric characters (upper or lower case), dots (`.`), and dashes (`-`). +A single fragment identifier (`#`) may be included in an extension segment, but it is optional. +Fragment segments can be used to indicate a specific interpretation or context for the extension. +Note: Without a fragment segment, all decision points of an organization fall into one bucket, which is in most cases not intended. Therefore, the use of a fragment segment is recommended. +The following diagram illustrates the structure of namespace extensions: +```mermaid +--- +title: Namespace Extensions +--- +flowchart LR + +subgraph exts[Extensions] + direction LR + subgraph first[1st Extension Segment] + direction TB + lang[Language Tag] + empty_lang[Empty String] + lang ~~~|OR| empty_lang + end + subgraph ext[Subsequent Extension Segments] + direction LR + reverse_ns_ext[Reverse Domain Name Notation] + fragment[#Optional Fragment ID] + reverse_ns_ext --> fragment + end + first -->|/| ext + ext -->|/| ext +end + +base_ns[Base Namespace] +base_ns -->|/| first + +``` + +!!! info "Namespace Extension Requirements" + + Extensions must follow the following structure: + + - Extension segments are separated by slashes (`/`). + - Multiple extension segments are allowed. + - If any extension segments are present, the first segment must be a valid BCP-47 language tag or an empty string. + - When the first segment is left as an empty string, the default language (`en-US`) is implied. + - Subsequent extension segments must begin with a reverse domain name notation string or be a valid, non-empty BCP-47 language tag. + - A fragment identifier (`#`) may be included in extension segments, but it is optional. + - Extension segments may contain alphanumeric characters (upper or lower case), dots (`.`), and dashes (`-`), and zero or one hash (`#`). + - Extensions must not alter the decision point key, version number, or value keys for any decision point they are derived from. + - Extensions may reduce the set of values for a decision point in the parent namespace, but must not add new values. + + The structure of the namespace string is intended to show inheritance for + variations on SSVC objects. + +!!! tip "Extension Order Matters" + + Extension order matters. `ssvc/de-DE/example.organization#ref-arch-1` + denotes that (a) a German (Germany) translation of the SSVC decision points + is available, and (b) that this translation has been extended with an extension + by `organization.example` to fit their specific needs for `ref-arch-1`. + + On the other hand, `ssvc//example.organization#ref-arch-1/de-DE` + denotes that (a) the `example.organization#ref-arch-1` extension is + available in the default language (`en-US`), and (b) that this extension has + been translated into German (Germany). + + +!!! example "Use of fragment identifiers and language tags" + + Imagine an Information Sharing and Analysis Organization (ISAO) `isao.example` + wants to create an extension to refine an existing decision point in the `ssvc` namespace + with additional context for a part of their constituency. They could create an extension + namespace like `ssvc//example.isao#constituency` to indicate that this extension + is specifically tailored for a particular constituency within the ISAO. + Note the empty first segment, which implies the default language (`en-US`). + + If they further chose to create a Polish language version of their extension, + they would add a language segment _following_ their extension namespace, + e.g., `ssvc//example.isao#constituency/pl-PL`. Note that this is different + from a hypothetical `ssvc/pl-PL/example.isao#constituency` extension, which would imply + that the `ssvc` namespace has been translated to Polish (Poland) and then extended + (in Polish) with the `example.isao#constituency` extension. + +!!! example "Refinement of Concepts for a Specific Constituency" + + A sector-specific information sharing and analysis organization (ISAO) + might create an extension for their specific constituency. + For example, say that a hypothetical registered namespace `foo` + has a decision point for `Regulated System=(Y,N)`. + A medical-focused ISAO might create an extension + `foo//example.med-isao` where they refine the values to refer to specific + regulations. If multiple regulatory regimes exist, they might even have + `foo//example.med-isao#regulation-1` and `foo//example.med-isao#regulation-2` + to cover assessment of the appropriate regulations. + +### Usage Suggestions + +Although we reserved the first segment of the extension for language tags, +there are scenarios where it may be appropriate to use a language tag in a later +segment of the extension. + +!!! tip "Use BCP-47 Language Tags" + + Regardless where they appear in the extension strings, BCP-47 language tags + must be used for any language-based extension. + Note, however that we do not yet strictly enforce this recommendation in the + SSVC codebase outside of the first extension segment. + +!!! example "Translation of a custom extension" + + If you have a custom extension that is not a translation of an existing + decision point, you might use a language tag in a later segment to indicate + a translation of the extension. + For example, `ssvc//com.example/extension/pl-PL` would indicate that the + an extension in the default `en-US` language has been translated to Polish (Poland). + +!!! tip "Use Reverse Domain Name Notation for Extensions" + + To avoid conflicts with other users' extensions, we require the use of reverse + domain name notation for your extensions. This helps to ensure that your + extensions are unique and easily identifiable. + For example, if your organization is `example.com`, you might use an extension + like `ssvc//com.example#extension`. + + +## Technical requirements + +The following technical requirements are enforced for SSVC namespaces, +based on the implementation in `src/ssvc/namespaces.py` and the NS_PATTERN regular expression: + +!!! info "Namespace Pattern" + + The regular expression used to validate namespaces is: + + ```python exec="true" idprefix="" + + from ssvc.utils.patterns import NS_PATTERN + + print(f"`{NS_PATTERN.pattern}`") + ``` + +### Length Requirements + +- Namespaces must be between 3 and 1000 characters long. + +### Base Namespace Requirements + +- Must start with a lowercase letter +- Must contain at least 3 total characters in the base part (after the optional experimental/private prefix) +- Must contain only lowercase letters, numbers, dots (`.`), and hyphens (`-`) +- Must not contain consecutive dots or hyphens (no `..`, `--`, `.-`, `-.`, `---`, etc.) +- May optionally start with the experimental/private prefix `{X_PFX}`. + +### Extension Requirements (Optional) + +- Extensions are optional +- Extensions must be delineated by slashes (`/`) +- If any extension segments are present, the following rules apply: + - The first extension segment must be a valid BCP-47 language tag or empty (i.e., `//`). + - Subsequent extension segments: + - must start with a letter (upper or lowercase) + - may contain letters, numbers, dots (`.`), hyphens (`-`), and at most one hash (`#`) + - must not contain consecutive dots or hyphens (no `..`, `--`, `.-`, `-.`, `---`, etc.) + - if a hash is present, it separates the main part from an optional fragment part + - are separated by single forward slashes (`/`) +- Multiple extension segments are allowed + +!!! info "ABNF Notation" + + ```abnf + namespace = base-ns [extensions] + ; Overall namespace must be 3–1000 characters + ; (Enforced via regex length lookahead) + + base-ns = x-base / std-base + x-base = "x_" ns-core + std-base = ns-core + + ; ns-core starts with a lowercase letter and may have '.' or '-' separators. + ; Consecutive '.' or '-' are not allowed. + ns-core = LOWER ALNUMLOW *("." / "-" 1*ALNUMLOW) + + extensions = lang-ext [ *("/" ext-seg) ] + + ; Language extension: either // (empty language extension) + ; or // (BCP-47 language code) + lang-ext = "//" / ( "/" bcp47 "/" ) + + ; Extension segment between slashes. + ; - Must start with ALPHA + ; - May have '.' or '-' separators + ; - Optional '#' section, at most one per segment + ; - No consecutive '.' or '-' + ext-seg = ALPHA ALNUM * + ( ("." / "-") 1*ALNUM ) * + [ "#" 1*ALNUM * ( ("." / "-") 1*ALNUM ) ] + + ; BCP-47 tag (based on the regex expansion) + bcp47 = ( 2*3ALPHA + [ "-" 3ALPHA *2( "-" 3ALPHA ) ] + / 4*8ALPHA ) + [ "-" 4ALPHA ] + [ "-" ( 2ALPHA / 3DIGIT ) ] + * ( "-" ( 5*8ALNUM / DIGIT 3ALNUM ) ) + * ( "-" %x41-57.59-5A.61-7A.7C-7E "-" 2*8ALNUM ) + [ "-" %x58.78 1*( "-" 1*8ALNUM ) ] + / %x58.78 1*( "-" 1*8ALNUM ) + / %x49.69 "-" %x44.64 %x45.65 %x46.66 %x41.61 %x55.75 %x4C.6C %x54.74 + / %x49.69 "-" %x4D.6D %x49.69 %x4E.6E %x47.67 %x4F.6F + + ; Character sets + LOWER = %x61-7A ; a-z + ALPHA = %x41-5A / %x61-7A ; A-Z / a-z + DIGIT = %x30-39 ; 0-9 + ALNUM = ALPHA / DIGIT + ALNUMLOW = LOWER / DIGIT + + ; Constraints: + ; - No consecutive "." or "-" in ns-core or ext-seg. + ; - Each ext-seg can contain at most one "#". + ; - Overall namespace is 3–1000 chars. + ``` + +## The `ssvc.namespaces` module + +The `ssvc.namespaces` module provides a way to access and use these namespaces. + ::: ssvc.namespaces + diff --git a/docs/reference/code/selection.md b/docs/reference/code/selection.md new file mode 100644 index 00000000..f1a2c466 --- /dev/null +++ b/docs/reference/code/selection.md @@ -0,0 +1,3 @@ +# Selections + +::: ssvc.selection diff --git a/mkdocs.yml b/mkdocs.yml index 80638cda..95671f67 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -106,10 +106,11 @@ nav: - Target Distribution: 'reference/decision_points/cvss/target_distribution.md' - Code: - Intro: 'reference/code/index.md' + - Namespaces: 'reference/code/namespaces.md' + - Selections: 'reference/code/selection.md' - CSV Analyzer: 'reference/code/analyze_csv.md' - Policy Generator: 'reference/code/policy_generator.md' - Outcomes: 'reference/code/outcomes.md' - - Namespaces: 'reference/code/namespaces.md' - Doctools: 'reference/code/doctools.md' - Learning SSVC: - Tutorials: 'tutorials/index.md' diff --git a/src/ssvc/_mixins.py b/src/ssvc/_mixins.py index 67576910..76583603 100644 --- a/src/ssvc/_mixins.py +++ b/src/ssvc/_mixins.py @@ -2,6 +2,7 @@ """ This module provides mixin classes for adding features to SSVC objects. """ + # Copyright (c) 2023-2025 Carnegie Mellon University. # NO WARRANTY. THIS CARNEGIE MELLON UNIVERSITY AND SOFTWARE # ENGINEERING INSTITUTE MATERIAL IS FURNISHED ON AN "AS-IS" BASIS. @@ -21,13 +22,16 @@ # subject to its own license. # DM24-0278 +from datetime import datetime, timezone from typing import Optional -from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from semver import Version from ssvc import _schemaVersion -from ssvc.namespaces import NS_PATTERN, NameSpace +from ssvc.namespaces import NameSpace +from ssvc.utils.defaults import DEFAULT_VERSION +from ssvc.utils.field_specs import NamespaceString, VersionString class _Versioned(BaseModel): @@ -35,7 +39,7 @@ class _Versioned(BaseModel): Mixin class for versioned SSVC objects. """ - version: str = "0.0.0" + version: VersionString = Field(default=DEFAULT_VERSION) @field_validator("version") @classmethod @@ -70,7 +74,7 @@ class _Namespaced(BaseModel): # the field definition enforces the pattern for namespaces # additional validation is performed in the field_validator immediately after the pattern check - namespace: str = Field(pattern=NS_PATTERN, min_length=3, max_length=100) + namespace: NamespaceString @field_validator("namespace", mode="before") @classmethod @@ -135,6 +139,28 @@ class _Commented(BaseModel): model_config = ConfigDict(json_encoders={Optional[str]: exclude_if_none}) +class _Timestamped(BaseModel): + """ + Mixin class for timestamped SSVC objects. + """ + + timestamp: datetime = Field( + ..., + description="Timestamp of the SSVC object, in RFC 3339 format.", + examples=["2025-01-01T12:00:00Z", "2025-01-02T15:30:45-04:00"], + ) + + # set the default value to the current time + @model_validator(mode="before") + def set_timestamp(cls, data): + """ + Set the timestamp to the current time if not provided. + """ + if "timestamp" not in data: + data["timestamp"] = datetime.now().astimezone(timezone.utc) + return data + + class _Base(BaseModel): """ Base class for SSVC objects. diff --git a/src/ssvc/decision_points/base.py b/src/ssvc/decision_points/base.py index 69e42d96..30eb2261 100644 --- a/src/ssvc/decision_points/base.py +++ b/src/ssvc/decision_points/base.py @@ -35,12 +35,12 @@ _Valued, _Versioned, ) +from ssvc.utils.defaults import FIELD_DELIMITER logger = logging.getLogger(__name__) REGISTERED_DECISION_POINTS = [] -FIELD_DELIMITER = ":" class Registry(BaseModel): @@ -187,6 +187,14 @@ class DecisionPoint( def __str__(self): return FIELD_DELIMITER.join([self.namespace, self.key, self.version]) + @property + def id(self): + """ + Return an identity string for the DecisionPoint. + """ + + return FIELD_DELIMITER.join([self.namespace, self.key, self.version]) + @property def str(self) -> str: """ diff --git a/src/ssvc/doc_helpers.py b/src/ssvc/doc_helpers.py index 9ab8a8dc..a78a6fee 100644 --- a/src/ssvc/doc_helpers.py +++ b/src/ssvc/doc_helpers.py @@ -25,7 +25,7 @@ from ssvc.decision_points.ssvc.base import SsvcDecisionPoint -MD_TABLE_ROW_TEMPLATE = "| {value.name} | {value.description} |" +MD_TABLE_ROW_TEMPLATE = "| {value.name} ({value.key}) | {value.description} |" def markdown_table(dp: SsvcDecisionPoint, indent: int = 0) -> str: @@ -56,9 +56,11 @@ def markdown_table(dp: SsvcDecisionPoint, indent: int = 0) -> str: def example_block_tabbed(dp: SsvcDecisionPoint, indent=4) -> str: """Given a decision point, return a markdown block that contains an example of the decision point.""" + dp_title_str = f"{dp.name} ({dp.id})" + indent_ = " " * 4 rows = [] - rows.append(f'!!! note "{dp.name} v{dp.version}"') + rows.append(f'!!! note "{dp_title_str}"') rows.append("") rows.append(indent_ + '=== "Table"') @@ -80,9 +82,11 @@ def example_block( ) -> str: """Given a decision point, return a markdown block that contains an example of the decision point.""" + dp_title_str = f"{dp.name} ({dp.id})" + indent_ = " " * indent rows = [] - rows.append(f'!!! note "{dp.name} v{dp.version}"') + rows.append(f'!!! note "{dp_title_str}"') rows.append("") for row in markdown_table(dp).splitlines(): @@ -90,7 +94,7 @@ def example_block( rows.append("") if include_json: - rows.append(indent_ + f'??? example "{dp.name} v{dp.version} JSON Example"') + rows.append(indent_ + f'??? example "{dp_title_str} JSON Example"') rows.append("") for row in json_example(dp, indent=4).splitlines(): rows.append(indent_ + row) diff --git a/src/ssvc/doctools.py b/src/ssvc/doctools.py index b345ff26..3dfca898 100755 --- a/src/ssvc/doctools.py +++ b/src/ssvc/doctools.py @@ -35,6 +35,7 @@ """ import importlib +import json import logging import os import re @@ -44,6 +45,7 @@ REGISTERED_DECISION_POINTS, ) from ssvc.decision_points.ssvc.base import SsvcDecisionPoint +from ssvc.selection import SelectionList logger = logging.getLogger(__name__) @@ -185,6 +187,7 @@ def dump_json(basename: str, dp: DecisionPoint, jsondir: str, overwrite: bool) - remove_if_exists(json_file) with EnsureDirExists(dirname): try: + logger.info(f"Writing {json_file}") with open(json_file, "x") as f: f.write(dp.model_dump_json(indent=2)) f.write("\n") # newline at end of file @@ -195,6 +198,23 @@ def dump_json(basename: str, dp: DecisionPoint, jsondir: str, overwrite: bool) - return str(json_file) +def dump_selection_schema(filepath: str) -> None: + """ + Dump the schema for the SelectionList model to a file. + Args: + filepath: The path to the file to write the schema to. + + Returns: + None + + """ + logger.info(f"Dumping schema to {filepath}") + schema = SelectionList.model_json_schema() + with open(filepath, "w") as f: + json.dump(schema, f, indent=2) + f.write("\n") # newline at end of file + + def main(): # we are going to generate three files for each decision point: # - a markdown table that can be used in the decision point documentation @@ -223,6 +243,8 @@ def main(): overwrite = args.overwrite jsondir = args.jsondir + dp_dir = os.path.join(os.path.abspath(jsondir), "decision_points") + find_modules_to_import("./src/ssvc/decision_points", "ssvc.decision_points") find_modules_to_import("./src/ssvc/outcomes", "ssvc.outcomes") @@ -232,7 +254,14 @@ def main(): # for each decision point: for dp in REGISTERED_DECISION_POINTS: - dump_decision_point(jsondir, dp, overwrite) + dump_decision_point(dp_dir, dp, overwrite) + + # dump the selection schema + schemadir = os.path.abspath(os.path.join(jsondir, "..", "schema", "v2")) + schemafile = os.path.join( + schemadir, "Decision_Point_Value_Selection-2-0-0.schema.json" + ) + dump_selection_schema(schemafile) if __name__ == "__main__": diff --git a/src/ssvc/dp_groups/cvss/collections.py b/src/ssvc/dp_groups/cvss/collections.py index 4beca498..c2b61c2c 100644 --- a/src/ssvc/dp_groups/cvss/collections.py +++ b/src/ssvc/dp_groups/cvss/collections.py @@ -153,21 +153,21 @@ CVSSv1_B = DecisionPointGroup( name="CVSS", - version="1.0", + version="1.0.0", description="CVSS v1 decision points", decision_points=tuple(_BASE_1), ) CVSSv1_BT = DecisionPointGroup( name="CVSS", - version="1.0", + version="1.0.0", description="CVSS v1 decision points", decision_points=tuple(_BASE_1 + _TEMPORAL_1), ) CVSSv1_BTE = DecisionPointGroup( name="CVSS", - version="1.0", + version="1.0.0", description="CVSS v1 decision points", decision_points=tuple(_BASE_1 + _TEMPORAL_1 + _ENVIRONMENTAL_1), ) @@ -200,21 +200,21 @@ CVSSv2_B = DecisionPointGroup( name="CVSS Version 2 Base Metrics", description="Base metrics for CVSS v2", - version="2.0", + version="2.0.0", decision_points=tuple(_BASE_2), ) CVSSv2_BT = DecisionPointGroup( name="CVSS Version 2 Base and Temporal Metrics", description="Base and Temporal metrics for CVSS v2", - version="2.0", + version="2.0.0", decision_points=tuple(_BASE_2 + _TEMPORAL_2), ) CVSSv2_BTE = DecisionPointGroup( name="CVSS Version 2 Base, Temporal, and Environmental Metrics", description="Base, Temporal, and Environmental metrics for CVSS v2", - version="2.0", + version="2.0.0", decision_points=tuple(_BASE_2 + _TEMPORAL_2 + _ENVIRONMENTAL_2), ) @@ -249,21 +249,21 @@ CVSSv3_B = DecisionPointGroup( name="CVSS Version 3 Base Metrics", description="Base metrics for CVSS v3", - version="3.0", + version="3.0.0", decision_points=tuple(_BASE_3), ) CVSSv3_BT = DecisionPointGroup( name="CVSS Version 3 Base and Temporal Metrics", description="Base and Temporal metrics for CVSS v3", - version="3.0", + version="3.0.0", decision_points=tuple(_BASE_3 + _TEMPORAL_3), ) CVSSv3_BTE = DecisionPointGroup( name="CVSS Version 3 Base, Temporal, and Environmental Metrics", description="Base, Temporal, and Environmental metrics for CVSS v3", - version="3.0", + version="3.0.0", decision_points=tuple(_BASE_3 + _TEMPORAL_3 + _ENVIRONMENTAL_3), ) @@ -313,7 +313,7 @@ CVSSv4_B = DecisionPointGroup( name="CVSSv4 Base Metrics", description="Base metrics for CVSS v4", - version="1.0.0", + version="4.0.0", decision_points=tuple(_BASE_4), ) @@ -321,7 +321,7 @@ CVSSv4_BE = DecisionPointGroup( name="CVSSv4 Base and Environmental Metrics", description="Base and Environmental metrics for CVSS v4", - version="1.0.0", + version="4.0.0", decision_points=tuple(_BASE_4 + _ENVIRONMENTAL_4), ) @@ -329,7 +329,7 @@ CVSSv4_BT = DecisionPointGroup( name="CVSSv4 Base and Threat Metrics", description="Base and Threat metrics for CVSS v4", - version="1.0.0", + version="4.0.0", decision_points=tuple(_BASE_4 + _THREAT_4), ) @@ -337,21 +337,21 @@ CVSSv4_BTE = DecisionPointGroup( name="CVSSv4 Base, Threat, and Environmental Metrics", description="Base, Threat, and Environmental metrics for CVSS v4", - version="1.0.0", + version="4.0.0", decision_points=tuple(_BASE_4 + _THREAT_4 + _ENVIRONMENTAL_4), ) CVSSv4 = DecisionPointGroup( name="CVSSv4", description="All decision points for CVSS v4 (including supplemental metrics)", - version="1.0.0", + version="4.0.0", decision_points=tuple(_BASE_4 + _THREAT_4 + _ENVIRONMENTAL_4 + _SUPPLEMENTAL_4), ) CVSSv4_Equivalence_Sets = DecisionPointGroup( name="CVSSv4 EQ Sets", description="Equivalence Sets for CVSS v4", - version="1.0.0", + version="4.0.0", decision_points=( EQ1, EQ2, diff --git a/src/ssvc/namespaces.py b/src/ssvc/namespaces.py index 56cb3ee4..58dfec2f 100644 --- a/src/ssvc/namespaces.py +++ b/src/ssvc/namespaces.py @@ -23,44 +23,20 @@ # subject to its own license. # DM24-0278 -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,100}$)`: 3-25 characters long -# `^(x_)`: `x_` prefix is optional -# `[a-z0-9]{3,4}`: must start with 3-4 alphanumeric characters -# `[/.-]?`: only one punctuation character is allowed between alphanumeric characters -# `[a-z0-9]+`: at least one alphanumeric character is required after the punctuation character -# `([/.-]?[a-z0-9]+){0,22}`: zero to 22 occurrences of the punctuation character followed by at least one alphanumeric character -# (note that the total limit will kick in at or before this point) -# `$`: end of the string -NS_PATTERN = re.compile(r"^(?=.{3,100}$)(x_)?[a-z0-9]{3}([/.-]?[a-z0-9]+){0,97}$") -"""The regular expression pattern for validating namespaces. - -Note: - 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. -""" +from ssvc.utils.defaults import MAX_NS_LENGTH, MIN_NS_LENGTH, X_PFX +from ssvc.utils.patterns import NS_PATTERN class NameSpace(StrEnum): - """ + f""" Defines the official namespaces for SSVC. The namespace value must be one of the members of this enum or start with the prefix specified in X_PFX. - Namespaces must be 3-25 lowercase characters long and must start with 3-4 alphanumeric characters after the optional prefix. - Limited punctuation characters (/.-) are allowed between alphanumeric characters, but only one at a time. + Namespaces must be {MIN_NS_LENGTH}-{MAX_NS_LENGTH} lowercase characters long and must start with 3-4 + alphanumeric characters after the optional prefix. + Limited punctuation characters (#/.-) are allowed between alphanumeric characters, but only one at a time. Example: Following are examples of valid and invalid namespace values: @@ -97,12 +73,26 @@ def validate(cls, value: str) -> str: 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 + valid = NS_PATTERN.match(value) + + if valid: + # pattern matches, so we can proceed with further checks + # partition always returns three parts: the part before the separator, the separator itself, and the part after the separator + (base_ns, _, extension) = value.partition("/") + # and we don't care about the extension beyond the pattern match above + # so base_ns is either the full value or the part before the first slash + + if base_ns in cls.__members__.values(): + # base_ns is a registered namespaces + return value + + elif base_ns.startswith(X_PFX): + # base_ns might start with x_ + return value + + # if you got here, the value is not a valid namespace raise ValueError( - f"Invalid namespace: {value}. Must be one of {[ns.value for ns in cls]} or start with '{X_PFX}'." + f"Invalid namespace: '{value}' Must be one of {[ns.value for ns in cls]} or start with '{X_PFX}'." ) diff --git a/src/ssvc/selection.py b/src/ssvc/selection.py new file mode 100644 index 00000000..de8b473f --- /dev/null +++ b/src/ssvc/selection.py @@ -0,0 +1,421 @@ +#!/usr/bin/env python +""" +Provides an SSVC selection object and functions to facilitate transition from an SSVC decision point to a selection. +""" +# Copyright (c) 2025 Carnegie Mellon University. +# NO WARRANTY. THIS CARNEGIE MELLON UNIVERSITY AND SOFTWARE +# ENGINEERING INSTITUTE MATERIAL IS FURNISHED ON AN "AS-IS" BASIS. +# CARNEGIE MELLON UNIVERSITY MAKES NO WARRANTIES OF ANY KIND, +# EITHER EXPRESSED OR IMPLIED, AS TO ANY MATTER INCLUDING, BUT +# NOT LIMITED TO, WARRANTY OF FITNESS FOR PURPOSE OR +# MERCHANTABILITY, EXCLUSIVITY, OR RESULTS OBTAINED FROM USE +# OF THE MATERIAL. CARNEGIE MELLON UNIVERSITY DOES NOT MAKE +# ANY WARRANTY OF ANY KIND WITH RESPECT TO FREEDOM FROM +# PATENT, TRADEMARK, OR COPYRIGHT INFRINGEMENT. +# Licensed under a MIT (SEI)-style license, please see LICENSE or contact +# permission@sei.cmu.edu for full terms. +# [DISTRIBUTION STATEMENT A] This material has been approved for +# public release and unlimited distribution. Please see Copyright notice +# for non-US Government use and distribution. +# This Software includes and/or makes use of Third-Party Software each +# subject to its own license. +# DM24-0278 + +from datetime import datetime +from typing import Literal, Optional + +from pydantic import ( + AnyUrl, + BaseModel, + ConfigDict, + Field, + field_validator, + model_validator, +) + +from ssvc._mixins import ( + _Base, + _Keyed, + _Namespaced, + _Timestamped, + _Valued, + _Versioned, +) +from ssvc.decision_points.base import DecisionPoint +from ssvc.utils.field_specs import TargetIdList, VersionString + +SCHEMA_VERSION = "2.0.0" + + +class MinimalDecisionPointValue(_Base, _Keyed, BaseModel): + """ + A minimal representation of a decision point value. + Intended to parallel the DecisionPointValue object, but with fewer required fields. + A decision point value is uniquely identified within a decision point by its key. + Globally, the combination of Decision Point namespace, key, and version coupled with the value key + uniquely identifies a value across all decision points and values. + Other required fields in the DecisionPointValue object, such as name and description, are optional here. + """ + + model_config = ConfigDict(extra="forbid") + + @model_validator(mode="before") + def set_optional_fields(cls, data): + if "name" not in data: + data["name"] = "" + if "description" not in data: + data["description"] = "" + + return data + + @model_validator(mode="after") + def validate_values(cls, data): + """ + If name or description are empty strings, set them to None so that + they are not included in the JSON output when serialized using model_dump_json. + """ + if not data.name: + data.name = None + if not data.description: + data.description = None + return data + + +class Selection(_Valued, _Versioned, _Keyed, _Namespaced, _Base, BaseModel): + """ + A minimal selection object that contains the decision point ID and the selected values. + While the Selection object parallels the DecisionPoint object, it is intentionally minimal, with + fewer required fields and no additional metadata, as it is meant to represent a selection made from a + previously defined decision point. The expectation is that a Selection object will usually have + fewer values than the original decision point, as it represents a specific evaluation + at a specific time and may therefore rule out some values that were previously considered. + Other fields like name and description may be copied from the decision point, but are not required. + """ + + model_config = ConfigDict(extra="forbid") + + # _Versioned has a default value, but here we don't want to have a default + version: VersionString + + values: tuple[MinimalDecisionPointValue, ...] = Field( + ..., + description="A list of selected value keys from the decision point values.", + min_length=1, + examples=[ + [{"key": "N"}, {"key": "Y"}], + [{"key": "A"}, {"key": "B"}, {"key": "C"}], + ], # Example values + ) + + # class method to convert a decision point to a selection + @classmethod + def from_decision_point( + cls, decision_point: DecisionPoint, include_optional: bool = False + ) -> "Selection": + """ + Converts a decision point to a minimal selection object. + + Args: + decision_point (DecisionPoint): The decision point to convert. + + Returns: + Selection: The resulting minimal selection object. + """ + data = { + "namespace": decision_point.namespace, + "key": decision_point.key, + "version": decision_point.version, + "values": [ + MinimalDecisionPointValue(key=val.key) for val in decision_point.values + ], + } + for attr in ("name", "description"): + if hasattr(decision_point, attr): + data[attr] = getattr(decision_point, attr) + + return cls(**data) + + @model_validator(mode="before") + def set_optional_fields(cls, data): + if "name" not in data: + data["name"] = "" + if "description" not in data: + data["description"] = "" + return data + + @model_validator(mode="after") + def validate_values(cls, data): + if not data.name: + data.name = None + if not data.description: + data.description = None + return data + + def model_json_schema(cls, **kwargs): + schema = super().model_json_schema(**kwargs) + not_required = ["name", "description"] + if "required" in schema and isinstance(schema["required"], list): + # remove description from required list if it exists + schema["required"] = [ + field for field in schema["required"] if field not in not_required + ] + return schema + + +class Reference(BaseModel): + """ + A reference to a resource that provides additional context about the decision points or selections. + This object is intentionally minimal and contains only the URL and an optional description. + """ + + model_config = ConfigDict(extra="forbid") + + uri: AnyUrl + description: str + + # override schema generation to ensure that description is not required + def model_json_schema(cls, **kwargs): + schema = super().model_json_schema(**kwargs) + not_required = ["description"] + if "required" in schema and isinstance(schema["required"], list): + # remove description from required list if it exists + schema["required"] = [ + field for field in schema["required"] if field not in not_required + ] + return schema + + +class SelectionList(_Timestamped, BaseModel): + """ + A list decision point selections that represent an evaluation at a specific time of evaluation. + Individual selections are derived from decision points, and each selection + contains a minimal representation of the decision point and the selected values. + + A SelectionList requires a timestamp in RFC 3339 format, which indicates when the selections were made. + + Optional fields include + + - `target_ids`: If present, a non-empty list of identifiers for the item or items being evaluated. + - `resources`: If present, a non-empty list of references to resources that provide additional context about the decision points + found in this selection. Resources point to documentation, JSON files, or other relevant information that + describe what the decision points are and how they should be interpreted. + - `references`: If present, a non-empty list of references to resources that provide additional context about the specific values selected. + References point to reports, advisories, or other relevant information that describe why the selected values were chosen. + """ + + model_config = ConfigDict(extra="forbid") + schemaVersion: Literal[SCHEMA_VERSION] = Field( + ..., + description="The schema version of this selection list.", + ) + + target_ids: TargetIdList = Field( + default_factory=list, + description="Optional list of identifiers for the item or items " + "(vulnerabilities, reports, advisories, systems, assets, etc.) " + "being evaluated by these selections.", + examples=[ + ["CVE-1900-0000"], + ["VU#999999", "GHSA-0123-4567-89ab"], + ], + min_length=1, + ) + selections: list[Selection] = Field( + ..., + description="List of selections made from decision points. Each selection item corresponds to " + "value keys contained in a specific decision point identified by its namespace, key, and version. " + "Note that selection objects are deliberately minimal objects and do not contain the full decision point details.", + min_length=1, + ) + timestamp: datetime = Field( + ..., + description="Timestamp of the selections, in RFC 3339 format.", + examples=["2025-01-01T12:00:00Z", "2025-01-02T15:30:45-04:00"], + ) + resources: list[Reference] = Field( + default_factory=list, + min_length=1, + description="A list of references to resources that provide additional context about the decision points found in this selection.", + examples=[ + [ + { + "uri": "https://example.com/decision_points", + "description": "Documentation for a set of decision points", + }, + { + "uri": "https://example.org/definitions/dp2.json", + "description": "JSON representation of decision point 2", + }, + { + "uri": "https://example.com/ssvc/x_com.example/decision_points.json", + "description": "A JSON file containing extension decision points in the x_com.example namespace", + }, + ], + ], + ) + references: list[Reference] = Field( + default_factory=list, + min_length=1, + description="A list of references to resources that provide additional context about the specific values selected.", + examples=[ + [ + { + "uri": "https://example.com/report", + "description": "A report on which the selections were based", + }, + ] + ], + ) + + @model_validator(mode="before") + def set_schema_version(cls, data): + if "schemaVersion" not in data: + data["schemaVersion"] = SCHEMA_VERSION + return data + + # target_ids should be a non-empty list if not None + @field_validator("target_ids", mode="before") + @classmethod + def validate_target_ids(cls, value: Optional[list[str]]) -> Optional[list[str]]: + """ + Validate the target_ids field. + If target_ids is provided, it must be a non-empty list of strings. + """ + if value is None: + return [] + if not isinstance(value, list): + raise ValueError("target_ids must be a list of strings.") + if len(value) == 0: + raise ValueError("target_ids must be a non-empty list of strings.") + for item in value: + if not isinstance(item, str): + raise ValueError("Each target_id must be a string.") + return value + + def add_selection(self, selection: Selection) -> None: + """ + Adds a minimal selection to the list. + + Args: + selection (Selection): The minimal selection to add. + """ + self.selections.append(selection) + + # override schema generation to ensure it's the way we want it + @classmethod + def model_json_schema(cls, **kwargs): + schema = super().model_json_schema(**kwargs) + + schema["title"] = "Decision Point Value Selection List" + schema["$schema"] = "https://json-schema.org/draft/2020-12/schema" + schema["$id"] = ( + "https://certcc.github.io/SSVC/data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json" + ) + schema["description"] = ( + "This schema defines the structure for representing selected values from SSVC Decision Points. " + "Each selection list can have multiple selection objects, each representing a decision point, and each " + "selection object can have multiple selected values when full certainty (i.e., a singular value selection) " + "is not available." + ) + + non_required_fields = [ + "name", + "description", + "target_ids", + "resources", + "references", + ] + + # remove non-required fields from the required list + if "required" in schema and isinstance(schema["required"], list): + schema["required"] = [ + field + for field in schema["required"] + if field not in non_required_fields + ] + + # dive in to find all the required lists in the schema + # don't forget the defs + if "$defs" in schema: + for prop in schema["$defs"].values(): + if isinstance(prop, dict) and "required" in prop: + # remove non-required fields from the required list + prop["required"] = [ + r for r in prop["required"] if r not in non_required_fields + ] + + # preferred order of fields, just setting for convention + preferred_order = [ + "$schema", + "$id", + "title", + "description", + "schemaVersion", + "type", + "$defs", + "required", + "properties", + "additionalProperties", + ] + + # create a new dict with the preferred order of fields first + ordered_fields = {k: schema[k] for k in preferred_order if k in schema} + # add the rest of the fields in their original order + for k in schema: + if k not in ordered_fields: + ordered_fields[k] = schema[k] + + return ordered_fields + + +def main() -> None: + """ + Prints example selections and their schema in JSON format. + + Returns: + None + """ + from ssvc.decision_points.ssvc.automatable import LATEST as dp1 + from ssvc.decision_points.ssvc.safety_impact import LATEST as dp2 + import json + + a1 = Selection.from_decision_point(dp1) + a2 = Selection.from_decision_point(dp2) + selections = SelectionList( + schemaVersion=SCHEMA_VERSION, + selections=[a1, a2], + timestamp=datetime.now(), + target_ids=["CVE-1900-0001", "GHSA-0123-4567-89ab"], + references=[ + Reference( + uri="https://example.com/report", + description="A report on which the selections were based", + ) + ], + ) + + print(selections.model_dump_json(indent=2, exclude_none=True, exclude_unset=True)) + + print("# Schema for SelectionList") + schema = SelectionList.model_json_schema() + + print(json.dumps(schema, indent=2)) + + # find local path to this file + import os + + current_dir = os.path.dirname(os.path.abspath(__file__)) + # construct the path to the schema file + schema_path = ( + "../../data/schema/v2/Decision_Point_Value_Selection-2-0-0.schema.json" + ) + schema_path = os.path.abspath(os.path.join(current_dir, schema_path)) + + with open(schema_path, "w") as f: + print(f"Writing schema to {schema_path}") + json.dump(schema, f, indent=2) + f.write("\n") # Ensure the file ends with a newline + + +if __name__ == "__main__": + main() diff --git a/src/ssvc/utils/__init__.py b/src/ssvc/utils/__init__.py new file mode 100644 index 00000000..ff52eb81 --- /dev/null +++ b/src/ssvc/utils/__init__.py @@ -0,0 +1,20 @@ +"""Provides utility features for the SSVC package.""" + +# Copyright (c) 2025 Carnegie Mellon University. +# NO WARRANTY. THIS CARNEGIE MELLON UNIVERSITY AND SOFTWARE +# ENGINEERING INSTITUTE MATERIAL IS FURNISHED ON AN "AS-IS" BASIS. +# CARNEGIE MELLON UNIVERSITY MAKES NO WARRANTIES OF ANY KIND, +# EITHER EXPRESSED OR IMPLIED, AS TO ANY MATTER INCLUDING, BUT +# NOT LIMITED TO, WARRANTY OF FITNESS FOR PURPOSE OR +# MERCHANTABILITY, EXCLUSIVITY, OR RESULTS OBTAINED FROM USE +# OF THE MATERIAL. CARNEGIE MELLON UNIVERSITY DOES NOT MAKE +# ANY WARRANTY OF ANY KIND WITH RESPECT TO FREEDOM FROM +# PATENT, TRADEMARK, OR COPYRIGHT INFRINGEMENT. +# Licensed under a MIT (SEI)-style license, please see LICENSE or contact +# permission@sei.cmu.edu for full terms. +# [DISTRIBUTION STATEMENT A] This material has been approved for +# public release and unlimited distribution. Please see Copyright notice +# for non-US Government use and distribution. +# This Software includes and/or makes use of Third-Party Software each +# subject to its own license. +# DM24-0278 diff --git a/src/ssvc/utils/defaults.py b/src/ssvc/utils/defaults.py new file mode 100644 index 00000000..e864283b --- /dev/null +++ b/src/ssvc/utils/defaults.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python +""" +Provides default values and constants for use in SSVC objects. +""" + +# Copyright (c) 2025 Carnegie Mellon University. +# NO WARRANTY. THIS CARNEGIE MELLON UNIVERSITY AND SOFTWARE +# ENGINEERING INSTITUTE MATERIAL IS FURNISHED ON AN "AS-IS" BASIS. +# CARNEGIE MELLON UNIVERSITY MAKES NO WARRANTIES OF ANY KIND, +# EITHER EXPRESSED OR IMPLIED, AS TO ANY MATTER INCLUDING, BUT +# NOT LIMITED TO, WARRANTY OF FITNESS FOR PURPOSE OR +# MERCHANTABILITY, EXCLUSIVITY, OR RESULTS OBTAINED FROM USE +# OF THE MATERIAL. CARNEGIE MELLON UNIVERSITY DOES NOT MAKE +# ANY WARRANTY OF ANY KIND WITH RESPECT TO FREEDOM FROM +# PATENT, TRADEMARK, OR COPYRIGHT INFRINGEMENT. +# Licensed under a MIT (SEI)-style license, please see LICENSE or contact +# permission@sei.cmu.edu for full terms. +# [DISTRIBUTION STATEMENT A] This material has been approved for +# public release and unlimited distribution. Please see Copyright notice +# for non-US Government use and distribution. +# This Software includes and/or makes use of Third-Party Software each +# subject to its own license. +# DM24-0278 + +DEFAULT_VERSION = "0.0.1" +"""The default version for SSVC objects, used when no version is specified at object creation.""" + +X_PFX = "x_" +"""The prefix for extension namespaces. Extension namespaces must start with this prefix.""" + +MIN_NS_LENGTH = 3 +"""The minimum length of a namespace string.""" + +MAX_NS_LENGTH = 1000 +"""The maximum length of a namespace string.""" + +NS_LENGTH_INTERVAL = MAX_NS_LENGTH - MIN_NS_LENGTH +"""The interval between the minimum and maximum lengths of a namespace string.""" + +FIELD_DELIMITER = ":" +"""The delimiter used to separate fields in SSVC object IDs.""" + + +def main(): + pass + + +if __name__ == "__main__": + main() diff --git a/src/ssvc/utils/field_specs.py b/src/ssvc/utils/field_specs.py new file mode 100644 index 00000000..9c4a84df --- /dev/null +++ b/src/ssvc/utils/field_specs.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python +""" +Provides custom data types for use in SSVC objects. +""" + +# Copyright (c) 2025 Carnegie Mellon University. +# NO WARRANTY. THIS CARNEGIE MELLON UNIVERSITY AND SOFTWARE +# ENGINEERING INSTITUTE MATERIAL IS FURNISHED ON AN "AS-IS" BASIS. +# CARNEGIE MELLON UNIVERSITY MAKES NO WARRANTIES OF ANY KIND, +# EITHER EXPRESSED OR IMPLIED, AS TO ANY MATTER INCLUDING, BUT +# NOT LIMITED TO, WARRANTY OF FITNESS FOR PURPOSE OR +# MERCHANTABILITY, EXCLUSIVITY, OR RESULTS OBTAINED FROM USE +# OF THE MATERIAL. CARNEGIE MELLON UNIVERSITY DOES NOT MAKE +# ANY WARRANTY OF ANY KIND WITH RESPECT TO FREEDOM FROM +# PATENT, TRADEMARK, OR COPYRIGHT INFRINGEMENT. +# Licensed under a MIT (SEI)-style license, please see LICENSE or contact +# permission@sei.cmu.edu for full terms. +# [DISTRIBUTION STATEMENT A] This material has been approved for +# public release and unlimited distribution. Please see Copyright notice +# for non-US Government use and distribution. +# This Software includes and/or makes use of Third-Party Software each +# subject to its own license. +# DM24-0278 + +from typing import Annotated + +from pydantic import Field + +from ssvc.utils.defaults import MAX_NS_LENGTH, MIN_NS_LENGTH +from ssvc.utils.patterns import NS_PATTERN, VERSION_PATTERN + +VersionString = Annotated[ + str, + Field( + description="The version of the SSVC object. This must be a valid semantic version string.", + examples=["1.0.0", "2.1.3"], + pattern=VERSION_PATTERN, + min_length=5, + ), +] +"""A string datatype for version values, for use in Pydantic models.""" + +NamespaceString = Annotated[ + str, + Field( + description="The namespace of the SSVC object.", + examples=[ + "ssvc", + "cisa", + "x_com.example//com.example#private", + "ssvc/de-DE/example.organization#reference-arch-1", + ], + pattern=NS_PATTERN, + min_length=MIN_NS_LENGTH, + max_length=MAX_NS_LENGTH, + ), +] +"""A string datatype for namespace values, for use in Pydantic models.""" + +TargetIdList = Annotated[list[str], Field(min_length=1)] +"""A list of target IDs, for use in Pydantic models.""" + + +def main(): + pass + + +if __name__ == "__main__": + main() diff --git a/src/ssvc/utils/patterns.py b/src/ssvc/utils/patterns.py new file mode 100644 index 00000000..c3cef410 --- /dev/null +++ b/src/ssvc/utils/patterns.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python +""" +Provides python regular expressions and utility functions for SSVC-related patterns. +""" + +# Copyright (c) 2025 Carnegie Mellon University. +# NO WARRANTY. THIS CARNEGIE MELLON UNIVERSITY AND SOFTWARE +# ENGINEERING INSTITUTE MATERIAL IS FURNISHED ON AN "AS-IS" BASIS. +# CARNEGIE MELLON UNIVERSITY MAKES NO WARRANTIES OF ANY KIND, +# EITHER EXPRESSED OR IMPLIED, AS TO ANY MATTER INCLUDING, BUT +# NOT LIMITED TO, WARRANTY OF FITNESS FOR PURPOSE OR +# MERCHANTABILITY, EXCLUSIVITY, OR RESULTS OBTAINED FROM USE +# OF THE MATERIAL. CARNEGIE MELLON UNIVERSITY DOES NOT MAKE +# ANY WARRANTY OF ANY KIND WITH RESPECT TO FREEDOM FROM +# PATENT, TRADEMARK, OR COPYRIGHT INFRINGEMENT. +# Licensed under a MIT (SEI)-style license, please see LICENSE or contact +# permission@sei.cmu.edu for full terms. +# [DISTRIBUTION STATEMENT A] This material has been approved for +# public release and unlimited distribution. Please see Copyright notice +# for non-US Government use and distribution. +# This Software includes and/or makes use of Third-Party Software each +# subject to its own license. +# DM24-0278 + +import re + +# from https://semver.org/ +VERSION_PATTERN = r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$" +"""A regular expression pattern for semantic versioning (semver).""" + +# from https://docs.oasis-open.org/csaf/csaf/v2.0/os/csaf-v2.0-os.html +BCP_47_PATTERN = r"(([A-Za-z]{2,3}(-[A-Za-z]{3}(-[A-Za-z]{3}){0,2})?|[A-Za-z]{4,8})(-[A-Za-z]{4})?(-([A-Za-z]{2}|[0-9]{3}))?(-([A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3}))*(-[A-WY-Za-wy-z0-9](-[A-Za-z0-9]{2,8})+)*(-[Xx](-[A-Za-z0-9]{1,8})+)?|[Xx](-[A-Za-z0-9]{1,8})+|[Ii]-[Dd][Ee][Ff][Aa][Uu][Ll][Tt]|[Ii]-[Mm][Ii][Nn][Gg][Oo])" +"""A regular expression pattern for BCP-47 language tags.""" + + +# --- Namespace Regex Components --- + +# --- Length constraint --- +LENGTH_CHECK_PATTERN = r"(?=.{3,1000}$)" + +# --- Base namespace --- +NO_CONSECUTIVE_SEP = r"(?!.*[.-]{2,})" # no consecutive '.' or '-' + +BASE_PATTERN = ( + rf"{NO_CONSECUTIVE_SEP}" + r"[a-z][a-z0-9]+" # starts with lowercase letter + 1+ alnum + r"(?:[.-][a-z0-9]+)*" # optional dot or dash + alnum +) + +BASE_NS_PATTERN = rf"(?:x_{BASE_PATTERN}|{BASE_PATTERN})" + +# --- Extension segments --- +# A single ext-seg with at most one '#' +EXT_SEGMENT_PATTERN = ( + rf"{NO_CONSECUTIVE_SEP}" + r"[a-zA-Z][a-zA-Z0-9]*" # start with a letter + r"(?:[.-][a-zA-Z0-9]+)*" # dot or dash + alnum + r"(?:#[a-zA-Z0-9]+(?:[.-][a-zA-Z0-9]+)*)?" # optional single hash section +) + +# Subsequent ext-seg(s) +SUBSEQUENT_EXT = rf"{EXT_SEGMENT_PATTERN}(?:/{EXT_SEGMENT_PATTERN})*" + + +# --- Language extension --- +LANG_EXT = rf"(?:/{BCP_47_PATTERN}/|//)" + +# --- Combine all parts into the full namespace pattern --- +NS_PATTERN_STR = ( + rf"^{LENGTH_CHECK_PATTERN}" + rf"{BASE_NS_PATTERN}" + rf"(?:{LANG_EXT}{SUBSEQUENT_EXT})?$" +) + +# Compile the regex with verbose flag for readability (if needed) +NS_PATTERN = re.compile(NS_PATTERN_STR) + + +def main(): + pass + + +if __name__ == "__main__": + main() diff --git a/src/test/decision_points/test_cvss_helpers.py b/src/test/decision_points/test_cvss_helpers.py index dc662dee..73dcad86 100644 --- a/src/test/decision_points/test_cvss_helpers.py +++ b/src/test/decision_points/test_cvss_helpers.py @@ -65,7 +65,7 @@ def setUp(self) -> None: dp = CvssDecisionPoint( name=f"test_{i}", description=f"test_{i}", - version="1.0", + version="1.0.0", key=f"TDP{i}", values=( DecisionPointValue( diff --git a/src/test/decision_points/test_dp_base.py b/src/test/decision_points/test_dp_base.py index 9c340fc5..d5b1fcf5 100644 --- a/src/test/decision_points/test_dp_base.py +++ b/src/test/decision_points/test_dp_base.py @@ -44,7 +44,7 @@ def setUp(self) -> None: key="bar", description="baz", version="1.0.0", - namespace="x_test", + namespace="x_example.test", values=tuple(self.values), ) @@ -95,7 +95,7 @@ def test_registry_errors_on_duplicate_key(self): dp3 = ssvc.decision_points.ssvc.base.DecisionPoint( name="asdfad", description="asdfasdf", - namespace="x_test-extra", # different namespace + namespace="x_example.test.extra", # different namespace key=self.dp.key, # same key version=self.dp.version, # same version values=tuple(self.values), @@ -131,7 +131,7 @@ def test_registry(self): key="asdfasdf", description="asdfasdf", version="1.33.1", - namespace="x_test", + namespace="x_example.test", values=self.values, ) @@ -157,7 +157,7 @@ def test_ssvc_decision_point(self): self.assertEqual(obj.key, "bar") self.assertEqual(obj.description, "baz") self.assertEqual(obj.version, "1.0.0") - self.assertEqual(obj.namespace, "x_test") + self.assertEqual(obj.namespace, "x_example.test") self.assertEqual(len(self.values), len(obj.values)) def test_ssvc_value_json_roundtrip(self): diff --git a/src/test/decision_points/test_dp_helpers.py b/src/test/decision_points/test_dp_helpers.py index 9be7148d..cd30fe43 100644 --- a/src/test/decision_points/test_dp_helpers.py +++ b/src/test/decision_points/test_dp_helpers.py @@ -31,7 +31,7 @@ def setUp(self) -> None: key="test_dp", description="This is a test decision point", version="1.0.0", - namespace='x_test', + namespace="x_example.test", values=( DecisionPointValue( name="Yes", diff --git a/src/test/dp_groups/test_dp_groups.py b/src/test/dp_groups/test_dp_groups.py index 7feefe94..907f3df6 100644 --- a/src/test/dp_groups/test_dp_groups.py +++ b/src/test/dp_groups/test_dp_groups.py @@ -31,7 +31,7 @@ def setUp(self) -> None: dp = ssvc.decision_points.ssvc.base.DecisionPoint( name=f"Decision Point {i}", key=f"DP_{i}", - namespace="x_test", + namespace="x_example.test", description=f"Description of Decision Point {i}", version="1.0.0", values=( diff --git a/src/test/outcomes/test_outcomes.py b/src/test/outcomes/test_outcomes.py index 1a130481..5d3245c3 100644 --- a/src/test/outcomes/test_outcomes.py +++ b/src/test/outcomes/test_outcomes.py @@ -41,7 +41,7 @@ def test_outcome_group(self): name="Outcome Group", key="OG", description="an outcome group", - namespace="x_test", + namespace="x_example.test", values=tuple(values), ) diff --git a/src/test/test_doc_helpers.py b/src/test/test_doc_helpers.py index 29899026..73d54650 100644 --- a/src/test/test_doc_helpers.py +++ b/src/test/test_doc_helpers.py @@ -26,7 +26,7 @@ class MyTestCase(unittest.TestCase): def setUp(self): self.dp = DecisionPoint( - namespace="x_test", + namespace="x_example.test", name="test name", description="test description", key="TK", @@ -48,8 +48,8 @@ def test_markdown_table(self): "\n" "| Value | Definition |\n" "|:-----|:-----------|\n" - "| A | A Definition |\n" - "| B | B Definition |" + "| A (A) | A Definition |\n" + "| B (B) | B Definition |" ) self.assertEqual(result, expected) @@ -61,8 +61,8 @@ def test_markdown_table(self): "\n" " | Value | Definition |\n" " |:-----|:-----------|\n" - " | A | A Definition |\n" - " | B | B Definition |" + " | A (A) | A Definition |\n" + " | B (B) | B Definition |" ) self.assertEqual(indented, expected_indented) @@ -73,8 +73,8 @@ def test_example_block(self): self.assertIn("!!! note", result) self.assertIn("\n | Value | Definition |", result) - self.assertIn("\n | A | A Definition |", result) - self.assertIn("\n | B | B Definition |", result) + self.assertIn("\n | A (A) | A Definition |", result) + self.assertIn("\n | B (B) | B Definition |", result) self.assertIn("\n ??? example", result) self.assertIn("\n ```json", result) diff --git a/src/test/test_doctools.py b/src/test/test_doctools.py index 5b38ca71..c669f924 100644 --- a/src/test/test_doctools.py +++ b/src/test/test_doctools.py @@ -146,9 +146,17 @@ def test_dump_json(self): # capture logger output with self.assertLogs() as cm: json_file = dump_json(basename, dp, jsondir, overwrite) - self.assertEqual(_jsonfile, json_file) - # logger warns that the file exists - self.assertIn("already exists", cm.output[0]) + self.assertEqual(_jsonfile, json_file) + # logger warns that the file exists + found = False + for line in cm.output: + if not "WARNING" in line: + continue + # it's a warning log + if "already exists" in line: + found = True + break + self.assertTrue(found, "Expected warning about existing file not found") # should overwrite the file overwrite = True @@ -166,8 +174,20 @@ def test_dump_json(self): d = json.load(open(json_file)) self.assertEqual(dp.name, d["name"]) - def test_main(self): - pass + def test_dump_selection_schema(self): + schemafile = os.path.join(self.tempdir.name, "selection_schema.json") + self.assertFalse(os.path.exists(schemafile)) + from ssvc.doctools import dump_selection_schema + + dump_selection_schema(schemafile) + self.assertTrue(os.path.exists(schemafile)) + + # file is loadable json + d = json.load(open(schemafile)) + self.assertIn("title", d) + self.assertEqual(d["title"], "Decision Point Value Selection List") + self.assertIn("type", d) + self.assertEqual(d["type"], "object") if __name__ == "__main__": diff --git a/src/test/test_mixins.py b/src/test/test_mixins.py index c4724c1c..41c8d480 100644 --- a/src/test/test_mixins.py +++ b/src/test/test_mixins.py @@ -22,8 +22,15 @@ from pydantic import BaseModel, ValidationError -from ssvc._mixins import _Base, _Keyed, _Namespaced, _Valued, _Versioned +from ssvc._mixins import ( + _Base, + _Keyed, + _Namespaced, + _Valued, + _Versioned, +) from ssvc.namespaces import NameSpace +from ssvc.utils.defaults import DEFAULT_VERSION, MAX_NS_LENGTH class TestMixins(unittest.TestCase): @@ -92,12 +99,12 @@ def test_namespaced_create_errors(self): _Namespaced(namespace="x_") # error if namespace starts with x_ but is too long - for i in range(150): + for i in range(MAX_NS_LENGTH + 50): shortest = "x_aaa" ns = shortest + "a" * i with self.subTest(ns=ns): # length limit set in the NS_PATTERN regex - if len(ns) <= 100: + if len(ns) <= MAX_NS_LENGTH: # expect success on shorter than limit _Namespaced(namespace=ns) else: @@ -114,13 +121,13 @@ def test_namespaced_create(self): # custom namespaces are allowed as long as they start with x_ for _ in range(100): # we're just fuzzing some random strings here - ns = f"x_{randint(1000,1000000)}" + ns = f"x_a{randint(1000,1000000)}" obj = _Namespaced(namespace=ns) self.assertEqual(obj.namespace, ns) def test_versioned_create(self): obj = _Versioned() - self.assertEqual(obj.version, "0.0.0") + self.assertEqual(obj.version, DEFAULT_VERSION) obj = _Versioned(version="1.2.3") self.assertEqual(obj.version, "1.2.3") @@ -153,7 +160,7 @@ def test_mixin_combos(self): {"class": _Keyed, "args": {"key": "fizz"}, "has_default": False}, { "class": _Namespaced, - "args": {"namespace": "x_test"}, + "args": {"namespace": "x_example.test"}, "has_default": False, }, { diff --git a/src/test/test_namespaces.py b/src/test/test_namespaces.py index 598de3ed..dde85463 100644 --- a/src/test/test_namespaces.py +++ b/src/test/test_namespaces.py @@ -19,7 +19,8 @@ import unittest -from ssvc.namespaces import NS_PATTERN, NameSpace +from ssvc.namespaces import NameSpace +from ssvc.utils.patterns import NS_PATTERN class MyTestCase(unittest.TestCase): @@ -34,8 +35,9 @@ def test_ns_pattern(self): "foo", "foo.bar", "foo.bar.baz", - "foo/bar/baz/quux", - "foo.bar/baz.quux", + "foo/jp-JP/bar.baz/quux", + "foo//bar/baz/quux", + "foo.bar//baz.quux", ] should_match.extend([f"x_{ns}" for ns in should_match]) diff --git a/src/test/test_namespaces_pattern.py b/src/test/test_namespaces_pattern.py new file mode 100644 index 00000000..716aa6f3 --- /dev/null +++ b/src/test/test_namespaces_pattern.py @@ -0,0 +1,246 @@ +# Copyright (c) 2025 Carnegie Mellon University. +# NO WARRANTY. THIS CARNEGIE MELLON UNIVERSITY AND SOFTWARE +# ENGINEERING INSTITUTE MATERIAL IS FURNISHED ON AN "AS-IS" BASIS. +# CARNEGIE MELLON UNIVERSITY MAKES NO WARRANTIES OF ANY KIND, +# EITHER EXPRESSED OR IMPLIED, AS TO ANY MATTER INCLUDING, BUT +# NOT LIMITED TO, WARRANTY OF FITNESS FOR PURPOSE OR +# MERCHANTABILITY, EXCLUSIVITY, OR RESULTS OBTAINED FROM USE +# OF THE MATERIAL. CARNEGIE MELLON UNIVERSITY DOES NOT MAKE +# ANY WARRANTY OF ANY KIND WITH RESPECT TO FREEDOM FROM +# PATENT, TRADEMARK, OR COPYRIGHT INFRINGEMENT. +# Licensed under a MIT (SEI)-style license, please see LICENSE or contact +# permission@sei.cmu.edu for full terms. +# [DISTRIBUTION STATEMENT A] This material has been approved for +# public release and unlimited distribution. Please see Copyright notice +# for non-US Government use and distribution. +# This Software includes and/or makes use of Third-Party Software each +# subject to its own license. +# DM24-0278 + +import logging +import re +import unittest + +from ssvc.utils.defaults import MAX_NS_LENGTH, MIN_NS_LENGTH +from ssvc.utils.patterns import ( + BASE_NS_PATTERN, + BASE_PATTERN, + EXT_SEGMENT_PATTERN, + LENGTH_CHECK_PATTERN, + NS_PATTERN, +) + +logger = logging.getLogger(__name__) + + +class TestNamespacePattern(unittest.TestCase): + def setUp(self): + self.expect_success = [ + "ssvc", + "cisa", + "custom", # not in enum, but valid for the pattern + "abc", # not in enum, but valid for the pattern + "x_abc", # valid namespace with x_ prefix + "x_custom", # valid namespace with x_ prefix + "x_private-test", # valid namespace with dash + "x_custom.with.dots", # valid namespace with x_ prefix and dots + "x_custom//extension", # double slash is okay when it's the first segment + "x_private-test", # valid namespace with x_ prefix and dash (does not follow reverse domain notation) + "x_com.example//custom-extension", # x_prefix, reverse domain notation, double slash, dashes + "ssvc/de-DE/example.organization#reference-arch-1", # valid BCP-47 tag, reverse domain notation, hash + "ssvc//example.organization#model/com.example#foo", # valid BCP-47 tag, two segments with one hash each + "ssvc/de-DE/reference-arch-1", # valid BCP-47 tag with dashes (But doesn't follow reverse domain notation) + "x_example.test/pl-PL/foo/bar/baz/quux", # valid BCP-47 tag and multiple segments + "com.example", # valid namespace with dots following reverse domain notation + "x_com.example", # valid namespace with x_ prefix and dots following reverse domain notation + "au.com.example", # valid namespace with dots following reverse domain notation for 2-letter TLD + "x_au.com.example" # valid namespace with x_ prefix and dots following reverse domain notation + "abc//com.example", # valid namespace with double slash + "abc//com.au.example", + "abc//com.example/foo.bar", # valid namespace with double slash and additional segments + "abc//com.example-foo.bar", # valid namespace with double slash and dash + "foo.bar//baz.quux", + ] + self.expect_fail = [ + "999", # invalid namespace, numeric only + "99xx", # invalid namespace, numeric prefix + "x__invalid", # invalid namespace, double underscore + "x_-invalid", # invalid namespace, dash after x_ + "x_.invalid", # invalid namespace, dash at end + "x_/foo", # invalid namespace, slash after x_, invalid BCP-47 tag + "x_//foo", # invalid namespace, double slash after x_ + "x_abc/invalid-bcp-47", # not a valid BCP-47 tag + "abc/invalid-bcp-47", # not in enum (but that's ok for the pattern), not a valid BCP-47 tag + "abc/invalid", # not in enum (but that's ok for the pattern), not a valid BCP-47 tag + "x_custom/extension", # not a valid BCP-47 tag + "x_example.test/not-bcp-47", # not a valid BCP-47 tag + "x_custom/extension/with/multiple/segments/" + + "a" * 990, # exceeds max length + "ssvc/de-DE/example.organization##reference-arch-1", # valid BCP-47 tag, reverse domain notation, double hash + "ssvc/de-DE/example.organization#multi#hash#forbidden", # valid BCP-47 tag, reverse domain notation, more than one hash per segment + "x_custom.extension.", # ends with punctuation + "x_custom..extension", # double dot + "x_custom/", # ends with slash + "x_custom/extension//", # double slash at end + "x_custom/extension/with//double/slash", # double slash in middle + "x_custom/extension/with..double.dot", # double dot in middle + "x_custom/extension/with--double-dash", # double dash in middle + "ab", # too short + "x_", # too short after prefix + "x_x_some-weird-private-one", # double x_ not allowed + "x_example.test///org.example#fragment", # three slashes in a row (was an mistake in ABNF previously) + ] + + def test_ns_pattern(self): + + self._test_successes_failures( + NS_PATTERN.pattern, self.expect_fail, self.expect_success + ) + + def test_base_pattern(self): + x_success = [ + "abc", + "contains.dot", + "contains-dash", + "contains-dash-and.dot", + "com.example", # valid namespace with dots following reverse domain notation + "au.com.example", # valid namespace with dots following reverse domain notation + ] + x_fail = [ + "a", # too short + "9abc", # starts with a number + "x_foo", # no x_ in base pattern + "com.example#foo", # no hashes in base + "com.example##foo", # double hash + "com.example#foo#bar", # multiple hashes not allowed + "contains..double.dot", # double dot + "contains--double-dash", # double dash + "contains_underscore", # underscore not allowed + "contains/slash", # slash not allowed + ".starts.with.dot", # starts with a dot + "-starts-with-dash", # starts with a dash + "/starts-with-slash", # starts with a slash + "_starts-with-underscore", # starts with an underscore + "ends-with-dot.", # ends with a dot + "ends-with-dash-", # ends with a dash + "ends-with-slash/", # ends with a slash + ] + self._test_successes_failures(BASE_PATTERN, x_fail, x_success) + + def test_experimental_base_pattern(self): + x_success = [ + "x_abc", + "x_custom", + "x_custom.with.dots", # dots are allowed in the base pattern + "x_custom-with-dashes", # dashes are allowed in the base pattern + ] + x_fail = [ + "9abc", # does not start with x_ + "x__invalid", # double underscore + "x_-invalid", # dash after x_ + "x_.invalid", # dash at end + "x_9abc", # starts with a number after x_ + "x_abc.", # ends with a dot + "x_abc-", # ends with a dash + "x_abc/", # ends with a slash + "x_/foo", # slashes aren't part of the base pattern + ] + self._test_successes_failures(BASE_NS_PATTERN, x_fail, x_success) + + def test_base_ns_pattern(self): + x_success = [ + "abc", + "x_abc", + "x_custom", + "x_custom.with.dots", # dots are allowed in the base pattern + "x_custom-with-dashes", # dashes are allowed in the base pattern + ] + x_fail = [ + "9abc", # starts with a number + "x__invalid", # double underscore + "x_-invalid", # dash after x_ + "x_.invalid", # dash at end + "x_9abc", # starts with a number after x_ + "x_abc.", # ends with a dot + "x_abc-", # ends with a dash + "x_abc/", # ends with a slash + "x_/foo", # slashes aren't part of the base pattern + ] + self._test_successes_failures(BASE_NS_PATTERN, x_fail, x_success) + + def _test_successes_failures( + self, pattern: str, x_fail: list[str], x_success: list[str] + ): + successes = [] + failures = [] + # if pattern is not anchored, anchor it + if not pattern.startswith("^"): + pattern = "^" + pattern + if not pattern.endswith("$"): + pattern = pattern + "$" + + for ns in x_success: + expected = f"Should match {ns}" + if re.match(pattern, ns) is None: + failures.append(expected) + else: + successes.append(expected) + for ns in x_fail: + expected = f"Should not match {ns}" + if re.match(pattern, ns) is not None: + failures.append(expected) + else: + successes.append(expected) + logger.debug(f"Successes: {successes}") + self.assertFalse(failures) + + def test_length_check_pattern(self): + """ + Test the length check pattern for namespaces. + The pattern should enforce a minimum and maximum length. + """ + min_length = MIN_NS_LENGTH + max_length = MAX_NS_LENGTH + + valid_ns = "x_valid_namespace" + too_short_ns = "x_v" + too_long_ns = "x_" + "a" * (max_length - 2) + + for i in range(0, MIN_NS_LENGTH): + # should fail for lengths less than MIN_NS_LENGTH + ns = "a" * i + self.assertIsNone( + re.match(LENGTH_CHECK_PATTERN, ns), f"Should not match: {ns}" + ) + + def test_ext_segment_pattern(self): + """ + Test the extension segment pattern. + The pattern should allow valid extension segments and disallow invalid ones. + """ + valid_segments = [ + "valid", + "valid.extension", + "valid-extension", + "valid#extension", + "valid.extension#with-hash", + "com.example#foo", # valid namespace with hash + ] + invalid_segments = [ + "a_bc", # underscore not allowed + "invalid..segment", # double dot + "invalid--segment", # double dash + "invalid.segment.", # ends with a dot + "invalid.segment-", # ends with a dash + "invalid/segment", # slash not allowed + "com.example##foo", # valid namespace with hash + "invalid#segment#with#multiple#hashes", # multiple hashes not allowed + "invalid/segment/", # ends with a slash + ] + self._test_successes_failures( + EXT_SEGMENT_PATTERN, invalid_segments, valid_segments + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/test/test_policy_generator.py b/src/test/test_policy_generator.py index 57f7b033..1dcc6250 100644 --- a/src/test/test_policy_generator.py +++ b/src/test/test_policy_generator.py @@ -39,7 +39,7 @@ def setUp(self) -> None: name="test", description="test", key="TEST", - namespace="x_test", + namespace="x_example.test", values=tuple( [ DecisionPointValue(key=c, name=c, description=c) @@ -56,7 +56,7 @@ def setUp(self) -> None: name=c, description=c, key=c, - namespace="x_test", + namespace="x_example.test", values=tuple( [ DecisionPointValue(name=v, key=v, description=v) diff --git a/src/test/test_selections.py b/src/test/test_selections.py new file mode 100644 index 00000000..f64c6819 --- /dev/null +++ b/src/test/test_selections.py @@ -0,0 +1,307 @@ +# Copyright (c) 2025 Carnegie Mellon University. +# NO WARRANTY. THIS CARNEGIE MELLON UNIVERSITY AND SOFTWARE +# ENGINEERING INSTITUTE MATERIAL IS FURNISHED ON AN "AS-IS" BASIS. +# CARNEGIE MELLON UNIVERSITY MAKES NO WARRANTIES OF ANY KIND, +# EITHER EXPRESSED OR IMPLIED, AS TO ANY MATTER INCLUDING, BUT +# NOT LIMITED TO, WARRANTY OF FITNESS FOR PURPOSE OR +# MERCHANTABILITY, EXCLUSIVITY, OR RESULTS OBTAINED FROM USE +# OF THE MATERIAL. CARNEGIE MELLON UNIVERSITY DOES NOT MAKE +# ANY WARRANTY OF ANY KIND WITH RESPECT TO FREEDOM FROM +# PATENT, TRADEMARK, OR COPYRIGHT INFRINGEMENT. +# Licensed under a MIT (SEI)-style license, please see LICENSE or contact +# permission@sei.cmu.edu for full terms. +# [DISTRIBUTION STATEMENT A] This material has been approved for +# public release and unlimited distribution. Please see Copyright notice +# for non-US Government use and distribution. +# This Software includes and/or makes use of Third-Party Software each +# subject to its own license. +# DM24-0278 + +import unittest +from datetime import datetime + +from ssvc import selection +from ssvc.selection import MinimalDecisionPointValue, SelectionList +from ssvc.utils.patterns import NS_PATTERN, VERSION_PATTERN + + +class MyTestCase(unittest.TestCase): + def setUp(self): + self.s1 = selection.Selection( + namespace="x_example.test", + key="test_key_1", + version="1.0.0", + values=[{"key": "value11"}, {"key": "value12"}], + ) + self.s2 = selection.Selection( + namespace="x_example.test", + key="test_key_2", + version="1.0.0", + values=[{"key": "value21"}, {"key": "value22"}], + ) + self.selections = SelectionList( + selections=[self.s1, self.s2], + timestamp=datetime.now(), + target_ids=["target_id_1", "target_id_2"], + ) + + def test_minimal_selection_init(self): + required_attrs = [ + "namespace", + "key", + "version", + "values", + ] + for attr in required_attrs: + self.assertTrue(hasattr(self.s1, attr), f"Attribute {attr} is missing") + # namespace is a valid NamespaceString + self.assertIsInstance(self.s1.namespace, str) + self.assertRegex( + self.s1.namespace, + NS_PATTERN, + "Namespace does not match the required pattern", + ) + + # key is a string + self.assertIsInstance(self.s1.key, str) + self.assertGreater(len(self.s1.key), 0, "Key should not be empty") + # version is a valid VersionString + self.assertIsInstance(self.s1.version, str) + self.assertRegex( + self.s1.version, + VERSION_PATTERN, + "Version does not match the required pattern", + ) + + # values is list of strings' + self.assertIsInstance(self.s1.values, tuple) + for value in self.s1.values: + self.assertIsInstance( + value, + MinimalDecisionPointValue, + f"Value {value} is not a MinimalDecisionPoint", + ) + self.assertTrue( + hasattr(value, "key"), f"Attribute 'key' is missing from {value}" + ) + self.assertIsInstance(value.key, str) + + def test_minimal_selection_list_init(self): + required_attrs = [ + "schemaVersion", + "selections", + "timestamp", + ] + for attr in required_attrs: + self.assertTrue( + hasattr(self.selections, attr), f"Attribute {attr} is missing" + ) + + # schemaVersion is a string + self.assertIsInstance(self.selections.schemaVersion, str) + self.assertEqual( + self.selections.schemaVersion, + selection.SCHEMA_VERSION, + "Schema version does not match the expected value", + ) + self.assertRegex(self.selections.schemaVersion, VERSION_PATTERN) + + self.assertIsInstance(self.selections.target_ids, (list, type(None))) + for target_id in self.selections.target_ids: + self.assertIsInstance( + target_id, str, f"Target ID {target_id} is not a string" + ) + + # selections is a list of Selection objects + self.assertIsInstance(self.selections.selections, list) + for sel in self.selections.selections: + self.assertIsInstance(sel, selection.Selection) + + # timestamp is a datetime object + self.assertIsInstance(self.selections.timestamp, datetime) + + def test_minimal_decision_point_value_validators(self): + """Test the model validators for MinimalDecisionPointValue.""" + # Test set_optional_fields validator + value = MinimalDecisionPointValue(key="test_key") + self.assertIsNone(value.name) + self.assertIsNone(value.description) + + # Test with empty strings + value_empty = MinimalDecisionPointValue(key="test_key", name="", description="") + self.assertIsNone(value_empty.name) + self.assertIsNone(value_empty.description) + + def test_selection_validators(self): + """Test the model validators for Selection.""" + # Test with minimal data + selection_minimal = selection.Selection( + namespace="x_example.test", + key="test_key", + version="1.0.0", + values=[{"key": "value1"}], + ) + self.assertIsNone(selection_minimal.name) + self.assertIsNone(selection_minimal.description) + + # Test with empty strings + selection_empty = selection.Selection( + namespace="x_example.test", + key="test_key", + version="1.0.0", + values=[{"key": "value1"}], + name="", + description="", + ) + self.assertIsNone(selection_empty.name) + self.assertIsNone(selection_empty.description) + + def test_from_decision_point(self): + """Test converting a decision point to a selection.""" + from ssvc.decision_points.ssvc.automatable import LATEST as dp + + selection_obj = selection.Selection.from_decision_point(dp) + + self.assertEqual(selection_obj.namespace, dp.namespace) + self.assertEqual(selection_obj.key, dp.key) + self.assertEqual(selection_obj.version, dp.version) + self.assertEqual(len(selection_obj.values), len(dp.values)) + + for sel_val, dp_val in zip(selection_obj.values, dp.values): + self.assertEqual(sel_val.key, dp_val.key) + + def test_reference_model(self): + """Test the Reference model.""" + ref = selection.Reference( + uri="https://example.com/test", description="Test description" + ) + self.assertEqual(str(ref.uri), "https://example.com/test") + self.assertEqual(ref.description, "Test description") + + def test_selection_list_validators(self): + """Test SelectionList validators.""" + # Test schema version is set automatically + sel_list = SelectionList( + selections=[self.s1], + timestamp=datetime.now(), + ) + self.assertEqual(sel_list.schemaVersion, selection.SCHEMA_VERSION) + + def test_target_ids_validation(self): + """Test target_ids field validation.""" + # Test empty list throws ValueError + with self.assertRaises(ValueError): + SelectionList( + selections=[self.s1], + timestamp=datetime.now(), + target_ids=[], + ) + + # Test None throws ValueError + with self.assertRaises(ValueError): + SelectionList( + selections=[self.s1], + timestamp=datetime.now(), + target_ids=None, + ) + + # absent target_ids leads to empty list + sel_list = SelectionList( + selections=[self.s1], + timestamp=datetime.now(), + ) + self.assertEqual(sel_list.target_ids, []) + + # Test valid target_ids + sel_list_valid = SelectionList( + selections=[self.s1], + timestamp=datetime.now(), + target_ids=["CVE-1900-0001"], + ) + self.assertEqual(sel_list_valid.target_ids, ["CVE-1900-0001"]) + + # Test invalid target_ids (non-string) + with self.assertRaises(ValueError): + SelectionList( + selections=[self.s1], + timestamp=datetime.now(), + target_ids=[123], # Invalid: not a string + ) + + def test_add_selection_method(self): + """Test the add_selection method.""" + initial_count = len(self.selections.selections) + new_selection = selection.Selection( + namespace="x_example.test", + key="new_key", + version="1.0.0", + values=[{"key": "new_value"}], + ) + + self.selections.add_selection(new_selection) + self.assertEqual(len(self.selections.selections), initial_count + 1) + self.assertEqual(self.selections.selections[-1], new_selection) + + def test_selection_list_optional_fields(self): + """Test SelectionList with optional fields.""" + ref = selection.Reference( + uri="https://example.com/resource", description="Test resource" + ) + + sel_list = SelectionList( + selections=[self.s1, self.s2], + timestamp=datetime.now(), + target_ids=["CVE-1900-0001"], + resources=[ref], + references=[ref], + ) + + self.assertEqual(len(sel_list.resources), 1) + self.assertEqual(len(sel_list.references), 1) + self.assertEqual(sel_list.resources[0].uri, ref.uri) + + def test_model_json_schema_customization(self): + """Test that JSON schema is properly customized.""" + schema = SelectionList.model_json_schema() + + # Check schema metadata + self.assertEqual(schema["title"], "Decision Point Value Selection List") + self.assertEqual( + schema["$schema"], "https://json-schema.org/draft/2020-12/schema" + ) + self.assertIn("$id", schema) + self.assertIn("description", schema) + + # Check that optional fields are not in required list + required_fields = schema.get("required", []) + optional_fields = [ + "name", + "description", + "target_ids", + "resources", + "references", + ] + for field in optional_fields: + self.assertNotIn(field, required_fields) + + def test_selection_values_validation(self): + """Test that Selection requires at least one value.""" + with self.assertRaises(ValueError): + selection.Selection( + namespace="x_example.test", + key="test_key", + version="1.0.0", + values=[], # Empty values should raise error + ) + + def test_selection_list_minimum_selections(self): + """Test that SelectionList requires at least one selection.""" + with self.assertRaises(ValueError): + SelectionList( + selections=[], # Empty selections should raise error + timestamp=datetime.now(), + ) + + +if __name__ == "__main__": + unittest.main()