《互联网产品的 AI 架构·平台化与治理》9.3 事故响应与披露

安全事件响应不是一份事后复盘模板,而是分级、止血、取证、披露四条并行的时间线。本节定义 sev1 到 sev4 的确认、止血与取证时限,给出按事实分级的规则、固定顺序的止血清单、证据链要求,以及客户、监管与公众三类披露的触发条件、时间线与口径。

09:00 发现,09:45 止血

2026-11-05 09:00,回声的租户隔离监控发现 content.summarize 的一次输出里出现了另一个租户的订单号。09:10 值班确认,09:45 关闭该租户的摘要自动发布并切断跨租户缓存,10:30 取证同学拿到第一份提示版本、工具入参与模型输出快照。

这是一次 sev1。它涉及客户数据、涉及跨境缓存、并且已经产生了实际输出。三条时间线同时开始跑:响应时限要求 15 分钟内确认、60 分钟内止血、2 小时内启动取证;披露判定要求 24 小时内通知客户、72 小时内准备监管材料;复盘则要在止血后回答「为什么监控看到了但没有自动阻断」。

卷三 9 章讲的是权限在一条链路里怎么传、怎么裁剪、怎么防传播。本节不重复那些技术细节,只讲组织级流程:谁在几分钟内做什么、证据怎么留、对外怎么说。

分级只看事实

事件分级最容易变成「谁嗓门大谁 sev1」。可执行的分级只接收事实:事件类型、是否涉及客户数据、是否跨境、是否已被利用。

事实组合级别理由
数据外泄且涉及客户数据或跨境sev1影响不可撤回,外部披露门槛触发
越权动作且已被利用sev1已产生真实动作,可能造成资金或权益损失
已利用、涉及客户数据或跨境,但非外泄sev2影响明确,需限时控制与通知
工具滥用或数据外泄,无上述事实sev2控制被绕过,但影响范围有限
提示注入或越权动作,未被利用sev3需要修复,暂无实际影响
其余sev4观察与常规修复

三条时限

级别确认止血取证启动
sev115 分钟60 分钟2 小时
sev230 分钟4 小时8 小时
sev34 小时24 小时72 小时
sev41 个工作日7 天7 天

时限从检测时间起算,不是从「有人开始处理」起算。确认是指值班书面接管并指定事件指挥官;止血是指影响停止扩大,不要求根因修复;取证启动是指证据快照完成并进入只读存储。

止血清单

  1. 冻结相关工具凭证与租户令牌,阻止继续调用。
  2. 保存提示版本、工具入参、模型输出与检索内容的原始快照。
  3. 关闭受影响的自动动作,改为人工复核。
  4. 如果涉及客户数据,按租户范围隔离数据并启动外泄评估。
  5. 如果涉及跨境,暂停跨境调用路径并记录数据流向。
  6. 指定事件指挥官,建立时间线与证据链。

顺序有讲究。先冻结再取证,是因为凭证还在时影响会继续扩大;先取证再关自动动作,是因为关闭动作可能改变现场。实际操作中前两步可以并行,但必须有人明确对「先做哪个」负责。

止血不等于修复。把自动动作关掉是止血,修好参数校验才是修复。事件状态可以从 open 进入 contained,但只有根因修复、回归通过、监控观察期结束,才能进入 closed。

取证:留下能复核的证据

取证的最低要求是四类材料:时间线、输入、输出、权限。

时间线要带时区,精确到分钟,记录检测、确认、止血、取证、通知、恢复六个节点。输入包括提示版本、系统指令、用户内容、工具返回;输出包括模型输出、工具调用参数、动作结果;权限包括当时使用的密钥、令牌、租户范围与审批记录。

证据必须只读保存,计算哈希,记录保管人。不要让值班在原始表上改状态,也不要让复现环境覆盖生产快照。模型输出可能包含客户数据,取证包的访问权限要按 8.2 的留存与审计规则单独控制,不能和普通日志共用一套权限。

取证还要回答「检测为什么没更早」。这次是租户隔离监控在输出侧发现,说明输入侧没有拦住;下一次要么在缓存层加租户键校验,要么在输出侧加自动阻断。这个结论会进入复盘,也会进入登记表。

披露:四个触发条件

披露判定不看舆情,看四个条件:严重级别是 sev1 或 sev2、涉及客户数据、涉及跨境、已被利用。任一成立就需要披露。

