Skip to content

Built-in validation logic for Response Plans #461

@code2319

Description

@code2319

Is your feature request related to a problem? Please describe.
Currently, Splunk Enterprise Security allows for the creation of response plans to standardize tasks and phases. However, there is no native validation mechanism to verify these plans in code format using this tool. The concept for shippable "Plans-as-Code" was previously introduced here: splunk/security_content#3803.

Describe the solution you'd like
It would be beneficial to the community if contentctl included built-in validation logic for Response Plans. Supporting a "Plans-as-Code" workflow would allow users to programmatically verify their configurations before deployment. By integrating this into contentctl, we can ensure that every response plan adheres to the required schema and logic, resulting in a more reliable and standardized experience for security teams.

Describe alternatives you've considered
One alternative is to handle validation via custom scripts within a CI/CD pipeline (e.g., GitHub Actions), as it shown in concept. However, this creates a delayed feedback loop where engineers only discover errors after pushing their code. Incorporating this directly into the toolset would allow for "shift-left" validation, catching issues on the local workstation and reducing operational friction for the entire team.

Additional context
Simple check within the contentctl validate command is below. This could verify the presence of required fields performing a simple check against the schema located in the response plans directory.

objects/response_template_validator.py

import yaml
import pathlib
from typing import Any, Dict, List
from jsonschema import Draft7Validator


class ResponseTemplateValidator:
    
    @classmethod
    def load_openapi_schema(cls, schema_name: str = 'ResponseTemplate') -> Dict[str, Any]:
        local_schema = pathlib.Path(".") / "response_templates" / "mcopenapi_public.yml"
        bundled_schema = pathlib.Path(file).parent.parent / "templates" / "mcopenapi_public.yml"
        
        schema_path = local_schema if local_schema.exists() else bundled_schema
        if not schema_path.exists():
            raise FileNotFoundError(f"Missing schema: {schema_path}")
        
        with open(schema_path, 'r') as f:
            spec = yaml.safe_load(f)
        
        schemas = spec.get('components', {}).get('schemas', {})
        
        def resolve(obj: Any, visited: set = None) -> Any:
            if visited is None: visited = set()
            if isinstance(obj, dict):
                if '$ref' in obj:
                    ref = obj['$ref'].split('/')[-1]
                    if ref in visited: return {"type": "object"} # Circular
                    visited.add(ref)
                    # Merge properties to catch all requirements
                    resolved_ref = resolve(schemas.get(ref, {}).copy(), visited)
                    visited.remove(ref)
                    return resolved_ref
                return {k: resolve(v, visited) for k, v in obj.items()}
            elif isinstance(obj, list):
                return [resolve(i, visited) for i in obj]
            return obj

        resolved = resolve(schemas.get(schema_name, {}))
        resolved['$schema'] = 'http://json-schema.org/draft-07/schema#'
        return resolved
    
    @classmethod
    def validate_against_openapi(cls, data: Dict[str, Any], schema_name: str = 'ResponseTemplate') -> None:
        schema = cls.load_openapi_schema(schema_name)
        validator = Draft7Validator(schema)
        errors = list(validator.iter_errors(data))
        
        if errors:
            msgs = []
            for err in errors:
                path = ".".join(map(str, err.path)) or "root"
                msgs.append(f"  - [{path}]: {err.message}")
            raise ValueError("\n".join(msgs))

objects/response_template.py

import pathlib
from typing import Any, Optional, List, Dict
from pydantic import BaseModel, Field, model_validator, ConfigDict
from uuid import UUID

from contentctl.objects.response_template_validator import ResponseTemplateValidator


class ResponseTemplate(BaseModel):
    """Response template object - validates against OpenAPI schema"""
    model_config = ConfigDict(extra='allow', populate_by_name=True)
   
    name: str = Field(..., description="Name of the response plan")
    id: Optional[UUID] = Field(None, description="The ID for the response plan")
    key: Optional[UUID] = Field(None, description="Internal use only", alias="_key")
    
    # For compatibility with Director
    file_path: Optional[pathlib.Path] = Field(None, description="Path to the template file")
    
    @classmethod
    def containing_folder(cls) -> pathlib.Path:
        return pathlib.Path("response_templates")
    
    @model_validator(mode='after')
    def validate_keys_match(self) -> 'ResponseTemplate':
        if self.id and self.key and self.id != self.key:
            raise ValueError(f"id ({self.id}) and _key ({self.key}) must match if both are provided")
        return self

    @model_validator(mode='after')
    def validate_openapi_schema(self) -> 'ResponseTemplate':
        data = self.model_dump(mode='json', exclude_none=True, by_alias=True)
        
        try:
            ResponseTemplateValidator.validate_against_openapi(data, 'ResponseTemplate')
        except ValueError as e:
            raise ValueError(f"OpenAPI validation failed for '{self.name}':\n{str(e)}")
        
        return self

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions