-
Notifications
You must be signed in to change notification settings - Fork 42
Description
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