对象触发示例时限内容口径
客户涉及客户数据或 sev1/sev2sev1 24 小时,sev2 48 小时发生了什么、影响哪些数据、已采取什么措施、如何申诉
监管涉及客户数据或跨境72 小时事实、范围、控制、后续计划,不推测原因
公众sev1 或已造成广泛影响sev1 72 小时,sev2 7 天已知事实、影响范围、补救渠道、下一次更新 time

披露口径有五条原则。第一,只讲已确认的事实,原因未定时写「正在调查」,不写猜测。第二,影响范围要给数字和分母,例如「受影响租户 3 个,占该能力活跃租户的 0.4%」,不写「少量」。第三,已经采取的措施要具体到动作和时间。第四,给出下一次更新时间,并守住它。第五,不承诺无法兑现的赔偿或恢复时间。

披露不是一次性的。第一次通知可以信息不全,但必须包含「已知、未知、下一步、下次更新时间」四段。后续更新只补充新事实,不推翻已确认事实;如果确实需要修正,要显式说明修正了哪一条。

分级、时限与披露的实现

incident-gov.ts 把分级、时限与披露判定写成纯函数。classifyIncident 只接收类型与三个事实;responseDeadlines 按级别生成三条截止时间;assessIncident 比较已完成与未完成阶段,输出 breach 与 pending;decideDisclosure 返回触发原因与三类截止时间。

// file: src/gov/incident-gov.ts
// tier: L2
// verify: node --test test/gov/security-gov.test.ts

/** 安全事件响应:分级、时限、止血清单与对外披露口径。 */
export type IncidentSeverity = "sev1" | "sev2" | "sev3" | "sev4";
export type IncidentKind =
  | "prompt-injection" | "tool-misuse" | "data-exfiltration" | "unauthorized-action";

export interface Incident {
  id: string; kind: IncidentKind; severity: IncidentSeverity;
  /** 带时区的 ISO 8601 时间戳。 */
  detectedAt: string;
  acknowledgedAt?: string; containedAt?: string; forensicsStartedAt?: string;
  customerData: boolean; crossBorder: boolean; exploited: boolean;
}
export interface ResponseDeadlines { acknowledge: string; contain: string; forensics: string }
export interface DisclosureDecision {
  required: boolean; reasons: string[];
  customerDeadline?: string; regulatorDeadline?: string; publicDeadline?: string;
}
export interface IncidentAssessment {
  id: string; severity: IncidentSeverity; deadlines: ResponseDeadlines;
  breached: string[]; pending: string[]; containedInMinutes?: number;
  disclosure: DisclosureDecision;
}

type Sla = { acknowledgeMinutes: number; containMinutes: number; forensicsMinutes: number };
export const RESPONSE_SLA: Record<IncidentSeverity, Sla> = {
  sev1: { acknowledgeMinutes: 15, containMinutes: 60, forensicsMinutes: 120 },
  sev2: { acknowledgeMinutes: 30, containMinutes: 240, forensicsMinutes: 480 },
  sev3: { acknowledgeMinutes: 240, containMinutes: 1_440, forensicsMinutes: 4_320 },
  sev4: { acknowledgeMinutes: 1_440, containMinutes: 10_080, forensicsMinutes: 10_080 },
};

function addMinutes(timestamp: string, minutes: number): string {
  return new Date(Date.parse(timestamp) + minutes * 60_000).toISOString();
}

/** 分级只看事实:是否涉及客户数据、是否跨境、是否已被利用。 */
export function classifyIncident(
  kind: IncidentKind,
  facts: { customerData: boolean; crossBorder: boolean; exploited: boolean },
): IncidentSeverity {
  if (kind === "data-exfiltration" && (facts.customerData || facts.crossBorder)) return "sev1";
  if (kind === "unauthorized-action" && facts.exploited) return "sev1";
  if (facts.exploited || facts.customerData || facts.crossBorder) return "sev2";
  if (kind === "data-exfiltration" || kind === "tool-misuse") return "sev2";
  if (kind === "prompt-injection" || kind === "unauthorized-action") return "sev3";
  return "sev4";
}

export function responseDeadlines(
  severity: IncidentSeverity, detectedAt: string,
): ResponseDeadlines {
  const sla = RESPONSE_SLA[severity];
  return {
    acknowledge: addMinutes(detectedAt, sla.acknowledgeMinutes),
    contain: addMinutes(detectedAt, sla.containMinutes),
    forensics: addMinutes(detectedAt, sla.forensicsMinutes),
  };
}

