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 | 观察与常规修复 |
三条时限
| 级别 | 确认 | 止血 | 取证启动 |
|---|---|---|---|
| sev1 | 15 分钟 | 60 分钟 | 2 小时 |
| sev2 | 30 分钟 | 4 小时 | 8 小时 |
| sev3 | 4 小时 | 24 小时 | 72 小时 |
| sev4 | 1 个工作日 | 7 天 | 7 天 |
时限从检测时间起算,不是从「有人开始处理」起算。确认是指值班书面接管并指定事件指挥官;止血是指影响停止扩大,不要求根因修复;取证启动是指证据快照完成并进入只读存储。
止血清单
- 冻结相关工具凭证与租户令牌,阻止继续调用。
- 保存提示版本、工具入参、模型输出与检索内容的原始快照。
- 关闭受影响的自动动作,改为人工复核。
- 如果涉及客户数据,按租户范围隔离数据并启动外泄评估。
- 如果涉及跨境,暂停跨境调用路径并记录数据流向。
- 指定事件指挥官,建立时间线与证据链。
顺序有讲究。先冻结再取证,是因为凭证还在时影响会继续扩大;先取证再关自动动作,是因为关闭动作可能改变现场。实际操作中前两步可以并行,但必须有人明确对「先做哪个」负责。
止血不等于修复。把自动动作关掉是止血,修好参数校验才是修复。事件状态可以从 open 进入 contained,但只有根因修复、回归通过、监控观察期结束,才能进入 closed。
取证:留下能复核的证据
取证的最低要求是四类材料:时间线、输入、输出、权限。
时间线要带时区,精确到分钟,记录检测、确认、止血、取证、通知、恢复六个节点。输入包括提示版本、系统指令、用户内容、工具返回;输出包括模型输出、工具调用参数、动作结果;权限包括当时使用的密钥、令牌、租户范围与审批记录。
证据必须只读保存,计算哈希,记录保管人。不要让值班在原始表上改状态,也不要让复现环境覆盖生产快照。模型输出可能包含客户数据,取证包的访问权限要按 8.2 的留存与审计规则单独控制,不能和普通日志共用一套权限。
取证还要回答「检测为什么没更早」。这次是租户隔离监控在输出侧发现,说明输入侧没有拦住;下一次要么在缓存层加租户键校验,要么在输出侧加自动阻断。这个结论会进入复盘,也会进入登记表。
披露:四个触发条件
披露判定不看舆情,看四个条件:严重级别是 sev1 或 sev2、涉及客户数据、涉及跨境、已被利用。任一成立就需要披露。
| 对象 | 触发 | 示例时限 | 内容口径 |
|---|---|---|---|
| 客户 | 涉及客户数据或 sev1/sev2 | sev1 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-01 | sev1 | 09:10 | 09:45 | 10:30 | 全部按时,止血 45 分钟 |
| INC-03 | sev1 | 09:30 | 11:30 | 09:40 | 确认超时 15 分钟,止血超时 90 分钟 |
| INC-02 | sev3 | 未确认 | 未止血 | 未启动 | 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 的边界每次都要吵。
- 止血当成修复。自动动作关了,根因还在,事件不能关闭。
- 证据散落在聊天记录和临时表。没有哈希、保管人和只读存储。
- 披露口径写「少量用户」「可能原因」。没有数字、分母和事实边界。
- 事件关闭后不回写登记表。同类风险下次仍然没有控制。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。