Block Kit、签名验证,以及防止一次按钮点击演变为事件的各项设计决策。


Slack 交互式机器人消息截图,显示询问是否批准 EBS 卷修复的提示,附带“批准”和“拒绝”按钮

这张截图展示了一个机器人正在请求删除 EBS 卷的权限。点击
Approve Remediation 将创建卷快照,等待快照完成,然后删除卷,并更新消息说明发生了什么。

实现这一功能主要涉及连接代码。而让它安全运行,避免因陈旧点击、重放请求或某人临时保护资源而造成损坏,才是真正有趣的部分。

本文将通过 FinOps Sentinel 的 Slack 适配器,详细讲解上述两部分。


问题的形态

Slack 的交互性实际上是两个独立的通道,看起来像是一次对话:

Your app ──── incoming webhook ────▶ Slack channel
                                          │
                                     user clicks
                                          │
Your app ◀─── HTTP POST ──────────────────┘
             (a completely new request, from Slack's servers)

Enter fullscreen mode Exit fullscreen mode

点击以来自公网的未认证 POST 形式到达你注册的任何 URL。请求中没有任何信息能证明它来自 Slack,或者确实是人类点击的。这就是安全问题的核心,后续一切都由此展开。


第一部分:设置 Slack 应用

创建应用并获取 webhook

  1. api.slack.com/appsCreate New AppFrom scratch
  2. 命名并选择你的工作区
  3. Incoming Webhooks → 开启 → Add New Webhook to Workspace
  4. 选择频道,点击 Allow,复制 URL
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/TXXXXX/BXXXXX/XXXXXXXX

Enter fullscreen mode Exit fullscreen mode

Webhook 只能向一个频道发送消息,且无法读取任何内容。对于通知机器人来说,这正是合适的权限:无需 OAuth 流程、无需机器人令牌、无需审查作用域。

启用交互性

Interactivity & Shortcuts → 开启 → 设置 Request URL:

https://your-domain.example/callbacks/slack

Enter fullscreen mode Exit fullscreen mode

本地测试需要隧道:

ngrok http 8000
# → https://a1b2c3d4.ngrok.app
# Request URL: https://a1b2c3d4.ngrok.app/callbacks/slack

Enter fullscreen mode Exit fullscreen mode

免费 ngrok URL 每次重启都会变化,必须每次更新 Slack 设置。如果按钮“没反应”,请首先检查此处。

获取签名密钥

Basic InformationApp CredentialsSigning Secret → 显示。

SLACK_SIGNING_SECRET=your_signing_secret_here

Enter fullscreen mode Exit fullscreen mode

这是让回调可信的关键。没有它,你的端点会为任何格式正确的 POST 删除基础设施。


第二部分:发送值得操作的消息

Block Kit 消息是 JSON 块数组。简单版本可以立即工作:

from slack_sdk.webhook import WebhookClient

WebhookClient(webhook_url).send(text=f"Idle volume found: {volume_id}")

Enter fullscreen mode Exit fullscreen mode

但工程师需要采取行动的警报必须在约十秒内(常在手机上)回答什么、在哪里、影响多大、点击后会发生什么

以下是实际实现:

def send_finding_alert(self, finding: Finding, resource: Resource) -> str | None:
    webhook_url = settings.slack_webhook_url
    if not webhook_url:
        raise RuntimeError("SLACK_WEBHOOK_URL is not configured")

    remediable = is_remediable(finding.rule)
    header = (
        "*FinOps Alert: Waste Detected*"
        if remediable
        else "*FinOps Advisory: Possible Idle Resource*"
    )

    blocks: list[dict[str, Any]] = [
        {
            "type": "section",
            "text": {
                "type": "mrkdwn",
                "text": (
                    f"{header}\n\n"
                    f"*Rule:* {finding.rule}\n"
                    f"*Resource:* `{resource.resource_id}` ({resource.resource_type})\n"
                    # Region is not decoration: with several regions scanned, it is
                    # the first thing an approver needs to know where to look, and
                    # remediation runs there.
                    f"*Region:* `{resource.region}`\n"
                    f"*Cost Impact:* ${finding.est_monthly_cost_usd}/mo"
                ),
            },
        }
    ]

Enter fullscreen mode Exit fullscreen mode

Region 行是在一次真实混淆后添加的。 扫描三个区域会产生三条几乎相同的警报,仅资源 ID 不同。没有 region 信息,审批人无法判断即将修改的是哪个账户角落。

LLM 摘要放在独立块中

    if finding.llm_summary:
        # Advisor output is untrusted display copy (it summarizes
        # user-controlled tags), so it goes in its own context block and is
        # never used to build action values.
        blocks.append({
            "type": "context",
            "elements": [{"type": "mrkdwn", "text": finding.llm_summary}],
        })

Enter fullscreen mode Exit fullscreen mode

本地 LLM 为每个发现生成通俗解释。提示包含 AWS 标签,任何拥有标签权限的人都可以写入。因此模型输出被视为不可信的显示内容:仅渲染,不解析,且绝不用于构建任何系统会执行的操作。

按钮是有条件的,这正是整个设计的关键

    if remediable:
        blocks.append({
            "type": "actions",
            "elements": [
                {
                    "type": "button",
                    "text": {"type": "plain_text", "text": "Approve Remediation"},
                    "style": "primary",
                    "value": f"approve_{finding.id}",
                    "action_id": "approve_remediation",
                },
                {
                    "type": "button",
                    "text": {"type": "plain_text", "text": "Deny"},
                    "style": "danger",
                    "value": f"deny_{finding.id}",
                    "action_id": "deny_remediation",
                },
            ],
        })
    else:
        # Metric-inferred: no playbook is allowed to run, so offering an
        # Approve button would promise an action the domain refuses.
        blocks.append({
            "type": "context",
            "elements": [{
                "type": "mrkdwn", "text": ("_Advisory only: inferred from CloudWatch metrics. "
                         "No automated remediation is available for this rule._"),
            }],
        })

Enter fullscreen mode Exit fullscreen mode

有些发现天生就是建议性质。空闲的 EC2 实例可能是热备、批处理任务的间隔,或许可证服务器,低 CPU 只是证据,而非证明。领域模型拒绝对此类规则执行修复。

早期版本对所有警报都渲染按钮。在建议性发现上点击 Approve 会返回拒绝。一个什么都不做的按钮比没有按钮更糟:它会让用户不再信任任何按钮。因此适配器先询问领域模型,再决定是否渲染。

通知预览文本

    response = WebhookClient(webhook_url).send(
        # Notification preview text. The region belongs here too, since
        # this is all a phone lock screen shows.
        text=(f"FinOps Alert: {finding.rule} on {resource.resource_id} "
              f"in {resource.region}"),
        blocks=blocks,
    )
    if response.status_code != 200:
        raise RuntimeError(f"Slack webhook returned {response.status_code}: {response.body}")

    logger.info("Sent Slack alert for finding %s", finding.id)
    # Incoming webhooks return no message timestamp; edits happen via the
    # interaction payload's response_url instead.
    return None

Enter fullscreen mode Exit fullscreen mode

容易忽略:当存在 blocks 时,text 成为推送通知预览。省略它,手机将显示“无法显示此内容”。

注意 raise。 发送失败不会被吞没。调用方会让发现保持未通知状态,以便下次扫描重试。吞没错误会导致警报静默丢失,成本警报意味着金钱继续流失却无人知晓。


第三部分:安全接收点击

先验证再解析

def parse_callback(
    self, raw_body: bytes, headers: Mapping[str, str]
) -> tuple[Decision, dict[str, Any]]:
    self._verify_signature(raw_body, headers)     # ← first line, always
    ...

Enter fullscreen mode Exit fullscreen mode

签名验证是第一步操作。先解析再验证意味着你已经对敌意输入运行了 JSON 解码器。

CALLBACK_MAX_AGE_SECONDS = 60 * 5

def _verify_signature(self, raw_body: bytes, headers: Mapping[str, str]) -> None:
    secret = settings.slack_signing_secret
    if not secret:
        # No secret configured: verification bypassed (local testing).
        return

    timestamp = headers.get("x-slack-request-timestamp") or headers.get(
        "X-Slack-Request-Timestamp"
    )
    signature = headers.get("x-slack-signature") or headers.get(
        "X-Slack-Signature"
    )
    if not timestamp or not signature:
        raise PermissionError("Missing Slack signature headers")

    try:
        age = abs(time.time() - int(timestamp))
    except ValueError as exc:
        raise PermissionError("Invalid Slack timestamp header") from exc
    if age > CALLBACK_MAX_AGE_SECONDS:
        raise PermissionError("Slack request timestamp expired")

    verifier = SignatureVerifier(secret)
    if not verifier.is_valid(body=raw_body, timestamp=timestamp, signature=signature):
        raise PermissionError("Invalid Slack signature")

Enter fullscreen mode Exit fullscreen mode

三项检查,针对三种不同攻击:

检查 阻止
Header 存在 对公网端点的随意探测
时间戳在 5 分钟内 重放:捕获有效请求后稍后重发
HMAC 有效 伪造

重放检查最容易被跳过。签名永远有效,除非你限制其年龄;捕获一次合法的批准回调即可无限重放。Slack 发送时间戳正是为了让你拒绝过期请求。

SignatureVerifier 来自 slack_sdk,使用恒定时间比较,值得使用而非手动实现 HMAC 并用 == 比较。

if not secret: return 旁路是故意为本地测试提供的便利,但也是地雷。 这意味着未配置的部署会接受任何内容。在笔记本上没问题,但在任何可公开访问的地方都很危险。如果要为生产环境加固,我会让它默认拒绝,除非显式设置 ALLOW_UNSIGNED_CALLBACKS=true

原始字节很重要

@app.post("/callbacks/{channel}")
async def notifier_callback(channel: str, request: Request) -> dict[str, Any]:
    raw_body = await request.body()          # ← bytes, not a parsed model
    decision, reply_context = notifier.parse_callback(raw_body, request.headers)

Enter fullscreen mode Exit fullscreen mode

HMAC 是针对 Slack 发送的确切字节计算的。如果先让 FastAPI 将 body 解析为 Pydantic 模型,就无法重建这些字节:键顺序、空白和编码都会改变。签名验证随后会神秘失败,浪费你一下午时间。

解析负载

Slack 以 application/x-www-form-urlencoded 发送,包含一个 payload 字段,里面是 JSON。真的。

    form = urllib.parse.parse_qs(raw_body.decode("utf-8"))
    payload_values = form.get("payload")
    if not payload_values:
        raise ValueError("No payload found")

    try:
        payload = json.loads(payload_values[0])
    except json.JSONDecodeError as exc:
        raise ValueError("Payload is not valid JSON") from exc

    actions = payload.get("actions") or []
    if not actions:
        raise ValueError("No actions in payload")

    value = str(actions[0].get("value", ""))
    action: Literal["approve", "deny"]
    if value.startswith("approve_"):
        action, finding_id = "approve", value.removeprefix("approve_")
    elif value.startswith("deny_"):
        action, finding_id = "deny", value.removeprefix("deny_")
    else:
        raise ValueError(f"Unrecognized action value: {value!r}")

    actor = payload.get("user", {}).get("username") or payload.get("user", {}).get(
        "id", "unknown"
    )

    decision = Decision(
        finding_id=finding_id,
        actor=actor,
        action=action,
        decided_at=datetime.now(UTC),
        channel=self.channel_name,
    )
    reply_context = {
        "response_url": payload.get("response_url"),
        "original_blocks": payload.get("message", {}).get("blocks", []),
    }
    return decision, reply_context

Enter fullscreen mode Exit fullscreen mode

输出是一个领域对象。所有 Slack 相关的东西(表单编码、payload 包装、response_url)都在此边界停止。接收 Decision 的领域服务不知道 Slack 的存在。

按钮值携带系统生成的发现 ID,绝不是模型输出,也绝不是用户输入的内容。解析器拒绝任何不符合预期前缀的内容。

异常到状态码的映射

    try:
        decision, reply_context = notifier.parse_callback(raw_body, request.headers)
    except PermissionError as exc:
        raise HTTPException(status_code=401, detail=str(exc)) from exc
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc

Enter fullscreen mode Exit fullscreen mode

端口定义了异常契约:PermissionError 用于认证失败,ValueError 用于格式错误输入,因此任何 notifier 实现都可以干净地映射到 HTTP,而路由无需知道配置的是哪一个。


第四部分:点击后会发生什么

路由立即将控制权交给领域:

def _decide(finding_id: str, action: str, actor: str, channel: str) -> bool:
    repo = get_repository()
    if action == "approve":
        return approve_finding(
            finding_id,
            repo,
            # The resolver, not a gateway: the service picks the endpoint for
            # the finding's own region.
            get_cloud_gateway,
            actor=actor,
            channel=channel,
            dry_run=settings.dry_run,
        )
    return deny_finding(finding_id, repo, actor=actor, channel=channel)

Enter fullscreen mode Exit fullscreen mode

approve_finding 内部,防护措施在批准时刻重新检查,而非信任检测时刻:

    if resource.lifecycle == ResourceLifecycle.DELETED:
        _audit(repo, "approve_blocked_resource_gone", finding.id, {...})
        return False

    if finding.protected or rules.is_protected(resource.current_tags):
        _audit(repo, "approve_blocked_protected", finding.id, {...})
        return False

    if not rules.is_remediable(finding.rule):
        _audit(repo, "approve_blocked_notify_only", finding.id, {...})
        return False

    playbook = rules.PLAYBOOK_ALLOWLIST.get(resource.resource_type)
    if playbook is None:
        _audit(repo, "approve_blocked_no_playbook", finding.id, {...})
        return False

Enter fullscreen mode Exit fullscreen mode

警报可能在 Slack 中停留数小时。在此期间,有人可能已将资源标记为 finops:protected=true,或已手动删除。较新的意图胜出,每次拒绝都有独立的审计事件名称,以便从数据库回答“为什么没有执行?”

双击问题

Slack 按钮可以轻松双击,Slack 本身也会在超时时重试。没有保护措施,你会得到两次修复。

    if not repo.transition_finding(finding.id, finding.status, FindingStatus.APPROVED):
        return False  # lost the race, someone else already decided

Enter fullscreen mode Exit fullscreen mode

由 compare-and-swap 支持:

UPDATE findings SET status = :new WHERE id = :id AND status = :expected

Enter fullscreen mode Exit fullscreen mode

仅当 rowcount == 1 时返回 True。第二次点击会发现状态已是 APPROVED,匹配失败,不更新,返回 False最多一次修复,从结构上保证,而非靠运气。

编辑消息

def confirm_decision(self, reply_context: dict[str, Any], text: str) -> None:
    response_url = reply_context.get("response_url")
    if not response_url:
        return

    blocks: list[dict[str, Any]] = []
    original_blocks = reply_context.get("original_blocks") or []
    if original_blocks:
        blocks.append(original_blocks[0])  # keep the alert text, drop the buttons
    blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": text}})

    response = WebhookClient(response_url).send(
        text=text, blocks=blocks, replace_original=True
    )
    if response.status_code != 200:
        logger.error("Failed to update Slack message: %s", response.body)

Enter fullscreen mode Exit fullscreen mode

保留块 [0] 并丢弃其余部分,保留上下文(警报原本的内容),同时移除按钮,使已决定的发现无法再次点击。response_url 有效期 30 分钟,最多使用 5 次,足够一次编辑。

结果文本区分每种终端状态:

outcome = f"*Approved* by @{decision.actor}: remediation executed{where}."
outcome = f"*Approved* by @{decision.actor}: DRY RUN, no resources were changed."
outcome = f"*Denied* by @{decision.actor}: no action taken, finding closed."
outcome = f"*Remediation failed*{where} after approval by @{decision.actor}: ..."

Enter fullscreen mode Exit fullscreen mode

仅“Approved”无法告诉操作员实际是否发生了更改。


第五部分:无需工作区即可测试

整个流程无需 Slack 账户即可测试,因为 notifier 是一个端口:

class FakeNotifier(Notifier):
    """Records what was sent. Zero Slack, zero HTTP."""

    def __init__(self):
        self.alerts: list[tuple[str, str]] = []
        self.digests: list[tuple[str, list[str]]] = []

    @property
    def channel_name(self):
        return "fake"

    def send_finding_alert(self, finding, resource):
        self.alerts.append((finding.id, resource.resource_id))
        return f"msg-{len(self.alerts)}"

    def send_digest(self, title, sections):
        self.digests.append((title, sections))
        return f"digest-{len(self.digests)}"

Enter fullscreen mode Exit fullscreen mode

批准并修复流程针对 FakeNotifier + 假云网关 + 内存仓库运行。如果通过,替换 Slack 为 Telegram 不会破坏审批逻辑,因为审批逻辑从未知道 Slack 的存在。

对于适配器本身,mock webhook 客户端并断言 blocks:

def send_and_capture(finding, resource):
    """Send an alert through a mocked webhook and return the blocks sent."""
    settings.slack_webhook_url = "https://hooks.slack.test/T/B/X"
    try:
        with patch("finops_sentinel.adapters.notifications.slack.WebhookClient") as client_cls:
            client_cls.return_value.send.return_value = MagicMock(status_code=200, body="ok")
            SlackAdapter().send_finding_alert(finding, resource)
            return client_cls.return_value.send.call_args.kwargs["blocks"]
    finally:
        settings.slack_webhook_url = None


def test_advisory_findings_get_no_buttons():
    blocks = send_and_capture(make_finding("ec2_idle"), resource)
    assert not any(b["type"] == "actions" for b in blocks)

Enter fullscreen mode Exit fullscreen mode

该测试编码了一个产品决策,而非实现细节。如果有人后来为建议性警报添加按钮,测试就会失败,这正是你希望被打断的时刻。


故障排除

症状 原因
401 Invalid Slack signature 密钥不匹配,或在验证前解析了 body
按钮无反应 Request URL 与当前隧道不匹配;ngrok 重启后会轮换
This content can't be displayed on mobile 缺少 text= 回退与 blocks= 一起提供
超时 Slack 要求 3 秒内返回 200 OK;慢速工作应在响应后执行
消息未编辑 response_url 已过期(30 分钟 / 5 次使用)
重复修复 状态转换没有 compare-and-swap

关于 3 秒规则:本项目的状态转换是单条索引 UPDATE,因此能在预算内很好地响应。如果你的修复很慢,请立即确认,然后在后台执行,否则 Slack 会重试,而你现在依赖的幂等性可能并不存在。


我会做不同的改进

缺少签名密钥时默认拒绝。 旁路在本地方便,但在其他任何地方都很危险。

使用 bot token 而非 incoming webhook。 Webhook 不返回消息时间戳,因此编辑依赖 response_url 及其 30 分钟窗口。bot token 允许你在任何时间对任何消息使用 chat.update

为高影响操作添加确认对话框。 Block Kit 支持在按钮上使用原生 confirm 对象,在误触和删除数据库之间增加一次额外点击。


总结

Slack 集成大约 260 行代码。大约三分之一是 Block Kit 格式化,三分之一是签名验证,三分之一是将负载转换为领域对象。

安全保障并不在 Slack 代码中。它在于按钮点击是一个请求,而非命令:领域重新检查每项防护,使用原子转换确保双击不会执行两次,并在规则不可修复时直接拒绝。

Slack 是驱动适配器。安全存在于内部。


完整源码:github.com/boazleleina/finops-sentinel。参见 adapters/notifications/slack.pySLACK_SETUP.md