/**
 * 披露判定:严重级别、客户数据、跨境、已利用四个条件任一成立即需披露。
 * 时间线是示例口径,不是法规结论;实际以法务与监管要求为准。
 */
export function decideDisclosure(incident: Incident): DisclosureDecision {
  const reasons: string[] = [];
  if (incident.severity === "sev1" || incident.severity === "sev2") reasons.push("severity");
  if (incident.customerData) reasons.push("customer-data");
  if (incident.crossBorder) reasons.push("cross-border");
  if (incident.exploited) reasons.push("exploited");
  if (reasons.length === 0) return { required: false, reasons };
  const customerHours = incident.severity === "sev1" ? 24 : incident.severity === "sev2" ? 48 : 120;
  const publicHours = incident.severity === "sev1" ? 72 : incident.severity === "sev2" ? 168 : 336;
  const decision: DisclosureDecision = {
    required: true, reasons,
    publicDeadline: addMinutes(incident.detectedAt, publicHours * 60),
  };
  if (incident.customerData) {
    decision.customerDeadline = addMinutes(incident.detectedAt, customerHours * 60);
  }
  if (incident.customerData || incident.crossBorder) {
    decision.regulatorDeadline = addMinutes(incident.detectedAt, 72 * 60);
  }
  return decision;
}

export function assessIncident(incident: Incident, now: string): IncidentAssessment {
  const deadlines = responseDeadlines(incident.severity, incident.detectedAt);
  const phases = [
    { name: "acknowledge", deadline: deadlines.acknowledge, done: incident.acknowledgedAt },
    { name: "contain", deadline: deadlines.contain, done: incident.containedAt },
    { name: "forensics", deadline: deadlines.forensics, done: incident.forensicsStartedAt },
  ];
  const breached: string[] = [];
  const pending: string[] = [];
  for (const phase of phases) {
    if (phase.done !== undefined) {
      if (Date.parse(phase.done) > Date.parse(phase.deadline)) breached.push(phase.name);
    } else if (Date.parse(now) > Date.parse(phase.deadline)) {
      breached.push(phase.name);
    } else {
      pending.push(phase.name);
    }
  }
  return {
    id: incident.id,
    severity: incident.severity,
    deadlines,
    breached,
    pending,
    containedInMinutes: incident.containedAt === undefined ? undefined
      : Math.round((Date.parse(incident.containedAt) - Date.parse(incident.detectedAt)) / 60_000),
    disclosure: decideDisclosure(incident),
  };
}

assessIncident 的一个关键设计是「完成但超时也算 breach」。很多值班系统只看当前状态,已经止血就显示绿色;但如果止血发生在 90 分钟后,而 sev1 要求 60 分钟,这次响应仍然是超时的。把已完成时间和截止时间比较,才能让复盘看到真实表现。

decideDisclosure 把客户、监管、公众三条截止时间分开。涉及客户数据但不需要监管时,只返回客户截止时间;涉及跨境时,监管截止时间固定为 72 小时;公众截止时间按级别取 72 小时、7 天或 14 天。这些是示例口径,不是法规结论,实际以法务意见为准。

示例样本上的运行结果

用三起示例事件运行,结果如下。这是示例样本上的运行结果,不是生产数据。

事件级别确认止血取证判定
INC-01sev109:1009:4510:30全部按时,止血 45 分钟
INC-03sev109:3011:3009:40确认超时 15 分钟,止血超时 90 分钟
INC-02sev3未确认未止血未启动12:00 时三阶段均 pending

INC-01 的披露判定返回四个原因:severity、customer-data、cross-border、exploited。客户截止时间是次日 09:00,监管与公众截止时间是 11-08 09:00。INC-02 是未被利用的提示注入,不涉及客户数据与跨境,披露判定为不需要。

INC-03 的止血用时 150 分钟。它最终控制住了影响,但 sev1 的 60 分钟时限已经超了;复盘时要问的不是「有没有止住」,而是「为什么确认就花了 30 分钟」。

事件关闭后回写登记表

事件关闭不是终点。每起事件至少要回写登记表三件事:对应哪条风险、可能性是否上调、控制是否降级。

INC-01 对应 R-03「摘要输出拼接其他租户内容」。事件证明这条风险不只是理论可能,可能性从 unlikely 上调到 possible;「租户过滤」控制从 partial 保持 partial,因为它在缓存层被绕过了;复查日期从 11-30 提前到 11-20。

如果事件暴露的是登记表里没有的新风险,就新建条目,不要把事件编号塞进旧条目备注。新条目同样要有类别、影响面、可能性、控制、负责人与复查日期,不能因为刚出事就只写一句「加强监控」。

