401 lines
16 KiB
Python
401 lines
16 KiB
Python
"""Deterministic security policy primitives shared by every CASAN adapter.
|
|
|
|
This module is deliberately stdlib-only. It never calls a model and never treats
|
|
model text as an enforcement decision. The JSON action registry is the canonical
|
|
machine-readable source; callers receive structured decisions rather than booleans.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import stat
|
|
import subprocess
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Mapping, MutableMapping, Optional
|
|
|
|
|
|
POLICY_VERSION = "1.0.0"
|
|
RISK_ORDER = {"low": 0, "medium": 1, "high": 2, "critical": 3}
|
|
PRODUCTION_PROFILES = {"prod", "production", "strict"}
|
|
DEVELOPMENT_PROFILES = {"", "dev", "development", "local", "test", "testing"}
|
|
FAILURE_POLICIES = {"halt", "quarantine", "require_approval", "record_only"}
|
|
|
|
|
|
def _harness_root() -> Path:
|
|
override = os.environ.get("CASAN_HARNESS_ROOT")
|
|
return Path(override).resolve() if override else Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def _action_registry_path() -> Path:
|
|
override = os.environ.get("CASAN_ACTION_CLASS_REGISTRY")
|
|
return Path(override).resolve() if override else _harness_root() / "config" / "action-classes.json"
|
|
|
|
|
|
def _load_action_registry() -> dict[str, Any]:
|
|
with _action_registry_path().open(encoding="utf-8") as handle:
|
|
payload = json.load(handle)
|
|
if not isinstance(payload, dict) or not isinstance(payload.get("classes"), dict):
|
|
raise ValueError("action class registry is malformed")
|
|
return payload
|
|
|
|
|
|
def _normal(value: object) -> str:
|
|
return re.sub(r"[^a-z0-9]+", "_", str(value or "").strip().lower()).strip("_")
|
|
|
|
|
|
def _higher(left: str, right: str) -> str:
|
|
return right if RISK_ORDER.get(right, 3) > RISK_ORDER.get(left, 3) else left
|
|
|
|
|
|
def _content_risk(text: str) -> str:
|
|
value = (text or "").lower()
|
|
if re.search(r"\b(drop\s+table|truncate|password|api[_-]?key|secret|credential|shutdown|rm\s+-rf)\b", value):
|
|
return "high"
|
|
if re.search(r"\b(deploy|release|migration|permission|policy|external\s+api|database)\b", value):
|
|
return "medium"
|
|
return "low"
|
|
|
|
|
|
def _resource_risk(resource: str) -> str:
|
|
value = (resource or "").lower()
|
|
if re.search(r"(^|/)(\.env|\.ssh|\.aws)(/|$)|\.(pem|key|p12|pfx)$|credential|secret", value):
|
|
return "high"
|
|
if re.search(r"\b(prod|production|customer|tenant)\b", value):
|
|
return "medium"
|
|
return "low"
|
|
|
|
|
|
def _environment_risk(environment: str) -> str:
|
|
return "medium" if _normal(environment) in PRODUCTION_PROFILES else "low"
|
|
|
|
|
|
def classify_action(
|
|
action: str = "",
|
|
tool: str = "",
|
|
resource: str = "",
|
|
command: str = "",
|
|
) -> dict[str, Any]:
|
|
"""Classify an action using deterministic aliases and command patterns.
|
|
|
|
When several signals match, the class with the highest risk floor wins. An
|
|
unknown side-effect-shaped tool is therefore never downgraded by benign text.
|
|
"""
|
|
|
|
registry = _load_action_registry()
|
|
classes = registry["classes"]
|
|
candidates: list[tuple[str, str]] = []
|
|
action_key = _normal(action)
|
|
tool_key = _normal(tool)
|
|
|
|
action_class = registry.get("action_aliases", {}).get(action_key)
|
|
if action_class:
|
|
candidates.append(("action", action_class))
|
|
tool_class = registry.get("tool_aliases", {}).get(tool_key)
|
|
if tool_class:
|
|
candidates.append(("tool", tool_class))
|
|
combined = " ".join(part for part in (command, resource) if part)
|
|
for rule in registry.get("command_patterns", []):
|
|
try:
|
|
if re.search(str(rule["pattern"]), combined, re.IGNORECASE):
|
|
candidates.append(("command_or_resource", str(rule["class"])))
|
|
except (KeyError, re.error, TypeError) as exc:
|
|
raise ValueError("invalid action class pattern") from exc
|
|
|
|
shell_like = tool_key in {"bash", "shell", "exec", "run", "run_command", "terminal"}
|
|
if not candidates and shell_like:
|
|
candidates.append(("unknown_shell_command", "unknown"))
|
|
if not candidates:
|
|
candidates.append(("unclassified", "unknown"))
|
|
|
|
selected_source, selected_class = candidates[0]
|
|
selected_risk = str(classes[selected_class]["risk_floor"])
|
|
for source, candidate in candidates[1:]:
|
|
risk = str(classes[candidate]["risk_floor"])
|
|
if RISK_ORDER.get(risk, 3) > RISK_ORDER.get(selected_risk, 3):
|
|
selected_source, selected_class, selected_risk = source, candidate, risk
|
|
|
|
metadata = classes[selected_class]
|
|
return {
|
|
"schema_version": registry.get("schema_version", POLICY_VERSION),
|
|
"action_class": selected_class,
|
|
"classification_source": selected_source,
|
|
"risk_floor": selected_risk,
|
|
"side_effect_level": metadata["side_effect_level"],
|
|
"side_effecting": metadata["side_effect_level"] != "none",
|
|
"requires_approval": bool(metadata["requires_approval"]),
|
|
"actor_required": bool(metadata["actor_required"]),
|
|
"evidence_required": bool(metadata["evidence_required"]),
|
|
"matched_classes": [candidate for _source, candidate in candidates],
|
|
}
|
|
|
|
|
|
def evaluate_risk(
|
|
action: str = "",
|
|
tool: str = "",
|
|
resource: str = "",
|
|
command: str = "",
|
|
content: str = "",
|
|
actor: str = "",
|
|
environment: str = "",
|
|
) -> dict[str, Any]:
|
|
classification = classify_action(action, tool, resource, command)
|
|
actor_present = bool(str(actor or "").strip())
|
|
identity_risk = "high" if classification["actor_required"] and not actor_present else "low"
|
|
factors = {
|
|
"content_risk": _content_risk(content),
|
|
"action_risk": classification["risk_floor"],
|
|
"resource_risk": _resource_risk(resource),
|
|
"identity_risk": identity_risk,
|
|
"environment_risk": _environment_risk(environment),
|
|
}
|
|
effective = "low"
|
|
for factor in factors.values():
|
|
effective = _higher(effective, factor)
|
|
|
|
reason_codes: list[str] = ["action_risk_floor_applied"]
|
|
if classification["actor_required"] and not actor_present:
|
|
decision = "deny"
|
|
reason_codes.append("actor_identity_required")
|
|
elif classification["requires_approval"] or effective in {"high", "critical"}:
|
|
decision = "require_approval"
|
|
reason_codes.append("explicit_approval_required")
|
|
else:
|
|
decision = "allow"
|
|
reason_codes.append("risk_within_auto_approval_policy")
|
|
|
|
return {
|
|
"schema_version": POLICY_VERSION,
|
|
"policy_id": "casan.action-risk-floor",
|
|
"policy_version": POLICY_VERSION,
|
|
"decision": decision,
|
|
"reason_codes": reason_codes,
|
|
"effective_risk": effective,
|
|
"risk_factors": factors,
|
|
"actor_identity_present": actor_present,
|
|
"evidence_requirement": "required" if classification["evidence_required"] else "standard",
|
|
**classification,
|
|
}
|
|
|
|
|
|
def evaluate_registry_configuration(
|
|
mode: str,
|
|
profile: str,
|
|
explicit_value: Optional[str],
|
|
) -> dict[str, Any]:
|
|
normalized_mode = _normal(mode)
|
|
normalized_profile = _normal(profile)
|
|
explicit = None if explicit_value is None else _normal(explicit_value) in {"1", "true", "yes", "on", "enabled"}
|
|
production = normalized_profile in PRODUCTION_PROFILES
|
|
|
|
if explicit is False and production:
|
|
return {
|
|
"enabled": True,
|
|
"valid": False,
|
|
"unsafe_bypass": False,
|
|
"severity": "critical",
|
|
"reason_code": "h2_registry_bypass_forbidden",
|
|
}
|
|
if explicit is False:
|
|
return {
|
|
"enabled": False,
|
|
"valid": normalized_profile in DEVELOPMENT_PROFILES,
|
|
"unsafe_bypass": True,
|
|
"severity": "high",
|
|
"reason_code": "h2_registry_unsafe_development_bypass",
|
|
}
|
|
return {
|
|
"enabled": True if explicit is None else explicit,
|
|
"valid": True,
|
|
"unsafe_bypass": False,
|
|
"severity": "info",
|
|
"reason_code": "h2_registry_default_enabled" if explicit is None and normalized_mode == "enforce" else "h2_registry_enabled",
|
|
}
|
|
|
|
|
|
def evaluate_failure_policy(
|
|
*,
|
|
side_effecting: bool,
|
|
mode: str,
|
|
profile: str,
|
|
configured: Optional[str] = None,
|
|
) -> dict[str, Any]:
|
|
"""Select single-step failure handling without conflating telemetry success.
|
|
|
|
Invalid configuration fails closed. Production side effects may be made
|
|
stricter, but they may never be configured as record-only.
|
|
"""
|
|
|
|
normalized = _normal(configured or "")
|
|
production = _normal(profile) in PRODUCTION_PROFILES
|
|
enforce = _normal(mode) == "enforce"
|
|
if normalized and normalized not in FAILURE_POLICIES:
|
|
policy, source, valid = "halt", "invalid_policy_fail_closed", False
|
|
elif normalized == "record_only" and side_effecting and production:
|
|
policy, source, valid = "halt", "production_record_only_forbidden", False
|
|
elif normalized:
|
|
policy, source, valid = normalized, "configured", True
|
|
elif side_effecting and (enforce or production):
|
|
policy, source, valid = "halt", "safe_enforce_default", True
|
|
else:
|
|
policy, source, valid = "record_only", "observe_or_read_only_default", True
|
|
return {
|
|
"schema_version": POLICY_VERSION,
|
|
"policy_id": "casan.single-step-failure",
|
|
"policy_version": POLICY_VERSION,
|
|
"failure_policy": policy,
|
|
"source": source,
|
|
"valid": valid,
|
|
"side_effecting": side_effecting,
|
|
"mode": _normal(mode) or "observe",
|
|
"profile": _normal(profile) or "development",
|
|
"execution_result": "failed",
|
|
"telemetry_result": "recorded",
|
|
"assurance_may_continue": policy == "record_only",
|
|
"reason_codes": [source],
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class H2GateContext:
|
|
mode: str
|
|
actor: str
|
|
action: str
|
|
tool: str
|
|
execution_id: str
|
|
enforcement_path: str
|
|
idempotency_key: str
|
|
timeout_seconds: float = 8.0
|
|
|
|
|
|
def _h2_result(context: H2GateContext, underlying: str, reason_code: str, detail: str) -> dict[str, Any]:
|
|
enforce = _normal(context.mode) == "enforce"
|
|
actual_deny = underlying == "deny"
|
|
dependency_failure = underlying == "error"
|
|
execution_allowed = not enforce or (not actual_deny and not dependency_failure)
|
|
if enforce:
|
|
decision = "allow" if execution_allowed else "deny"
|
|
else:
|
|
decision = "observe_only"
|
|
return {
|
|
"schema_version": POLICY_VERSION,
|
|
"policy_id": "casan.h2.tool-registry",
|
|
"policy_version": POLICY_VERSION,
|
|
"decision": decision,
|
|
"underlying_decision": underlying,
|
|
"reason_codes": [reason_code],
|
|
"reason_code": reason_code,
|
|
"mode": _normal(context.mode) or "observe",
|
|
"actor": context.actor or "unidentified",
|
|
"action": context.action,
|
|
"tool": context.tool,
|
|
"execution_id": context.execution_id,
|
|
"enforcement_path": context.enforcement_path,
|
|
"execution_allowed": execution_allowed,
|
|
"certifiable": enforce and execution_allowed,
|
|
"assurance_status": "enforced" if enforce and execution_allowed else "denied" if enforce else "degraded",
|
|
"severity": "high" if reason_code != "h2_ok" else "info",
|
|
"detail": detail[:400],
|
|
"timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
}
|
|
|
|
|
|
def evaluate_h2_gate(
|
|
gate_path: str,
|
|
context: H2GateContext,
|
|
env: Optional[Mapping[str, str]] = None,
|
|
bash_path: str = "bash",
|
|
) -> dict[str, Any]:
|
|
"""Execute the H2 dependency and convert every outcome into one policy decision."""
|
|
|
|
path = Path(gate_path)
|
|
if not path.is_file():
|
|
return _h2_result(context, "error", "h2_gate_unavailable", "gate file is missing")
|
|
try:
|
|
mode_bits = stat.S_IMODE(path.stat().st_mode)
|
|
except OSError as exc:
|
|
return _h2_result(context, "error", "h2_gate_unavailable", f"gate stat failed: {type(exc).__name__}")
|
|
if mode_bits & 0o444 == 0:
|
|
return _h2_result(context, "error", "h2_gate_permission_denied", "gate file is not readable")
|
|
resolved_bash = shutil.which(bash_path) if not os.path.isabs(bash_path) else bash_path
|
|
if not resolved_bash or not Path(resolved_bash).is_file():
|
|
return _h2_result(context, "error", "h2_gate_unavailable", "bash runtime is unavailable")
|
|
|
|
child_env: MutableMapping[str, str] = dict(os.environ)
|
|
if env:
|
|
child_env.update({str(key): str(value) for key, value in env.items()})
|
|
child_env["CASAN_IDEMPOTENCY_KEY"] = context.idempotency_key
|
|
if context.actor and not child_env.get("CASAN_AGENT"):
|
|
child_env["CASAN_AGENT"] = context.actor
|
|
try:
|
|
proc = subprocess.run(
|
|
[resolved_bash, str(path), context.action],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
timeout=max(0.001, context.timeout_seconds),
|
|
env=child_env,
|
|
check=False,
|
|
text=True,
|
|
)
|
|
except subprocess.TimeoutExpired:
|
|
return _h2_result(context, "error", "h2_gate_timeout", "gate exceeded internal timeout")
|
|
except (OSError, ValueError, subprocess.SubprocessError) as exc:
|
|
return _h2_result(context, "error", "h2_gate_internal_error", type(exc).__name__)
|
|
|
|
stdout = proc.stdout.strip()[:4096]
|
|
stderr = proc.stderr.strip()[:4096]
|
|
if proc.returncode == 0 and re.search(r"\bTOOL_APPROVED\b", stdout):
|
|
return _h2_result(context, "allow", "h2_ok", stdout)
|
|
if re.search(r"\bTOOL_DENIED\b", stdout + "\n" + stderr):
|
|
reason_match = re.search(r"reason=([^\s]+)", stdout + "\n" + stderr)
|
|
reason = "h2_denied:%s" % (reason_match.group(1) if reason_match else "policy")
|
|
return _h2_result(context, "deny", reason, stderr or stdout)
|
|
if proc.returncode in {126, 127}:
|
|
return _h2_result(context, "error", "h2_gate_unavailable", stderr or "gate runtime unavailable")
|
|
if proc.returncode != 0:
|
|
return _h2_result(context, "error", "h2_gate_internal_error", stderr or stdout or f"exit={proc.returncode}")
|
|
return _h2_result(context, "error", "h2_gate_malformed_response", stdout or "empty response")
|
|
|
|
|
|
def evaluate_trust_capabilities(env: Optional[Mapping[str, str]] = None) -> dict[str, Any]:
|
|
values = dict(os.environ if env is None else env)
|
|
profile = _normal(values.get("CASAN_PROFILE", ""))
|
|
production = profile in PRODUCTION_PROFILES
|
|
signing_provider = _normal(values.get("CASAN_SIGNING_PROVIDER", "local_openssl"))
|
|
anchor_provider = _normal(values.get("CASAN_IMMUTABLE_ANCHOR_PROVIDER", "local_hash_chain"))
|
|
emergency = _normal(values.get("CASAN_TRUST_EMERGENCY_OVERRIDE", "")) in {"1", "true", "yes", "on"}
|
|
|
|
signing_external = signing_provider in {"vault", "vault_kms", "kms", "hsm"}
|
|
anchor_external = anchor_provider in {"s3_object_lock", "qldb", "external_worm"}
|
|
vault_addr = str(values.get("VAULT_ADDR") or "")
|
|
signing_configured = signing_external and vault_addr.startswith("https://") and bool(values.get("VAULT_TOKEN"))
|
|
anchor_configured = anchor_external and bool(values.get("CASAN_S3_BUCKET")) and bool(values.get("CASAN_S3_KMS_KEY_ID"))
|
|
ready = (not production) or (signing_configured and anchor_configured)
|
|
certifiable = ready and not emergency
|
|
reasons: list[str] = []
|
|
if production and not signing_configured:
|
|
reasons.append("external_signing_trust_root_required")
|
|
if production and not anchor_configured:
|
|
reasons.append("external_immutable_anchor_required")
|
|
if emergency:
|
|
reasons.append("emergency_local_trust_override_active")
|
|
|
|
return {
|
|
"schema_version": POLICY_VERSION,
|
|
"profile": profile or "development",
|
|
"production": production,
|
|
"signing_provider": signing_provider or "local_openssl",
|
|
"signing_capability": "external" if signing_configured else "local_or_unavailable",
|
|
"immutable_anchor_provider": anchor_provider or "local_hash_chain",
|
|
"immutable_anchor_capability": "external" if anchor_configured else "local_or_unavailable",
|
|
"ready": ready,
|
|
"certifiable": certifiable,
|
|
"emergency_override": emergency,
|
|
"severity": "critical" if emergency or not ready else "info",
|
|
"reason_codes": reasons or ["trust_capabilities_satisfied"],
|
|
}
|