fix(auto): use action parser for repair gates
Some checks failed
CD Pipeline / tests (push) Failing after 1m2s
CD Pipeline / build-and-deploy (push) Has been skipped
CD Pipeline / post-deploy-checks (push) Has been skipped
Code Review / ai-code-review (push) Successful in 24s

This commit is contained in:
Your Name
2026-04-30 14:06:09 +08:00
parent 9ee3cc6242
commit ed2a4838f2
11 changed files with 279 additions and 60 deletions

View File

@@ -57,6 +57,8 @@ class ActionKind(StrEnum):
READONLY = "readonly"
ROLLOUT = "rollout"
SCALE = "scale"
AUTOSCALE = "autoscale"
SET_RESOURCES = "set_resources"
DELETE_POD = "delete_pod"
@@ -81,13 +83,29 @@ def is_safe_kubectl_action(command: str) -> bool:
return parse_kubectl_action(command).ok
def kubectl_safety_reason(command: str) -> str | None:
"""Return None for a safe kubectl command, otherwise the parser reason.
Non-kubectl commands are outside this parser's scope and return None so
SSH / host-repair gates can keep their own policy.
"""
command = (command or "").strip()
if not command.lower().startswith("kubectl"):
return None
parsed = parse_kubectl_action(command)
return None if parsed.ok else parsed.reason
def parse_kubectl_action(command: str) -> ParsedKubectlAction:
"""Parse and validate a kubectl command for auto-execute safety.
The grammar is intentionally narrow:
- readonly: get/describe/logs/top/version with bounded, known-safe flags
- rollout: rollout restart/undo on workload resources
- rollout: rollout restart on workload resources
- scale: scale deployment/statefulset to a positive replica count
- autoscale: HPA bounds on deployment/statefulset with positive min/max
- set resources: CPU/memory requests/limits on deployment/statefulset
- delete: delete one pod by name only
"""
@@ -124,6 +142,10 @@ def parse_kubectl_action(command: str) -> ParsedKubectlAction:
return _parse_rollout(rest, namespace, namespace_flags)
if verb == "scale":
return _parse_scale(rest, namespace, namespace_flags)
if verb == "autoscale":
return _parse_autoscale(rest, namespace, namespace_flags)
if verb == "set":
return _parse_set(rest, namespace, namespace_flags)
if verb == "delete":
return _parse_delete(rest, namespace, namespace_flags)
return _reject("unsupported_verb")
@@ -241,7 +263,7 @@ def _parse_rollout(
if len(tokens) < 2:
return _reject("rollout_missing_args")
subverb = tokens[0]
if subverb not in {"restart", "undo"}:
if subverb != "restart":
return _reject("unsupported_rollout_subverb")
resource_type, resource_name, rest = _split_resource_ref(tokens[1:])
@@ -308,6 +330,104 @@ def _parse_scale(
)
def _parse_autoscale(
tokens: list[str],
namespace: str | None,
namespace_flags: list[str],
) -> ParsedKubectlAction:
resource_type, resource_name, rest = _split_resource_ref(tokens)
if resource_type not in _SCALABLE_RESOURCES or not resource_name:
return _reject("invalid_autoscale_resource")
min_replicas: int | None = None
max_replicas: int | None = None
cpu_percent: int | None = None
remaining_flags: list[str] = []
i = 0
while i < len(rest):
token = rest[i]
flag, raw_value, consumed = _consume_required_flag_value(
rest,
i,
{"--min", "--max", "--cpu-percent"},
)
if not flag or raw_value is None:
return _reject("unsupported_autoscale_flag")
value = _parse_positive_int(raw_value)
if value < 1:
return _reject("autoscale_value_must_be_positive")
if flag == "--min":
min_replicas = value
elif flag == "--max":
max_replicas = value
elif flag == "--cpu-percent":
cpu_percent = value
remaining_flags.extend(rest[i:i + consumed])
i += consumed
if min_replicas is None or max_replicas is None:
return _reject("autoscale_min_max_required")
if max_replicas < min_replicas:
return _reject("autoscale_max_below_min")
if cpu_percent is not None and cpu_percent > 100:
return _reject("autoscale_cpu_percent_out_of_range")
return ParsedKubectlAction(
ok=True,
reason="ok",
kind=ActionKind.AUTOSCALE,
verb="autoscale",
resource_type=resource_type,
resource_name=resource_name,
namespace=namespace,
flags=tuple(namespace_flags + remaining_flags),
)
def _parse_set(
tokens: list[str],
namespace: str | None,
namespace_flags: list[str],
) -> ParsedKubectlAction:
if not tokens or tokens[0] != "resources":
return _reject("unsupported_set_subverb")
resource_type, resource_name, rest = _split_resource_ref(tokens[1:])
if resource_type not in _SCALABLE_RESOURCES or not resource_name:
return _reject("invalid_set_resources_target")
saw_resource_flag = False
remaining_flags: list[str] = []
i = 0
while i < len(rest):
flag, raw_value, consumed = _consume_required_flag_value(
rest,
i,
{"--limits", "--requests"},
)
if not flag or raw_value is None:
return _reject("unsupported_set_resources_flag")
if not _resource_quantity_assignments_safe(raw_value):
return _reject("invalid_resource_quantity")
saw_resource_flag = True
remaining_flags.extend(rest[i:i + consumed])
i += consumed
if not saw_resource_flag:
return _reject("set_resources_requires_limits_or_requests")
return ParsedKubectlAction(
ok=True,
reason="ok",
kind=ActionKind.SET_RESOURCES,
verb="set",
subverb="resources",
resource_type=resource_type,
resource_name=resource_name,
namespace=namespace,
flags=tuple(namespace_flags + remaining_flags),
)
def _parse_delete(
tokens: list[str],
namespace: str | None,
@@ -339,6 +459,43 @@ def _parse_positive_int(value: str) -> int:
return int(value)
def _consume_required_flag_value(
tokens: list[str],
index: int,
allowed_flags: set[str],
) -> tuple[str | None, str | None, int]:
token = tokens[index]
if "=" in token:
flag, value = token.split("=", 1)
if flag not in allowed_flags or not value:
return None, None, 1
return flag, value, 1
if token not in allowed_flags or index + 1 >= len(tokens):
return None, None, 1
value = tokens[index + 1]
if not value or value.startswith("-"):
return None, None, 1
return token, value, 2
def _resource_quantity_assignments_safe(value: str) -> bool:
parts = value.split(",")
if not parts:
return False
for part in parts:
key, separator, quantity = part.partition("=")
if separator != "=":
return False
if key not in {"cpu", "memory"}:
return False
if not quantity or not set(quantity) <= _SAFE_TOKEN_CHARS:
return False
if quantity in {"0", "0m", "0Mi", "0Gi"}:
return False
return True
def _flags_allowed(tokens: list[str], allowed_flags: set[str]) -> bool:
i = 0
while i < len(tokens):

View File

@@ -32,6 +32,7 @@ import structlog
import yaml
from src.constants.alert_types import ALERTNAME_TO_TYPE
from src.services.action_parser import parse_kubectl_action
logger = structlog.get_logger(__name__)
@@ -43,19 +44,17 @@ _generating: set[str] = set()
# Redis 分散式鎖 TTL (秒),覆蓋 Ollama + Gemini 最長生成時間
_RULE_GEN_LOCK_TTL = 120
# ── kubectl 注入防護 (Task 2.3, ADR-076, 2026-04-14) ─────────
# 對齊 auto_approve._DESTRUCTIVE_PATTERNS + decision_manager._ALLOWED_KUBECTL_PATTERN
# 目標: 規則 YAML 中的 kubectl_command 在變數替換後若含下列破壞性模式 → 清空並告警
_RULE_ENGINE_DESTRUCTIVE_RE = re.compile(
r"(kubectl\s+delete\s+(pvc|namespace|statefulset|deployment)" # 破壞性 K8s 刪除
r"|kubectl\s+(drain|cordon)" # 節點驅逐/封鎖
r"|--replicas=\s*0\b" # 縮容至零
r"|rm\s+-[rf]{1,2}\s" # rm -rf
r"|\bdrop\s+(table|database)\b" # SQL 破壞性 DDL
r"|\$\([^)]{0,200}\)" # shell 命令替換 $(...)
r"|`[^`]{0,200}`" # 反引號替換
r")",
re.IGNORECASE,
# ── action parser 注入防護 (SPF-2, 2026-04-30) ───────────────
# kubectl 走 structured token parser非 kubectl 保留簡單 dangerous-fragment
# 掃描,避免舊式巨型 regex 誤殺安全的單一 delete pod / deployment resource forms。
_RULE_ENGINE_DANGEROUS_FRAGMENTS = (
"rm -rf",
"rm -f /",
"drop table",
"drop database",
"truncate table",
"$(",
"`",
)
# ── kubectl 注入防護 公開 API ───────────────────────────────
@@ -63,7 +62,7 @@ _RULE_ENGINE_DESTRUCTIVE_RE = re.compile(
def validate_kubectl_command(command: str) -> bool:
"""
kubectl 注入安全驗證Task 2.3, ADR-076
Action 注入安全驗證Task 2.3, ADR-076; SPF-2 parser upgrade)。
Returns:
True — 指令安全,可執行
@@ -74,18 +73,19 @@ def validate_kubectl_command(command: str) -> bool:
- "ssh ..." 開頭 — SSH 層指令,不走 kubectl 路徑
阻擋條件(返回 False:
- kubectl delete pvc/namespace/statefulset/deployment — 破壞性刪除
- kubectl drain / cordon — 節點驅逐(業務衝擊
- --replicas=0 — 縮容至零(服務停止)
- rm -rf — 主機層破壞
- DROP TABLE/DATABASE — SQL 破壞性 DDL
- $(...) 或反引號 — Shell 命令注入
- kubectl parser 不支援的語法deployment delete / drain / cordon /
replicas=0 / shell metachar / compound command
- 非 kubectl 指令內含主機/SQL/command-substitution 危險片段
"""
command = (command or "").strip()
if not command:
return True
if command.strip().startswith("ssh "):
if command.startswith("ssh "):
return True
return not bool(_RULE_ENGINE_DESTRUCTIVE_RE.search(command))
if command.startswith("kubectl"):
return parse_kubectl_action(command).ok
command_lower = command.lower()
return not any(fragment in command_lower for fragment in _RULE_ENGINE_DANGEROUS_FRAGMENTS)
# ── 變數提取 ────────────────────────────────────────────────

View File

@@ -26,6 +26,7 @@ from typing import Any
import structlog
from src.models.playbook import Playbook
from src.services.action_parser import parse_kubectl_action
from src.services.playbook_rag import PlaybookMatch
from src.services.trust_engine import TrustScoreManager, get_trust_manager
@@ -105,7 +106,9 @@ _DESTRUCTIVE_PATTERNS: list[str] = [
"replicas=0", # 任何形式的 replicas=0
# --- K8s 刪除操作 ---
"delete pod", # 強制刪除 pod (kubectl delete pod / pods)
"delete pod --all", # 批次刪除 pod
"delete pod -A", # 跨 namespace 刪除 pod
"delete pod --all-namespaces",
"delete pods", # 複數形式
"delete deployment", # 刪除 deployment
"delete pvc", # 刪除 PVC (資料丟失)
@@ -263,20 +266,36 @@ class AutoApprovePolicy:
confidence=confidence,
)
# 條件 1b: 破壞性指令攔截 (ADR-070: 2026-04-11 Claude Sonnet 4.6)
# 即使是 low/medium risk以下操作仍需人工確認
# 原則: 可恢復操作 → 自動執行; 不可逆 / 業務衝擊 → 人工
# M1+C3 修復 2026-04-11 (Code Review): 移至模組常量 + 補全 K8s/Docker 高風險操作
action_lower = action.lower()
for pattern in _DESTRUCTIVE_PATTERNS:
if pattern in action_lower:
# 條件 1b: structured action parser 安全閘 (SPF-2, 2026-04-30)
# kubectl 指令以 token grammar 判斷,避免 substring regex 誤殺
# `kubectl delete pod <one-pod>`,同時攔截 delete deployment /
# delete --all / rollout undo / replicas=0 / shell injection。
action_stripped = action.strip()
action_lower = action_stripped.lower()
kubectl_cmd_raw = str(proposal_data.get("kubectl_command", "") or "").strip()
kubectl_candidate = kubectl_cmd_raw
if not kubectl_candidate and "kubectl" in action_lower:
kubectl_candidate = action_stripped[action_lower.index("kubectl"):].strip()
if kubectl_candidate.lower().startswith("kubectl"):
parsed_action = parse_kubectl_action(kubectl_candidate)
if not parsed_action.ok:
return self._reject(
reason=AutoApproveReason.CRITICAL_OPERATION,
detail=f"Destructive pattern detected: '{pattern}' in action — requires human approval",
detail=f"kubectl action parser rejected action: {parsed_action.reason} — requires human approval",
risk_level=risk_level,
trust_score=trust_score,
confidence=confidence,
)
else:
for pattern in _DESTRUCTIVE_PATTERNS:
if pattern in action_lower:
return self._reject(
reason=AutoApproveReason.CRITICAL_OPERATION,
detail=f"Destructive pattern detected: '{pattern}' in action — requires human approval",
risk_level=risk_level,
trust_score=trust_score,
confidence=confidence,
)
# 條件 1c: 無可執行指令 → 拒絕自動執行2026-04-16 ogt + Claude Sonnet 4.6
# 根因INVALID_TARGET 導致 rule engine 清空 kubectl_commandaction 為空