Current section
Files
Jump to
Current section
Files
lib/cmdc/plugin/builtin/security_guard.ex
defmodule CMDC.Plugin.Builtin.SecurityGuard do
@moduledoc """
P0 安全防护插件 — 路径黑名单 + 命令黑名单 + 审计日志。
在每次工具执行前(`before_tool`)检查:
- **路径黑名单**:`read_file`、`write_file`、`edit_file`、`grep` 等文件操作工具的路径参数
必须不匹配任何 `blocked_paths` glob 模式
- **命令黑名单**:`shell` 工具的 `command` 参数必须不包含 `blocked_commands` 中的关键词
- **审计日志**:所有被阻止的操作都通过 `CMDC.EventBus` 广播 `{:security_audit, entry}`
## 优先级
`priority/0` 返回 `10`,确保在所有其他 Plugin 前最先执行。
## 默认黑名单
路径默认屏蔽:密钥、凭证、环境变量、系统敏感文件。
命令默认屏蔽:进程信号、磁盘格式化、shutdown 等破坏性操作。
## 配置
{CMDC.Plugin.Builtin.SecurityGuard,
blocked_paths: [".env*", "**/*.key", "**/*.pem"],
blocked_commands: ["rm -rf /", "mkfs"],
audit: true
}
`blocked_paths` 和 `blocked_commands` 与内置默认值**合并**(非替换)。
传入 `default_blocked_paths: false` 或 `default_blocked_commands: false` 可禁用内置默认值。
"""
@behaviour CMDC.Plugin
require Logger
# ==========================================================================
# 默认黑名单
# ==========================================================================
@default_blocked_paths [
"**/*.key",
"**/*.pem",
"**/*.p12",
"**/*.pfx",
"**/*.crt",
"**/*.cer",
".env",
".env.*",
"**/.env",
"**/.env.*",
"~/.ssh/**",
"**/.ssh/**",
"~/.gnupg/**",
"**/.aws/credentials",
"**/.aws/config",
"**/.config/gcloud/**",
"/Library/Keychains/**",
"~/Library/Keychains/**",
"/etc/shadow",
"/etc/passwd"
]
@default_blocked_commands [
"mkfs",
"fdisk",
"dd if=",
"shred",
{:regex, ~r/rm\s+-[a-z]*r[a-z]*f[a-z]*\s+\/(\s|$|;|\|)/i},
{:regex, ~r/chmod\s+-R\s+777\s+\/(\s|$|;|\|)/i},
"chown -R /",
"shutdown",
"reboot",
"halt",
"poweroff",
"kill -9 1",
"kill -KILL 1",
"DROP DATABASE",
"DROP TABLE"
]
@path_tools %{
"read_file" => ["path"],
"write_file" => ["path"],
"edit_file" => ["path"],
"grep" => ["path", "dir", "directory"]
}
# ==========================================================================
# Plugin Callbacks
# ==========================================================================
@impl true
def init(opts) do
use_default_paths = Keyword.get(opts, :default_blocked_paths, true)
use_default_cmds = Keyword.get(opts, :default_blocked_commands, true)
extra_paths = Keyword.get(opts, :blocked_paths, [])
extra_cmds = Keyword.get(opts, :blocked_commands, [])
state = %{
blocked_paths: if(use_default_paths, do: @default_blocked_paths, else: []) ++ extra_paths,
blocked_commands:
if(use_default_cmds, do: @default_blocked_commands, else: []) ++ extra_cmds,
audit: Keyword.get(opts, :audit, true),
blocked_count: 0
}
{:ok, state}
end
@impl true
def priority, do: 10
@impl true
def describe, do: "P0 安全防护:路径黑名单 + 命令黑名单 + 审计"
@impl true
def handle_event({:before_tool, tool_name, args}, state, ctx) do
cond do
Map.has_key?(@path_tools, tool_name) -> check_path(tool_name, args, state, ctx)
tool_name == "shell" -> check_command(args, state, ctx)
true -> {:continue, state}
end
end
def handle_event(_event, state, _ctx), do: {:continue, state}
# ==========================================================================
# 私有 — 路径检查
# ==========================================================================
defp check_path(tool_name, args, state, ctx) do
path_keys = Map.get(@path_tools, tool_name, [])
blocked =
Enum.find_value(path_keys, fn key ->
find_blocked_path(args, key, state)
end)
case blocked do
nil ->
{:continue, state}
{key, path} ->
reason = "路径被安全策略阻止: #{key}=#{path}"
audit(state, ctx, :blocked_path, %{tool: tool_name, path: path, key: key})
{:block_tool, reason, %{state | blocked_count: state.blocked_count + 1}}
end
end
defp find_blocked_path(args, key, state) do
case Map.get(args, key) do
nil -> nil
path -> if path_blocked?(path, state.blocked_paths), do: {key, path}, else: nil
end
end
# ==========================================================================
# 私有 — 命令检查
# ==========================================================================
defp check_command(args, state, ctx) do
command = Map.get(args, "command", "")
blocked_cmd =
Enum.find(state.blocked_commands, fn blocked ->
command_blocked?(command, blocked)
end)
case blocked_cmd do
nil ->
{:continue, state}
pattern ->
pattern_str = format_pattern(pattern)
reason = "命令被安全策略阻止: 匹配 \"#{pattern_str}\""
audit(state, ctx, :blocked_command, %{command: command, pattern: pattern_str})
{:block_tool, reason, %{state | blocked_count: state.blocked_count + 1}}
end
end
# ==========================================================================
# 私有 — 匹配辅助
# ==========================================================================
defp path_blocked?(path, blocked_patterns) do
expanded = expand_home(path)
basename = Path.basename(expanded)
Enum.any?(blocked_patterns, fn pattern ->
expanded_pattern = expand_home(pattern)
cond do
glob_match?(expanded, expanded_pattern) -> true
not String.contains?(pattern, "/") and glob_match?(basename, expanded_pattern) -> true
true -> false
end
end)
end
defp command_blocked?(command, {:regex, %Regex{} = regex}) do
Regex.match?(regex, command)
end
defp command_blocked?(command, pattern) when is_binary(pattern) do
String.contains?(String.downcase(command), String.downcase(pattern))
end
defp glob_match?(path, pattern) do
regex = glob_to_regex(pattern)
case Regex.compile(regex) do
{:ok, re} -> Regex.match?(re, path)
{:error, _} -> false
end
end
defp glob_to_regex(pattern) do
pattern
|> String.replace(".", "\\.")
|> String.replace("**/", "\x00ANYDIR\x00")
|> String.replace("**", "\x00ANYPATH\x00")
|> String.replace("*", "[^/]*")
|> String.replace("?", "[^/]")
|> String.replace("\x00ANYDIR\x00", "(.*/)?")
|> String.replace("\x00ANYPATH\x00", ".*")
|> then(&"^#{&1}$")
end
defp expand_home("~/" <> rest), do: Path.join(System.user_home!(), rest)
defp expand_home(path), do: path
defp format_pattern({:regex, %Regex{} = regex}), do: "regex:#{inspect(regex)}"
defp format_pattern(pattern) when is_binary(pattern), do: pattern
# ==========================================================================
# 私有 — 审计
# ==========================================================================
defp audit(%{audit: false}, _ctx, _type, _detail), do: :ok
defp audit(_state, ctx, type, detail) do
entry = %{
type: type,
session_id: ctx.session_id,
timestamp: DateTime.utc_now() |> DateTime.to_iso8601(),
detail: detail
}
Logger.warning("[SecurityGuard] #{type}: #{inspect(detail)}")
CMDC.EventBus.broadcast(ctx.session_id, {:security_audit, entry})
:ok
end
end