审计日志导出、保留与检索
一次权限事故发生后,团队知道某个仓库设置被改过,却回答不了是谁改的、调用来自浏览器还是 token、改动成功还是被拒绝、随后是否又被恢复。产品页面只显示最近活动,导出的 CSV 被不同管理员反复下载,字段含义不一致,时区混杂,还有几天数据因分页中断而永远缺失。审计日志不是“打开开关就有证据”;证据要经过采集、规范化、去重、完整性保护、受控检索、保留和可证明删除。
最小可用审计链必须稳定回答 actor、action、target、result 四个问题,并保留事件时间、接收时间、租户、来源、请求关联、认证方式和原始事件引用。actor 不能只写显示名,result 不能由采集器根据 HTTP 状态随意猜测,target 不能丢掉资源类型。任何无法映射的字段都应保留在原始区,而不是静默删除。
先用关键操作证明日志是否真的存在
在合成租户中创建一个低风险动作,例如给测试仓库添加只读标签,然后撤销;同时制造一次因权限不足而失败的修改。记录操作者、开始时间和合成对象,随后分别在产品界面、API 和导出存储中搜索。目标不是测试告警,而是确认成功和失败是否都被记录、字段延迟多久、哪个入口看不到哪些事件。
{
"testCase": "AUDIT-DEMO-017",
"actor": "user-demo-operator",
"target": "repository/demo-audit-sandbox",
"positiveAction": "label.create",
"negativeAction": "repository.visibility.update",
"windowStart": "<iso-8601-utc>",
"windowEnd": "<iso-8601-utc>",
"expected": {
"successEvents": 1,
"deniedEvents": 1,
"maxIngestionDelay": "<team-slo>"
}
}预期证据包含两个不同结果的事件,并能关联到同一测试编号或时间窗口。若失败操作没有事件,先核对产品是否记录拒绝、事件是否属于另一日志类型、查询角色是否有权看到、导出过滤器是否排除了它。不能把“界面搜不到”直接等同于“产品没记录”,也不能把 API 返回为空解释成没有风险。
现场保护时先暂停会改变审计配置、导出目标或保留策略的操作,记录当前配置和采集器检查点。不要为了重新导出而覆盖旧文件;旧文件可能是证明缺口从何时开始的唯一证据。
统一事件模型必须保留来源语义
不同工具对同一概念使用 user、actor、principal、subject,对对象使用 repo、resource、entity。规范化层应提供统一查询字段,同时保留 source.raw 或原始对象引用。推荐最小模型如下:
{
"schema_version": 1,
"event_id": "<source-id-or-stable-hash>",
"occurred_at": "<iso-8601-utc>",
"received_at": "<iso-8601-utc>",
"source": {"product": "example-tool", "tenant": "tenant-demo", "raw_ref": "raw/<object-key>"},
"actor": {"id": "usr_demo_01", "type": "user", "auth": "sso"},
"action": {"name": "repository.visibility.update", "category": "configuration"},
"target": {"type": "repository", "id": "repo_demo_01", "name": "demo-audit-sandbox"},
"result": {"status": "denied", "reason": "insufficient_role"},
"correlation": {"request_id": "req_demo_01", "session_id": null},
"network": {"ip_hash": "<policy-approved-value>", "country": "ZZ"},
"integrity": {"raw_sha256": "<hex>", "collector": "collector-demo"}
}occurred_at 来自产品,received_at 来自采集器,两者差值用于判断延迟。所有时间统一为带时区的 UTC,原始精度不应被提前截断。actor.id 优先使用不可变标识,显示名只作展示;机器人、服务账号、应用、代理代表用户执行时应能区分主体类型和委托关系。
GitHub 的组织审计事件参考列出了 action、actor、actor_id、org、repo、request_id、token_id、operation_type 等事件字段。它们可映射到统一模型,但不是每类事件都有相同字段。规范化器必须允许缺失并保留事件类型,不能把不存在的 result 默认写成成功。
导出账号和存储先做最小权限隔离
采集器是高敏系统。读取凭据只允许访问审计 API,不能继承仓库写权限、组织管理员或账单权限;写入凭据只能追加到指定原始区,不能读取全部历史或删除对象。查询人员不应直接获得采集凭据,保留管理员也不应默认能检索人员活动内容。
collector:
identity: service-audit-exporter-demo
source_permissions:
- audit:read
destination_permissions:
- raw:append
- checkpoint:read-write
denied:
- repository:write
- identity:admin
- raw:delete
- retention:bypass
secrets:
provider: workload-identity
static_token: prohibited
rotation_owner: security-platform优先使用工作负载身份或短期凭据;确需静态 token 时,放入受控 secret store,限制来源网络、设置到期和轮换演练,日志中只记录 token 标识或哈希,绝不记录明文。采集器调试日志也要脱敏 Authorization 头、查询中的人员标识和原始响应。
正向权限实验让采集身份读取一页合成事件并写入专用前缀。反向实验让它尝试修改仓库或删除原始对象,预期分别得到权限拒绝。若读审计必须授予全局管理员,风险记录要说明产品限制,并通过独立账号、网络隔离、短期会话和调用审批降低暴露,不能把高权长期 token 放进普通 CI 变量。
游标分页和 checkpoint 决定是否会漏数
审计 API 常见页码、时间窗口和游标三种分页。页码在数据持续写入时容易产生重复或跳过;时间窗口会遇到相同时间戳和延迟到达;游标最适合顺序读取,但游标可能过期且只对当前查询条件有效。采集器需要同时保存产品游标、最后事件时间、最后事件 ID、查询参数哈希和成功写入批次。
GitHub 组织审计日志 REST 端点提供 after、before 游标、排序、过滤和每页数量等参数,游标来自响应的 Link 头。端点默认只返回有限数量以及有限时间窗口内的事件;要读取更早事件必须显式给出查询条件。实现时应跟随 Link,不能解析或自行拼接不透明游标,也不能把某次响应为空解释成“来源已经完整导出”。每个检查点都必须绑定租户、API 版本、事件类型、查询条件、排序方向和页大小,任一项变化都应创建新采集世代或从安全重叠窗口重放。
{
"source": "github-org-audit-demo",
"tenant": "org-demo",
"api_version": "<pinned-api-version>",
"include": "all",
"order": "asc",
"per_page": 100,
"query_hash": "sha256:<normalized-query>",
"cursor_after": "<opaque-cursor>",
"generation": 42,
"lease_fence": "collector-demo-0007",
"last_occurred_at": "<iso-8601-utc>",
"last_event_id": "<source-event-id>",
"batch_id": "batch-demo-00042",
"raw_object": "raw/org-demo/<partition>/batch-demo-00042.jsonl.gz",
"raw_sha256": "<hex>",
"committed_at": "<iso-8601-utc>"
}写入顺序必须是:拉取响应、持久化原始批次、计算摘要、验证对象可读、原子提交检查点。若先推进游标再写对象,进程崩溃会永久漏数;若对象写入成功但检查点未提交,下次会重复,去重层应能安全处理。
检查点还要防止两个采集器同时推进。采集器取得有期限的租约和单调递增的 fencing token,提交时用数据库事务、对象 ETag 或 compare-and-swap 同时比较 generation 与 lease_fence。过期实例即使网络恢复,也只能写入新的原始批次,不能覆盖新实例已经提交的检查点。原始对象键应包含不可变 batch_id,禁止两个实例覆盖同一键;孤儿批次由对账任务识别并重放,不能由采集器随手删除。
游标只能保证继续同一分页会话,不能替代迟到事件治理。周期任务应按来源允许的最小时间粒度回读一个重叠窗口,把本轮结果与已落库事件集合对账,再在窗口封账前比较事件数、首尾标识、查询条件和批次摘要。重叠窗口过短会漏掉延迟到达,过长会增加 API 配额和去重成本;长度应由实际迟到分布、来源保留窗口和故障恢复时长共同决定,而不是固定抄一个小时。来源窗口已经过期且没有原始副本时,只能登记不可恢复缺口,不能移动 checkpoint 假装追平。
反向实验在“原始对象写入后、checkpoint 提交前”注入退出。重启后预期重新读取同一批事件,原始区可能出现第二个批次,但规范化表按稳定 event_id 去重,且检查点最终只前进一次。若事件没有稳定 ID,可使用来源租户、事件时间、动作、主体、对象、请求 ID 和规范化原文摘要构造键,同时保留碰撞监控。
再增加一个并发反例:让采集器 A 取得第 42 代检查点后暂停,采集器 B 接管租约并提交第 43 代,再恢复 A。预期 A 的 compare-and-swap 失败并产生 stale-fence 指标,第 43 代检查点和 B 写入的对象都不被覆盖。若 A 仍能成功提交,单实例测试再稳定也无法证明故障切换不会倒退游标。
重试、限流和去重必须一起设计
网络超时无法证明服务端没有返回或接收请求。GET 导出通常可以重试,但要遵守 Retry-After、速率限制头和指数退避;认证失败、权限拒绝、查询语法错误不应无限重试。重试预算耗尽后,采集器进入失败状态并告警,不能跳过当前页继续推进。
const retryable = new Set([408, 429, 500, 502, 503, 504]);
async function fetchPage(url, token, attempt = 0) {
const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (response.ok) return response;
if (!retryable.has(response.status) || attempt >= 6) {
throw new Error(`audit-export-failed status=${response.status} attempt=${attempt}`);
}
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1000
: Math.min(60000, 1000 * 2 ** attempt) + Math.floor(Math.random() * 500);
await new Promise((resolve) => setTimeout(resolve, delayMs));
return fetchPage(url, token, attempt + 1);
}示例只展示控制逻辑,真实实现还要设置连接与响应超时、取消信号、代理和 CA、指标以及 token 脱敏。429 不是普通错误,它表示采集频率超过服务能力;应降低页频、缩小并发,必要时改用流式导出。401/403 连续出现通常是凭据到期、scope 改变或产品许可变化,应立刻阻断并通知 owner。
GitHub 的企业审计流采用至少一次交付,因此可能出现重复事件;其审计流说明还说明暂停和错误配置存在有限缓冲窗口。至少一次意味着消费者必须幂等,不意味着可以容忍缺口。应同时监控最后接收时间、批次数、重复率、解析失败率和来源健康检查。
完整性要能发现缺页、篡改和错误重放
TLS 和存储加密保护传输与静态数据,但不能单独证明导出文件未被有权账号改写。每个原始批次应保存字节摘要、对象版本、采集器身份、查询条件、起止事件和前一批摘要;每日生成清单,并由与采集账号分离的签名身份签名。
manifest-version: 1
partition: tenant=org-demo/date=<utc-date>
batch-count: 144
first-event: <stable-id>
last-event: <stable-id>
previous-manifest-sha256: <hex>
objects:
- key: raw/org-demo/<partition>/batch-demo-00042.jsonl.gz
size: <bytes>
sha256: <hex>
first-occurred-at: <iso-8601-utc>
last-occurred-at: <iso-8601-utc>
signature: <detached-signature-reference>验证任务重新计算摘要、检查对象版本、确认批次时间连续性,并抽样把规范化事件追溯到原始行。缺少批次不一定代表丢数,可能是该时间没有事件;因此还需要来源心跳或定期合成 canary 事件。canary 到期未出现时,告警应指出来源、最后成功检查点和缺失窗口。
供应商原生完整性功能也必须区分“开启”和“验证”。以 CloudTrail 日志文件完整性验证为例,启用后服务会交付带签名的 digest 文件,但这个动作本身不会执行验证;调查时仍要运行 aws cloudtrail validate-logs,核对签名链、摘要和缺失范围。官方 CLI 只验证 digest 引用且仍位于原始 S3 位置的日志,移动后的副本需要按公开格式另行验证。由此可见,采集任务成功、对象存在、摘要字段非空和供应商功能已开启都只是过程状态,只有验证器针对指定时间窗给出的链路结果才能进入取证记录。
NIST SP 800-92把日志基础设施、组织流程和持续维护视为整体。对工程团队而言,这意味着采集、传输、存储、分析和处置都要有 owner 与容量计划。完整性验证失败时,应冻结受影响分区的删除,保存对象版本和访问日志,不能由采集器自行“修复”历史文件。
检索权限按调查目的和数据敏感度拆分
审计日志包含人员标识、仓库名、IP、token 标识、客户端和失败原因,集中导出后敏感度往往高于单个工具。检索角色至少拆成平台运维、调查员、合规读取和保留管理员:平台运维看采集健康但不默认看事件正文;调查员按工单和时间窗口检索;保留管理员改变期限或 legal hold,但不自动获得全部查询权。
access_request:
ticket: CASE-DEMO-009
requester: investigator-demo
purpose: permission-change-review
scope:
tenants: [org-demo]
actions: [team.add_member, repository.visibility.update]
from: "<iso-8601-utc>"
to: "<iso-8601-utc>"
fields:
actor_ip: masked
token_id: hashed
expires_at: "<iso-8601-utc>"
approver: security-duty-role查询本身也要审计:谁查了什么条件、返回多少记录、是否导出、导出文件何时删除。高敏字段默认遮蔽,仅在调查理由和权限同时满足时解封。批量导出应写入临时受控区,设置短到期时间和下载次数限制,禁止通过聊天附件或个人网盘传播。
正向实验让调查角色在批准时间窗内查询两类动作,预期只看到脱敏字段。反向实验扩大到全部租户或请求原始 token 字段,预期策略拒绝并留下拒绝事件。管理员在 UI 中能看见并不代表自动化账号也应拥有同等权限。
retention、legal hold 和 delete 是三个状态机
保留期回答数据至少或至多保存多久;legal hold 回答特定调查或法律事项期间哪些对象不得删除;删除回答条件满足后怎样实际清除主副本、索引、缓存、临时导出和密钥引用。三者不能合并成一个 retention_days 字段。
retention_policy:
dataset: tool-audit-normalized
owner: security-governance
basis: internal-security-investigation
hot: 30d
searchable: 180d
archive: 730d
delete_after: 730d
deletion_review: required
legal_hold:
enabled: true
key: event_id
release_requires: [legal-role, records-role]
copies:
- raw-object-store
- normalized-index
- investigation-export这些数字只是合成配置,不是法律建议或通用期限。实际期限由合同、地区法规、劳动与隐私要求、事故响应需求和成本共同决定,并由相应责任角色批准。产品自身保留可能更短:GitHub 说明企业审计事件通常只在产品中保留有限时间,Git 事件的窗口更短;当前能力和计划差异应在审计日志访问说明中重新确认。外部导出必须早于最短来源窗口运行。
使用 WORM 存储时也要理解语义。Amazon S3 Object Lock只在启用版本控制的桶中工作,固定 retention 与无固定到期的 legal hold 相互独立,且二者保护的都是请求指定的对象版本;同名新版本和 delete marker 仍可继续产生。Governance 模式可由同时具备 s3:BypassGovernanceRetention 并显式发送绕过头的主体越过,控制台还可能自动带上该请求头;Compliance 模式在期限内约束更强。无版本 ID 的简单删除即使返回 200 OK,也可能只是创建 delete marker,不能据此认定受保护版本已删除。落地验收要用两个普通角色和一个受控绕过角色分别执行覆盖、简单删除、指定版本删除和缩短期限实验,再逐版本读取 retention、hold 与可见性;采用其他存储也要验证等价语义,不能把“启用保留”直接写成“数据不可变”。
删除要有候选清单、阻断条件和完成证明
到期任务先生成候选清单,不应直接删除。每个候选对象检查期限、legal hold、调查引用、复制状态、对象版本和删除权限。存在 hold、期限未满、清单签名失败或副本未知时必须拒绝。删除执行者与批准者分离,绕过不可变保留的权限默认不授予日常账号。
# dry-run 删除候选检查;只读取合成清单,不连接真实存储
$items = Get-Content .\audit-delete-candidates.demo.json | ConvertFrom-Json
$blocked = $items | Where-Object {
$_.legalHold -eq $true -or
$_.retainUntil -gt (Get-Date).ToUniversalTime() -or
$_.manifestVerified -ne $true -or
$_.copiesKnown -ne $true
}
if ($blocked.Count -gt 0) {
$blocked | Select-Object objectId, legalHold, retainUntil, manifestVerified, copiesKnown
throw "audit-delete-blocked"
}
$items | Select-Object objectId, versionId, deleteAfter | ConvertTo-Json正向实验准备一个已到期、无 hold、清单有效且副本已知的合成对象,预期进入待批准列表但不立即删除。反向实验把 legalHold 设为 true,预期脚本非零退出。真正执行后,不能只对对象键执行一次读取:要枚举并核对清单中的每个 versionId、delete marker、复制目标、搜索索引、缓存和临时导出,确认目标版本确实不存在或按策略仍受保护;还要复核生命周期任务没有留下失败队列。删除证明只保留策略 ID、对象与版本数量、清单摘要、批准人、执行结果和复核时间,不复制事件正文。导出成功、对象不可见和删除 API 返回成功分别只是过程证据,都不能单独证明数据完整或删除完成。
legal hold 解除也不能触发即时盲删。解除动作要记录事项编号、批准人和时间,随后重新计算普通保留期;如果对象尚未到期,继续保留。密钥销毁可作为某些加密副本的删除手段,但必须证明所有副本都依赖该密钥且没有明文缓存,不能用“删了密钥”替代实际验证。
容量、成本和故障恢复决定长期可用性
日志量会随成员、自动化和 Git 活动增长。容量模型至少估算事件率、平均压缩后大小、原始与规范化副本、索引倍率、查询并发和保留层级。把所有数据长期放在高性能检索层成本很高;常见取舍是短期热索引、较长期可搜索归档和更长原始对象,但每层都要能从事件 ID 追溯。
监控指标包括采集延迟、来源游标年龄、最后成功时间、429/401/403、重试次数、重复率、解析隔离队列、清单验证失败、对象存储增长、查询延迟、hold 数量和到期删除积压。容量告警要早于磁盘满或配额耗尽,不能等采集器写失败后才处理。
灾难恢复演练应从原始对象和清单重建规范化索引,抽样核对事件数、摘要和查询结果。检查点库丢失时,使用最后签名清单与重叠时间窗重放,依赖幂等键消除重复。若来源窗口已经过期且没有原始副本,缺口只能被记录,不能伪造补齐。
重建必须写入新的索引世代,完成前不替换线上查询别名。新世代按分区验证对象数、事件数、去重数、解析隔离数、首尾事件和随机摘要;这些不变量与旧世代或签名清单相符后,才能原子切换查询入口。反向演练故意移除一个原始批次,预期重建停在 incomplete 并指出缺失对象,而不是从剩余数据生成一个看似健康的新索引。旧索引只在切换观察期结束、调查引用清零且删除策略允许后清理,否则恢复过程会把存储成本永久翻倍。
团队运行与退出要保留最后一段证据
工具 owner 维护事件字典和产品保留差异;平台 owner 维护采集器、检查点和容量;安全 owner 定义检索与告警;记录或法务角色决定期限和 hold;隐私角色审查人员字段与删除请求。角色交接要包含凭据、查询、保留策略和未解决缺口,而不是只转移仪表盘。
退出某个工具前,先记录最后可用导出时间和来源窗口,停止新的管理变更,完成最终全量或增量导出,校验清单,再撤销采集凭据和流式目标。保留数据继续按既有策略运行,不能因产品停用立即删除,也不能无限期遗忘。最终验证包括来源不再产生事件、采集器不再重试计费、凭据已撤销、hold 仍生效、到期删除任务仍可执行。
审计链路检查单
成功与拒绝事件都能映射到 actor、action、target、result,并保留原始引用。采集身份只有审计读取和目标追加权限,不能改业务对象或删除历史。游标、查询条件、最后事件和原始批次一起形成原子 checkpoint。
超时重放不会丢数,稳定事件键能去重,429 与权限错误不会被无限重试。每个批次有摘要、对象版本和链式清单,canary 能发现静默采集缺口。查询按工单、租户、时间和字段授权,查询与二次导出同样被审计。
保留、legal hold 与删除分别建模,来源最短窗口早于导出周期。删除先生成候选并 dry-run,hold、期限、清单或副本不明会阻断。容量、延迟、积压和成本有人负责,原始数据能够重建检索层。
工具退出后,最终导出、凭据撤销、保留延续和到期删除都有完成证据。