// file: src/gov/security-gov.ts
// tier: L2
// verify: node --test test/gov/security-gov.test.ts

/** 安全风险登记:提示注入、工具滥用、数据外泄、越权动作;金额无关,分数为整数。 */
export type RiskCategory = "prompt-injection" | "tool-misuse" | "data-exfiltration" | "unauthorized-action";
export type Impact = "low" | "medium" | "high" | "critical";
export type Likelihood = "rare" | "unlikely" | "possible" | "likely";
export type ControlMaturity = "planned" | "partial" | "effective";
export type RiskStatus = "open" | "mitigating" | "accepted" | "closed";
export type ResidualRating = "low" | "medium" | "high" | "critical";
export type ReviewState = "scheduled" | "due-soon" | "overdue" | "closed";

const IMPACT: Record<Impact, number> = { low: 1, medium: 2, high: 3, critical: 4 };
const LIKELIHOOD: Record<Likelihood, number> = { rare: 1, unlikely: 2, possible: 3, likely: 4 };
const MATURITY: Record<ControlMaturity, number> = { planned: 0, partial: 1, effective: 2 };
const CONTROL_CAP = 8;
const REVIEW_DAYS: Record<ResidualRating, number> = {
  critical: 30, high: 60, medium: 90, low: 180,
};
export const RISK_CATEGORIES: readonly RiskCategory[] = [
  "prompt-injection", "tool-misuse", "data-exfiltration", "unauthorized-action",
];

export interface RiskControl { id: string; description: string; maturity: ControlMaturity }
export interface RiskEntry {
  id: string; category: RiskCategory; title: string; impact: Impact; likelihood: Likelihood;
  controls: readonly RiskControl[]; owner: string; status: RiskStatus;
  /** 最近一次复查与下一次复查,均为 YYYY-MM-DD。 */
  lastReviewed: string; reviewDate: string;
}
export interface RiskAssessment {
  id: string; inherentScore: number; controlPoints: number; residualScore: number;
  residualRating: ResidualRating; reviewState: ReviewState; overdueDays: number; escalate: boolean;
}
export type RiskIssueCode = "duplicate-id" | "invalid-category" | "empty-owner" | "invalid-date"
  | "review-before-last" | "review-interval-exceeded" | "invalid-control";
export interface RiskIssue { id: string; code: RiskIssueCode; detail: string }

const DATE = /^\d{4}-\d{2}-\d{2}$/;

