chore: import upstream snapshot with attribution
Validate YAML Workflows / Validate YAML Configuration Files (push) Has been cancelled
Validate YAML Workflows / Validate YAML Configuration Files (push) Has been cancelled
This commit is contained in:
Executable
+207
@@ -0,0 +1,207 @@
|
||||
"""Utilities for validating and persisting workflow YAML files."""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Tuple
|
||||
|
||||
import yaml
|
||||
|
||||
from check.check import check_config
|
||||
from utils.exceptions import (
|
||||
ResourceConflictError,
|
||||
ResourceNotFoundError,
|
||||
SecurityError,
|
||||
ValidationError,
|
||||
WorkflowExecutionError,
|
||||
)
|
||||
from utils.structured_logger import get_server_logger, LogType
|
||||
|
||||
|
||||
def _update_workflow_id(content: str, workflow_id: str) -> str:
|
||||
pattern = re.compile(r"^(id:\\s*).*$", re.MULTILINE)
|
||||
match = pattern.search(content)
|
||||
if match:
|
||||
return pattern.sub(rf"\\1{workflow_id}", content, count=1)
|
||||
|
||||
lines = content.splitlines()
|
||||
insert_index = 0
|
||||
if lines and lines[0].strip() == "---":
|
||||
insert_index = 1
|
||||
lines.insert(insert_index, f"id: {workflow_id}")
|
||||
updated = "\n".join(lines)
|
||||
if content.endswith("\n"):
|
||||
updated += "\n"
|
||||
return updated
|
||||
|
||||
|
||||
def validate_workflow_filename(filename: str, *, require_yaml_extension: bool = True) -> str:
|
||||
"""Sanitize workflow filenames and guard against traversal attempts."""
|
||||
|
||||
value = (filename or "").strip()
|
||||
if not value:
|
||||
raise ValidationError("Filename cannot be empty", field="filename")
|
||||
|
||||
if ".." in value or value.startswith(("/", "\\")):
|
||||
logger = get_server_logger()
|
||||
logger.log_security_event(
|
||||
"PATH_TRAVERSAL_ATTEMPT",
|
||||
f"Suspicious filename detected: {value}",
|
||||
details={"received_filename": value},
|
||||
)
|
||||
raise SecurityError("Invalid filename format", details={"filename": value})
|
||||
|
||||
if not re.match(r"^[a-zA-Z0-9._-]+$", value):
|
||||
raise ValidationError(
|
||||
"Invalid filename: only letters, digits, dots, underscores, and hyphens are allowed",
|
||||
field="filename",
|
||||
)
|
||||
|
||||
if require_yaml_extension and not value.endswith((".yaml", ".yml")):
|
||||
raise ValidationError("Filename must end with .yaml or .yml", field="filename")
|
||||
|
||||
return Path(value).name
|
||||
|
||||
|
||||
def validate_workflow_content(filename: str, content: str) -> Tuple[str, Any]:
|
||||
safe_filename = validate_workflow_filename(filename, require_yaml_extension=True)
|
||||
|
||||
try:
|
||||
yaml_content = yaml.safe_load(content)
|
||||
if yaml_content is None:
|
||||
raise ValidationError("YAML content is empty", field="content")
|
||||
|
||||
errors = check_config(yaml_content)
|
||||
if errors:
|
||||
raise ValidationError(f"YAML validation errors:\n{errors}", field="content")
|
||||
except yaml.YAMLError as exc:
|
||||
logger = get_server_logger()
|
||||
logger.warning("Invalid YAML content in upload", details={"error": str(exc)})
|
||||
raise ValidationError(f"Invalid YAML syntax: {exc}", field="content")
|
||||
|
||||
return safe_filename, yaml_content
|
||||
|
||||
|
||||
def persist_workflow(
|
||||
safe_filename: str,
|
||||
content: str,
|
||||
yaml_content: Any,
|
||||
*,
|
||||
action: str,
|
||||
directory: Path,
|
||||
) -> None:
|
||||
save_path = directory / safe_filename
|
||||
logger = get_server_logger()
|
||||
|
||||
try:
|
||||
save_path.write_text(content, encoding="utf-8")
|
||||
except Exception as exc:
|
||||
logger.log_exception(exc, f"Failed to save workflow file {safe_filename}")
|
||||
raise WorkflowExecutionError(
|
||||
"Failed to save workflow file", details={"filename": safe_filename}
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Workflow file persisted",
|
||||
log_type=LogType.WORKFLOW,
|
||||
filename=safe_filename,
|
||||
action=action,
|
||||
)
|
||||
|
||||
|
||||
def rename_workflow(source_filename: str, target_filename: str, *, directory: Path) -> None:
|
||||
source_safe = validate_workflow_filename(source_filename, require_yaml_extension=True)
|
||||
target_safe = validate_workflow_filename(target_filename, require_yaml_extension=True)
|
||||
|
||||
if source_safe == target_safe:
|
||||
raise ValidationError("Source and target filenames must be different", field="new_filename")
|
||||
|
||||
source_path = directory / source_safe
|
||||
target_path = directory / target_safe
|
||||
|
||||
if not source_path.exists() or not source_path.is_file():
|
||||
raise ResourceNotFoundError(
|
||||
"Workflow file not found",
|
||||
resource_type="workflow",
|
||||
resource_id=source_safe,
|
||||
)
|
||||
|
||||
if target_path.exists():
|
||||
raise ResourceConflictError(
|
||||
"Target workflow already exists",
|
||||
resource_type="workflow",
|
||||
resource_id=target_safe,
|
||||
)
|
||||
|
||||
logger = get_server_logger()
|
||||
try:
|
||||
source_path.rename(target_path)
|
||||
except Exception as exc:
|
||||
logger.log_exception(exc, f"Failed to rename workflow file {source_safe} to {target_safe}")
|
||||
raise WorkflowExecutionError(
|
||||
"Failed to rename workflow file",
|
||||
details={"source": source_safe, "target": target_safe},
|
||||
)
|
||||
|
||||
try:
|
||||
new_workflow_id = Path(target_safe).stem
|
||||
content = target_path.read_text(encoding="utf-8")
|
||||
updated = _update_workflow_id(content, new_workflow_id)
|
||||
if updated != content:
|
||||
target_path.write_text(updated, encoding="utf-8")
|
||||
except Exception as exc:
|
||||
logger.log_exception(exc, f"Failed to update workflow id after rename to {target_safe}")
|
||||
raise WorkflowExecutionError(
|
||||
"Failed to update workflow id after rename",
|
||||
details={"target": target_safe},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Workflow file renamed",
|
||||
log_type=LogType.WORKFLOW,
|
||||
source=source_safe,
|
||||
target=target_safe,
|
||||
action="rename",
|
||||
)
|
||||
|
||||
|
||||
def copy_workflow(source_filename: str, target_filename: str, *, directory: Path) -> None:
|
||||
source_safe = validate_workflow_filename(source_filename, require_yaml_extension=True)
|
||||
target_safe = validate_workflow_filename(target_filename, require_yaml_extension=True)
|
||||
|
||||
if source_safe == target_safe:
|
||||
raise ValidationError("Source and target filenames must be different", field="new_filename")
|
||||
|
||||
source_path = directory / source_safe
|
||||
target_path = directory / target_safe
|
||||
|
||||
if not source_path.exists() or not source_path.is_file():
|
||||
raise ResourceNotFoundError(
|
||||
"Workflow file not found",
|
||||
resource_type="workflow",
|
||||
resource_id=source_safe,
|
||||
)
|
||||
|
||||
if target_path.exists():
|
||||
raise ResourceConflictError(
|
||||
"Target workflow already exists",
|
||||
resource_type="workflow",
|
||||
resource_id=target_safe,
|
||||
)
|
||||
|
||||
logger = get_server_logger()
|
||||
try:
|
||||
target_path.write_text(source_path.read_text(encoding="utf-8"), encoding="utf-8")
|
||||
except Exception as exc:
|
||||
logger.log_exception(exc, f"Failed to copy workflow file {source_safe} to {target_safe}")
|
||||
raise WorkflowExecutionError(
|
||||
"Failed to copy workflow file",
|
||||
details={"source": source_safe, "target": target_safe},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Workflow file copied",
|
||||
log_type=LogType.WORKFLOW,
|
||||
source=source_safe,
|
||||
target=target_safe,
|
||||
action="copy",
|
||||
)
|
||||
Reference in New Issue
Block a user