export function daysBetween(from: string, to: string): number {
  return Math.round((Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`)) / 86_400_000);
}

export function reviewIntervalDays(rating: ResidualRating): number { return REVIEW_DAYS[rating]; }

export function rateResidual(score: number): ResidualRating {
  if (score >= 12) return "critical";
  if (score >= 8) return "high";
  if (score >= 4) return "medium";
  return "low";
}

/** 固有分 = 影响 × 可能性;控制分 planned 0 / partial 1 / effective 2,合计封顶 8。 */
export function assessRisk(entry: RiskEntry, today: string): RiskAssessment {
  const inherentScore = IMPACT[entry.impact] * LIKELIHOOD[entry.likelihood];
  const controlPoints = Math.min(
    CONTROL_CAP, entry.controls.reduce((sum, item) => sum + MATURITY[item.maturity], 0),
  );
  const residualScore = Math.max(1, inherentScore - controlPoints);
  const residualRating = rateResidual(residualScore);
  if (entry.status === "closed") {
    return {
      id: entry.id, inherentScore, controlPoints, residualScore, residualRating,
      reviewState: "closed", overdueDays: 0, escalate: false,
    };
  }
  const until = daysBetween(today, entry.reviewDate);
  const overdueDays = until < 0 ? -until : 0;
  const reviewState: ReviewState = until < 0 ? "overdue" : until <= 14 ? "due-soon" : "scheduled";
  const effective = entry.controls.some((item) => item.maturity === "effective");
  const escalate = residualRating === "critical"
    || (residualRating === "high" && !effective) || overdueDays > 0;
  return {
    id: entry.id, inherentScore, controlPoints, residualScore, residualRating,
    reviewState, overdueDays, escalate,
  };
}

/** 日期不可解析时只报 invalid-date,不继续做间隔比较。 */
export function validateRegister(entries: readonly RiskEntry[]): RiskIssue[] {
  const issues: RiskIssue[] = [];
  const seen = new Set<string>();
  for (const entry of entries) {
    if (seen.has(entry.id)) issues.push({ id: entry.id, code: "duplicate-id", detail: "id twice" });
    seen.add(entry.id);
    if (!RISK_CATEGORIES.includes(entry.category)) {
      issues.push({ id: entry.id, code: "invalid-category", detail: entry.category });
    }
    if (entry.owner.trim() === "") {
      issues.push({ id: entry.id, code: "empty-owner", detail: "owner empty" });
    }
    const dates = [entry.lastReviewed, entry.reviewDate];
    if (dates.some((date) => !DATE.test(date) || Number.isNaN(Date.parse(`${date}T00:00:00Z`)))) {
      issues.push({ id: entry.id, code: "invalid-date", detail: dates.join(",") });
      continue;
    }
    const interval = daysBetween(entry.lastReviewed, entry.reviewDate);
    const allowed = reviewIntervalDays(assessRisk(entry, entry.lastReviewed).residualRating);
    if (interval <= 0) issues.push({ id: entry.id, code: "review-before-last", detail: `${interval}` });
    else if (interval > allowed) {
      issues.push({ id: entry.id, code: "review-interval-exceeded", detail: `${interval}>${allowed}` });
    }
    for (const control of entry.controls) {
      if (control.id.trim() === "" || control.description.trim() === "") {
        issues.push({ id: entry.id, code: "invalid-control", detail: control.id });
      }
    }
  }
  return issues;
}

export interface RegisterSummary {
  total: number; open: number; overdue: number; escalate: number;
  missingOwner: number; byRating: Record<ResidualRating, number>;
}

export function summarizeRegister(entries: readonly RiskEntry[], today: string): RegisterSummary {
  const byRating: Record<ResidualRating, number> = { low: 0, medium: 0, high: 0, critical: 0 };
  let open = 0; let overdue = 0; let escalate = 0; let missingOwner = 0;
  for (const entry of entries) {
    const item = assessRisk(entry, today);
    byRating[item.residualRating] += 1;
    if (entry.status !== "closed") open += 1;
    if (item.reviewState === "overdue") overdue += 1;
    if (item.escalate) escalate += 1;
    if (entry.owner.trim() === "") missingOwner += 1;
  }
  return { total: entries.length, open, overdue, escalate, missingOwner, byRating };
}

assessRisk 在回写后重新计算剩余分与复查状态。如果控制降级、可能性上调,剩余分会上升,复查状态可能从 scheduled 变成 due-soon 或 overdue。登记表不是事故档案,它是下一轮控制的输入。

复盘:不问「谁忘了」,问「哪一层没拦住」

复盘要回答五个问题:检测为什么在这个时间点发生、确认为什么花了这么久、止血动作是否按清单执行、取证是否完整、披露是否按时且口径一致。

每个问题都要落到一层具体控制:输入过滤、工具校验、租户隔离、输出检测、人工复核、监控告警。不要停留在「值班不够细心」。如果一层控制缺失,就补控制;如果控制存在但没生效,就查为什么没生效;如果生效了但没人看,就改告警路由。

复盘输出三条清单:已完成的止血动作、带负责人和日期的修复项、需要更新或新建的风险条目。没有负责人和日期的修复项不算完成,只能算愿望。

小结

安全事件响应有四条并行时间线:分级、止血、取证、披露。分级只看事实,sev1 要求 15 分钟确认、60 分钟止血、2 小时取证;止血有固定六步清单;取证要留时间线、输入、输出、权限四类材料并只读保存;披露由严重级别、客户数据、跨境、已利用四个条件触发,客户、监管、公众分别有独立时限与口径。

示例样本中,INC-01 全部按时、止血 45 分钟;INC-03 确认超时 15 分钟、止血超时 90 分钟;INC-02 在 12:00 时三阶段均 pending。事件关闭后必须回写登记表:调整可能性、降级控制、提前复查,必要时新建条目并扩红队用例集。

阅读导航:上一节:9.2 红队与滥用检测 · 下一节:10.1 平台的自助服务边界 。

常见错误与边界

  • 分级靠感觉。没有事实组合表,sev1 和 sev3 的边界每次都要吵。
  • 止血当成修复。自动动作关了,根因还在,事件不能关闭。
  • 证据散落在聊天记录和临时表。没有哈希、保管人和只读存储。
  • 披露口径写「少量用户」「可能原因」。没有数字、分母和事实边界。
  • 事件关闭后不回写登记表。同类风险下次仍然没有控制。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查