可验证报告复核修正(第 1 轮):运行信息拿已核对的事件对照,报告徽标连同数字一起算,遮罩判定宁严勿宽,各分支在浏览器里有确定的检查

- 页面核对加 ⑥ 运行信息:运行结果看封存范围内最后一条结束类事件的类型,运行类别看运行开始事件记下的运行参数,
  出具档位认最后一条出具判定(不再信元信息指的那一条);页面显示事件里的值,元信息写得不一样算不通过、计入总览。
  工作流名、版本、起止时间、节点名称绑不进封存,单列一组写明取自运行记录,封存说明里也写明要到系统里一并对照
- 报告标题旁的徽标连同正文里的数字一起算,旁边写分项:重算全部哈希的伪造只在 ⑤ 露馅,徽标不能还写着通过。
  分块条目多时不通过和无法核对的都列在外面,有无法核对的显示「部分无法核对」和分项计数;⑤ 改叫「数字与取值」并分开数,
  读屏分别读作数字、取值,「显示格式由服务端渲染」只附在带出处的数字上;无出处数字的说法改成未使用引用标记
- 服务端不会给出的状态算不通过:被引用的工件不在清单里、非查询快照标着遮罩、工件既没内容也没说明;报告文档导出时
  已不在服务端,总览不再是绿色的通过,写「有 N 份报告未能核对」,报告处写明是服务端已没有这份文档
- 认 json.dumps 写出的裸 NaN / Infinity,没被改过的文件不再误判不通过;哈希对得上但读不懂的照实写「无法核对」,
  和哈希不一致分开说
- 宽屏打开出处面板时正文让出右边一栏,表格里右对齐的数字不再被面板盖住;补上斜体(不用后行断言,较早的 Safari 也能执行)
- 封存哈希、运行 ID、工作流名直接写进页面,不执行脚本时写「尚未核对」而不是一直「正在核对」;脚本挪进 head,
  执行后核一遍页面顶部写出的封存哈希和数据里核对用的一致
- 成果节点和运行结束事件的规范字符串,只在封存范围内每次数据源查询都对得上无遮罩的快照(或确实没交回数据)时才给:
  升级前的运行从工具快照认出查询快照,协作团队成员的查询认不出就按带遮罩算,数据源现在设了遮罩的按工具名就挡
- 封存核对不一致的 409 改成一句固定的话,不再叠冒号、让人去本来就在的记录页;失败结束的运行在页面封存说明和导出提示里
  写明之后继续运行会重新封存;记录页上左边写着「已封存」却不能导出时,原因写在屏幕上
- tests/bundle_samples.py 用虚构数据真实生成九份样本页(遮罩快照、两份报告、早期运行、失败结束、两条出具判定、
  服务端缺文件、裸 NaN、别的运行的文档),check-bundle 断网打开逐项断言,在上面篡改引用链、运行信息、拿掉被引用的口径卡,
  量宽屏面板,看不执行脚本的样子;把核对改成恒通过、徽标不看数字等七种改坏法都会变红。pytest 补遮罩判定的三种形状、
  事后设遮罩(去掉接线即变红)、页面静态内容和样本页本身
This commit is contained in:
ThearchyHelios
2026-10-05 05:30:50 +08:00
parent 183814d2c1
commit e7c8d2a1f3
12 changed files with 1296 additions and 228 deletions
+1 -1
View File
@@ -96,7 +96,7 @@ const ESTIMATE = {
stream: 173, chat: 160, studio: 149, manage: 145, runs: 112, publish: 92, shell: 87,
evidence: 91, versions: 60, catalog: 45, 'ui-kit': 70, ui: 61, guards: 8, 'canvas-layout': 2, copy: 2, tokens: 1, trace: 1, decode: 1,
// 占位,换成真检查时按实测改
schedules: 1, compare: 1, verify: 1, inquiry: 1, bundle: 10, whatif: 1,
schedules: 1, compare: 1, verify: 1, inquiry: 1, bundle: 16, whatif: 1,
}
// 最慢的两项按段拆成几份。用的是它们自己的段过滤:最后一份用 *_SKIP 兜住「其余全部」,以后新加的段
// 不用改这里也跑得到(拆出去的那几份要是一段都没对上,那一份会报「一项都没跑」)。
+421 -88
View File
@@ -1,32 +1,42 @@
// 可验证报告的检查:真服务端导出、断网打开核对、篡改变红,外加记录页上的导出按钮。
// 可验证报告的检查:真服务端导出、断网打开核对、篡改变红;虚构样本页上的各种形状;外加记录页上的导出按钮。
//
// 跑之前前后端都得起着(check-all 另起的那套,或者 ./scripts/dev.sh)。默认连 5273 / 8000;对别的实例跑时带上地址:
// AGENTLAB_WEB=http://localhost:<前端端口> AGENTLAB_API=http://localhost:<后端端口>/api node scripts/check-bundle.mjs
// 样本页用后端解释器生成(CHECK_PYTHON,默认 ~/miniforge3/envs/agentlab/bin/python),从这份工作区的 backend 导入。
//
// 分几段:
// - 导出:从服务端找一份已封存、封存核对一致、有报告文档的运行(GET /api/runs 找成功结束的,再看 /verify 和证据接口),
// 下载 bundle.html:状态码、类型、附件名(RFC 5987)、页面本身零外链。找不到可导出的运行时照实报失败并说明原因;
// - 断网打开:拦掉一切网络请求,从本地文件打开:五项核对全部通过、总览通过、没有发出任何请求,数字点开看得到出处和核对结果;
// - 断网打开:拦掉一切网络请求,从本地文件打开:各项核对没有不通过、总览通过、没有发出任何请求,数字点开看得到出处;
// - 去掉 crypto.subtle:页面加载前删掉它,照常全部通过,页脚写明用的是本页内置的实现;
// - 篡改:① 一条带规范字符串的事件内容、② 一份嵌入工件的一个字节、③ 元信息里的封存哈希,各自对应的核对项不通过、
// 总览显示不通过;另做一次「哈希全部重算」的伪造:改报告里一个数字的引用值,文档、事件、清单的哈希都重算一遍,
// ①–④ 都对得上,只有 ⑤ 拿口径卡、快照对出来不通过(这也是页面要人去系统里对照封存哈希的原因);
// 总览显示不通过;另做一次「哈希全部重算」的伪造:改报告里一个数字的引用值,文档、事件、清单、页面顶部的封存哈希
// 都重算一遍,①–④ 都对得上,只有 ⑤ 拿口径卡、快照对出来不通过,报告标题旁的徽标跟着不通过;
// - 深浅色、窄屏:390 宽不横向溢出,点开的数字面板在窄屏上从底部抽出,深色下底是深的、字是浅的;
// - 样本页:tests/bundle_samples.py 用虚构数据真实生成的几份页面(遮罩快照、两份报告、早期运行、失败结束、两条出具
// 判定、服务端缺文件、裸 NaN、别的运行的文档),逐份断言各项结果和原因文案;在上面篡改 ④ 引用链(换文档、拿掉报告、
// 多一份没人引用的工件)、⑥ 运行信息(运行结果、类别、出具档位)、拿掉被引用的口径卡、把口径卡标成遮罩、只改页面顶部
// 的封存哈希;宽屏点开表格里右对齐的数字不被面板盖住;不执行脚本时封存哈希、运行 ID 照样看得到,状态写「尚未核对」;
// - 记录页的导出按钮(预览用例 ?syn=bundle-provenance-*,page.route 假装导出接口):已封存时可点、下载的文件名同服务端;
// 未封存时不可点并说明原因;服务端回 409 时提示原因。
// 未封存时不可点并说明原因(左边写着「已封存」却不能点时原因写在屏幕上);服务端回 409 时提示原因;失败结束的运行
// 提示之后继续运行会重新封存。
//
// 读的是真数据:输出里不打印工作流名、报告内容和数字,只打印计数和运行号前 8 位。下载的文件写在临时目录,跑完删掉。
// 导出接口只读(不追加事件、不改运行),页面检查里的写接口一律是假的。
import { spawnSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
import { homedir, tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
const API = process.env.AGENTLAB_API ?? 'http://localhost:8000/api'
const CHROME = process.env.CHROME_PATH ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
const BACKEND = join(dirname(dirname(fileURLToPath(import.meta.url))), 'backend')
const PYTHON = [process.env.CHECK_PYTHON, join(homedir(), 'miniforge3/envs/agentlab/bin/python')]
.find((p) => p && existsSync(p))
let failed = 0
const check = (name, cond, detail = '') => {
@@ -59,11 +69,15 @@ async function section(name, fn) {
// ---------------------------------------------------------------- 页面数据的读写(篡改用)
const DATA_RE = /(<script type="application\/json" id="bundle-data">)([\s\S]*?)(<\/script>)/
const SEAL_RE = /(<code id="seal-hash" class="hash">)([^<]*)(<\/code>)/
const dataOf = (html) => JSON.parse(DATA_RE.exec(html)[2])
// 和服务端 bundle_json 一样转义 < > & 和 U+2028 / U+2029,改过的数据照样截不断数据块
const escapeJson = (obj) => JSON.stringify(obj)
.replace(/[<>&\u2028\u2029]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`)
const withData = (html, obj) => html.replace(DATA_RE, (_, open, __, close) => open + escapeJson(obj) + close)
/** 只改页面顶部静态写出的封存哈希(不执行脚本也看得到的那一份) */
const withSeal = (html, hash) => html.replace(SEAL_RE, (_, open, __, close) => open + hash + close)
const sha = (data) => createHash('sha256').update(data).digest('hex')
function writePage(name, html) {
const file = join(TMP, name)
@@ -102,18 +116,81 @@ const stateOf = (page) => page.evaluate(() => ({
overall: document.getElementById('overall')?.getAttribute('data-overall'),
text: document.getElementById('overall-text')?.textContent ?? '',
checks: Object.fromEntries([...document.querySelectorAll('[data-check]')].map((c) => [c.dataset.check, c.dataset.result])),
sums: Object.fromEntries([...document.querySelectorAll('[data-check]')].map((c) => [c.dataset.check,
c.querySelector('.check-sum')?.textContent ?? ''])),
failsIn: Object.fromEntries([...document.querySelectorAll('[data-check]')].map((c) => [c.dataset.check,
new Set([...c.querySelectorAll('li[data-result="fail"]')].map((li) => li.textContent)).size])),
// 每一条核对:在哪一块、结果、文字(含原因)、是不是收在「展开全部」里
items: [...document.querySelectorAll('[data-check] li[data-result]')].map((li) => ({
check: li.closest('[data-check]').dataset.check, result: li.dataset.result, text: li.textContent,
folded: !!li.closest('details'),
})),
reports: [...document.querySelectorAll('article.report')].map((a) => ({
node: a.dataset.report, result: a.dataset.result, parts: a.querySelector('[data-report-parts]')?.textContent ?? '',
warn: a.querySelector('.warn')?.textContent ?? '', why: a.querySelector('.warn')?.dataset.why ?? '',
})),
meta: Object.fromEntries([...document.querySelectorAll('[data-meta]')].map((m) => [m.dataset.meta,
{ result: m.dataset.result, text: m.textContent }])),
numbers: document.querySelectorAll('button.num').length,
numberStates: [...document.querySelectorAll('button.num')].reduce((acc, b) => {
acc[b.dataset.result] = (acc[b.dataset.result] ?? 0) + 1
return acc
}, {}),
sha: document.querySelector('[data-sha]')?.dataset.sha ?? null,
seal: document.getElementById('seal-hash')?.textContent ?? '',
overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
}))
const describe = (s) => Object.entries(s.checks).map(([k, v]) => `${k}=${v}`).join(' ')
/** 有通过、没有不通过:「部分无法核对」也算(遮罩快照、没有出处的数字是正常的无法核对) */
const noFail = (r) => r === 'pass' || r === 'partial'
const hasItem = (s, check, result, text) => s.items.some((i) => i.check === check && i.result === result
&& (!text || i.text.includes(text)))
/**
* 「哈希全部重算」的伪造:改报告里一个能核对的数字的引用值,再把文档、引用它的事件、清单哈希、页面顶部的封存哈希
* 全部重算。返回改了哪一处的说明,找不到可改的数字返回 null
*/
function forge(data) {
const docArt = data.artifacts.find((a) => a.kind === 'report_doc' && typeof a.b64 === 'string')
if (!docArt) return null
const doc = JSON.parse(Buffer.from(docArt.b64, 'base64').toString('utf8'))
const embedded = new Set(data.artifacts.filter((a) => typeof a.b64 === 'string').map((a) => a.id))
let hit = null
for (const block of doc.blocks ?? []) for (const unit of block.units ?? []) for (const seg of unit.segments ?? []) {
const c = seg.cite
if (!hit && c?.status === 'resolved' && ['metric', 'cell'].includes(c.kind) && typeof c.value === 'number'
&& embedded.has(doc.catalog?.[c.alias]?.artifact)) hit = seg
}
if (!hit) return null
hit.cite.value += 1
const bytes = Buffer.from(JSON.stringify(doc), 'utf8')
const oldId = docArt.id
const newId = sha(bytes)
docArt.id = newId
docArt.b64 = bytes.toString('base64')
for (const r of data.reports) if (r.doc_artifact === oldId) r.doc_artifact = newId
for (const ev of data.events) {
if (typeof ev.canonical === 'string' && ev.canonical.includes(oldId)) {
ev.canonical = ev.canonical.split(oldId).join(newId)
ev.data_sha256 = sha(ev.canonical)
}
}
const lines = data.events.map((e, i) => [e, i]).sort((x, y) => x[0].seq - y[0].seq || x[1] - y[1])
.map(([e]) => `${e.seq}|${e.type}|${e.node_id ?? ''}|${e.data_sha256}`)
data.meta.manifest_hash = sha(lines.join('\n'))
return { what: `片段 ${hit.id}(${hit.cite.kind})`, node: data.reports.find((r) => r.doc_artifact === newId)?.node_id }
}
/** 改一份嵌入工件的一个字节(优先改数字),返回说明 */
function flipByte(art) {
const bytes = Buffer.from(art.b64, 'base64')
const at = bytes.findIndex((b) => b >= 0x30 && b <= 0x39)
const index = at >= 0 ? at : bytes.length - 2
bytes[index] = at >= 0 ? (bytes[index] === 0x39 ? 0x30 : bytes[index] + 1) : bytes[index] ^ 0x01
art.b64 = bytes.toString('base64')
return `${art.kind} ${art.id.slice(0, 8)} 的第 ${index} 字节`
}
// ---------------------------------------------------------------- 导出
@@ -188,6 +265,7 @@ await section('导出', async () => {
&& !/<script[^>]*\ssrc\s*=|<link\b|<img\b|@import|url\s*\(/i.test(shell))
check('内容安全策略默认不许连任何地方', /default-src 'none'/.test(shell))
check('数据块里没有裸的 < > &(截不断)', !/[<>&]/.test(DATA_RE.exec(html)?.[2] ?? '<'))
check('封存哈希直接写在页面上(不执行脚本也看得到)', SEAL_RE.exec(html)?.[2] === run.manifest_hash)
const after = await getJson(`/runs/${run.id}/events`).catch(() => null)
check('导出不追加事件', !!before && !!after && before.length === after.length, before && after ? `${before.length} → ${after.length} 条` : '')
// 没封存的,或者还没结束的(失败后接着跑、早年停在审批时封存过的运行留着旧的封存):都该回 409
@@ -221,30 +299,33 @@ await section('断网打开', async () => {
try {
const s = await stateOf(page)
check('总览通过', s.overall === 'pass', `${s.overall}:${s.text}`)
for (const key of ['events', 'manifest', 'artifacts', 'chain']) {
check(`核对项「${key}」通过`, s.checks[key] === 'pass', s.checks[key])
}
check('数字核对没有不通过的', s.checks.numbers === 'pass' || s.checks.numbers === 'na',
for (const key of ['events', 'manifest', 'chain']) check(`核对项「${key}」通过`, s.checks[key] === 'pass', s.checks[key])
// 遮罩快照、服务端已没有文件的工件是正常的无法核对;没有出处的数字同理
check('核对项「artifacts」没有不通过', noFail(s.checks.artifacts), s.checks.artifacts)
check('数字与取值没有不通过的', noFail(s.checks.numbers) || s.checks.numbers === 'na',
`${s.checks.numbers}:${JSON.stringify(s.numberStates)}`)
// 早期运行的运行结果、升级前的运行类别核对不了(无法核对),只要求没有对不上的
check('运行信息没有与已核对的事件对不上的', s.checks.meta !== 'fail', `${s.checks.meta}:${JSON.stringify(s.meta)}`)
check('没有发出任何网络请求', requests.length === 0, requests.slice(0, 3).join('、'))
check('页面没有报错', errors.length === 0, errors.slice(0, 2).join(' | '))
check('用的是浏览器内置的 SHA-256', s.sha === 'subtle', String(s.sha))
check('封存哈希醒目地写在页面上', await page.locator('#seal-hash').innerText() === tamperable.meta.manifest_hash)
check('封存哈希醒目地写在页面上', s.seal === tamperable.meta.manifest_hash)
// 页面列出的清单原文:拿出去用别的工具(这里是 node 的 crypto)算,等于封存哈希——读的人可以不信页面上的脚本
const lines = await page.locator('[data-manifest-lines]').textContent()
check('页面列出的清单原文在页面之外重算,等于封存哈希',
createHash('sha256').update(lines ?? '', 'utf8').digest('hex') === tamperable.meta.manifest_hash)
check('页面写明只证明内部一致、真伪要去系统里对照', /内部一致/.test(await page.locator('#seal-note').innerText())
&& /\/api\/runs\/[^/]+\/verify/.test(await page.locator('#seal-note').innerText()))
check('页面列出的清单原文在页面之外重算,等于封存哈希', sha(lines ?? '') === tamperable.meta.manifest_hash)
const sealNote = await page.locator('#seal-note').innerText()
check('页面写明只证明内部一致、真伪要去系统里对照', /内部一致/.test(sealNote) && /\/api\/runs\/[^/]+\/verify/.test(sealNote))
check('页面写明哪几项取自运行记录、不在封存范围内', /取自运行记录/.test(await page.locator('#record-note').innerText()))
if (s.numbers) {
const first = page.locator('button.num[data-result="pass"]').first()
const first = page.locator('button.num[data-kind="number"][data-result="pass"]').first()
const target = (await first.count()) ? first : page.locator('button.num').first()
const key = await target.getAttribute('data-num')
await target.click()
await page.locator('#detail').waitFor({ state: 'visible', timeout: 5000 })
const text = await page.locator('#detail').innerText()
check('点开数字看得到出处和核对结果', /出处/.test(text) && /核对结果/.test(text) && /显示格式由服务端渲染/.test(text)
&& await page.locator('#detail').getAttribute('data-open') === key)
const cited = await target.getAttribute('data-kind') === 'number'
check('点开数字看得到出处和核对结果', /出处/.test(text) && /核对结果/.test(text)
&& (!cited || /显示格式由服务端渲染/.test(text)) && await page.locator('#detail').getAttribute('data-open') === key)
await page.keyboard.press('Escape')
check('Esc 关掉出处面板', await page.locator('#detail').isHidden())
} else {
@@ -270,86 +351,63 @@ await section('去掉 crypto.subtle', async () => {
// ---------------------------------------------------------------- 篡改
async function tampered(label, mutate, expectFail, keepPass = []) {
const data = structuredClone(tamperable)
/**
* 在一份页面上篡改后断网打开。mutate(data) 改数据、返回说明(找不到可改的返回 null);expect(state) 返回
* [[检查名, 条件, 说明]…]。seal:同时把页面顶部的封存哈希换成改过的数据里的那一份(重算全部哈希的伪造会这么做)
*/
async function tamper(source, label, mutate, expect, { seal = false } = {}) {
const data = structuredClone(dataOf(source))
const what = mutate(data)
if (!what) {
check(`篡改:${label}`, false, '这份导出里找不到可以篡改的对象')
check(`篡改:${label}`, false, '这份页面里找不到可以篡改的对象')
return
}
const file = writePage(`tampered-${expectFail}.html`, withData(html, data))
const { ctx, page } = await openFile(file)
let page = withData(source, data)
if (seal) page = withSeal(page, data.meta.manifest_hash)
const opened = await openFile(writePage(`tampered-${label.replace(/[^\w一-鿿]+/g, '_')}.html`, page))
try {
const s = await stateOf(page)
check(`篡改${label}:「${expectFail}」不通过、总览不通过`, s.checks[expectFail] === 'fail' && s.overall === 'fail'
&& s.failsIn[expectFail] >= 1, `${what};${describe(s)}`)
for (const key of keepPass) check(`篡改${label}:「${key}」仍通过`, s.checks[key] === 'pass', s.checks[key])
const s = await stateOf(opened.page)
for (const [name, cond, detail] of expect(s, what)) check(`篡改${label}:${name}`, cond, detail ?? `${typeof what === 'string' ? what : what.what};${describe(s)}`)
} finally {
await ctx.close()
await opened.ctx.close()
}
}
/** 一项变红、总览不通过,另几项仍通过 */
const turnsRed = (key, keepPass = []) => (s) => [
[`「${key}」不通过、总览不通过`, s.checks[key] === 'fail' && s.overall === 'fail' && s.failsIn[key] >= 1],
...keepPass.map((k) => [`「${k}」仍没有不通过`, noFail(s.checks[k]), s.checks[k]]),
]
await section('篡改', async () => {
if (skipped() || !tamperable) return
// ① 改一条带规范字符串的事件内容(不动它记的哈希):只有 ① 变红,清单照样对得上
await tampered('一条事件的内容', (data) => {
await tamper(html, '一条事件的内容', (data) => {
const ev = data.events.find((e) => e.type === 'run.started' && typeof e.canonical === 'string')
?? data.events.find((e) => typeof e.canonical === 'string')
if (!ev) return null
ev.canonical = /\d/.test(ev.canonical) ? ev.canonical.replace(/\d/, (d) => String((Number(d) + 1) % 10)) : `${ev.canonical} `
return `第 ${ev.seq} 条(${ev.type})`
}, 'events', ['manifest'])
}, turnsRed('events', ['manifest']))
// ② 改一份嵌入工件的一个字节:优先口径卡、快照,没有就改报告文档
await tampered('一份工件的一个字节', (data) => {
await tamper(html, '一份工件的一个字节', (data) => {
const embedded = data.artifacts.filter((a) => typeof a.b64 === 'string')
const art = embedded.find((a) => a.kind !== 'report_doc') ?? embedded[0]
if (!art) return null
const bytes = Buffer.from(art.b64, 'base64')
const at = bytes.findIndex((b) => b >= 0x30 && b <= 0x39)
const index = at >= 0 ? at : bytes.length - 2
bytes[index] = at >= 0 ? (bytes[index] === 0x39 ? 0x30 : bytes[index] + 1) : bytes[index] ^ 0x01
art.b64 = bytes.toString('base64')
return `${art.kind} ${art.id.slice(0, 8)} 的第 ${index} 字节`
}, 'artifacts', ['events', 'manifest'])
// 伪造:改报告里一个数字的引用值,再把文档、引用它的事件、清单的哈希全部重算
await tampered('一个数字的引用值并重算全部哈希', (data) => {
const sha = (text) => createHash('sha256').update(text).digest('hex')
const docArt = data.artifacts.find((a) => a.kind === 'report_doc' && typeof a.b64 === 'string')
if (!docArt) return null
const doc = JSON.parse(Buffer.from(docArt.b64, 'base64').toString('utf8'))
const embedded = new Set(data.artifacts.filter((a) => typeof a.b64 === 'string').map((a) => a.id))
let hit = null
for (const block of doc.blocks ?? []) for (const unit of block.units ?? []) for (const seg of unit.segments ?? []) {
const c = seg.cite
if (!hit && c?.status === 'resolved' && ['metric', 'cell'].includes(c.kind) && typeof c.value === 'number'
&& embedded.has(doc.catalog?.[c.alias]?.artifact)) hit = seg
}
if (!hit) return null
hit.cite.value += 1
const bytes = Buffer.from(JSON.stringify(doc), 'utf8')
const oldId = docArt.id
const newId = sha(bytes)
docArt.id = newId
docArt.b64 = bytes.toString('base64')
for (const r of data.reports) if (r.doc_artifact === oldId) r.doc_artifact = newId
for (const ev of data.events) {
if (typeof ev.canonical === 'string' && ev.canonical.includes(oldId)) {
ev.canonical = ev.canonical.split(oldId).join(newId)
ev.data_sha256 = sha(ev.canonical)
}
}
const lines = data.events.map((e, i) => [e, i]).sort((x, y) => x[0].seq - y[0].seq || x[1] - y[1])
.map(([e]) => `${e.seq}|${e.type}|${e.node_id ?? ''}|${e.data_sha256}`)
data.meta.manifest_hash = sha(lines.join('\n'))
return `片段 ${hit.id}(${hit.cite.kind})`
}, 'numbers', ['events', 'manifest', 'artifacts', 'chain'])
return art ? flipByte(art) : null
}, turnsRed('artifacts', ['events', 'manifest']))
// 伪造:改报告里一个数字的引用值,再把文档、引用它的事件、清单、页面顶部的封存哈希全部重算
await tamper(html, '一个数字的引用值并重算全部哈希', forge, (s, forged) => [
...turnsRed('numbers', ['events', 'manifest', 'artifacts', 'chain'])(s),
['那份报告标题旁的徽标跟着不通过', s.reports.find((r) => r.node === forged.node)?.result === 'fail',
JSON.stringify(s.reports.map((r) => [r.node, r.result]))],
], { seal: true })
// ③ 改元信息里的封存哈希
await tampered('元信息里的封存哈希', (data) => {
await tamper(html, '元信息里的封存哈希', (data) => {
const h = data.meta.manifest_hash
if (typeof h !== 'string' || !h) return null
data.meta.manifest_hash = h.slice(0, -1) + (h.endsWith('0') ? '1' : '0')
return '最后一位'
}, 'manifest', ['events', 'artifacts'])
}, turnsRed('manifest', ['events', 'artifacts']))
})
// ---------------------------------------------------------------- 深浅色、窄屏
@@ -391,9 +449,263 @@ await section('深浅色与窄屏', async () => {
}
})
// ---------------------------------------------------------------- 样本页
/** tests/bundle_samples.py 生成的页面:{名字: HTML}。生成不了时抛出,原因写清 */
function samplePages() {
if (!PYTHON) throw new Error('找不到后端解释器,用 CHECK_PYTHON 指定')
const out = join(TMP, 'samples')
const r = spawnSync(PYTHON, ['-m', 'tests.bundle_samples', out], {
cwd: BACKEND, encoding: 'utf8', timeout: 120_000,
// 从这份工作区的 backend 导入;导入时会建数据目录,指到临时目录里
env: { ...process.env, PYTHONPATH: BACKEND, AGENTLAB_DATA_DIR: join(TMP, 'py-data'), AGENTLAB_SECRET_KEY: 'check-bundle-samples' },
})
if (r.status !== 0) throw new Error(`生成样本页失败:${(r.stderr || String(r.error ?? '')).trim().split('\n').pop()}`)
return Object.fromEntries(readdirSync(out).filter((f) => f.endsWith('.html'))
.map((f) => [f.replace(/\.html$/, ''), readFileSync(join(out, f), 'utf8')]))
}
let samples = null
/** 断网打开一份样本页,交给 fn(state, page) 断言 */
async function sample(name, fn, opts = {}) {
const { ctx, page, requests, errors } = await openFile(writePage(`sample-${name}.html`, samples[name]), opts)
try {
const s = await stateOf(page)
if (requests.length || errors.length) check(`${name}:没有发出请求、没有报错`, false, [...requests, ...errors].slice(0, 2).join(' | '))
await fn(s, page)
} finally {
await ctx.close()
}
}
await section('样本页:各种形状', async () => {
try {
samples = samplePages()
} catch (e) {
check('生成样本页(tests/bundle_samples.py)', false, String(e?.message ?? e))
return
}
check('生成样本页(tests/bundle_samples.py)', Object.keys(samples).length === 9, Object.keys(samples).join('、'))
await sample('base', async (s, page) => {
check('base:总览通过,有无法核对的项时写「可核对的项全部通过」', s.overall === 'pass' && s.text === '可核对的项全部通过',
`${s.overall}:${s.text}`)
check('base:①②④⑥ 通过,③⑤ 部分无法核对', ['events', 'manifest', 'chain', 'meta'].every((k) => s.checks[k] === 'pass')
&& s.checks.artifacts === 'partial' && s.checks.numbers === 'partial', describe(s))
check('base:遮罩快照在 ③ 无法核对,写明「含遮罩列,未随文件附带,只能核对到哈希」',
hasItem(s, 'artifacts', 'na', '含遮罩列,未随文件附带,只能核对到哈希'))
check('base:引用遮罩快照的数字无法核对,原因同上', hasItem(s, 'numbers', 'na', '含遮罩列,未随文件附带,只能核对到哈希'))
check('base:没有出处的数字写明未使用引用标记', hasItem(s, 'numbers', 'na', '该数字未使用引用标记,无法核对其来源'))
const outside = s.items.filter((i) => i.check === 'numbers' && !i.folded)
check('base:⑤ 超过 20 项收起来时,无法核对的列在外面', outside.length === 2 && outside.every((i) => i.result === 'na')
&& s.items.some((i) => i.check === 'numbers' && i.folded), `外面 ${outside.length} 条`)
check('base:⑤ 分开数数字和取值,写明分项', s.sums.numbers.includes('数字 13 个 · 取值 8 个') && s.sums.numbers.includes('无法核对 2'),
s.sums.numbers)
check('base:报告徽标连同数字一起算(部分无法核对),旁边写分项', s.reports[0]?.result === 'partial'
&& s.reports[0].parts.includes('文档 通过') && s.reports[0].parts.includes('无法核对 2'), JSON.stringify(s.reports[0]))
check('base:运行结果、类别、出具档位标着已核对', ['status', 'class', 'tier'].every((k) => s.meta[k]?.result === 'pass'),
JSON.stringify(s.meta))
check('base:封存之后追加的事件单列,标明不在封存范围内',
(await page.locator('[data-appended]').allTextContents()).some((t) => t.includes('封存之后追加,不在封存范围内')))
check('base:斜体渲染成斜体', (await page.locator('.doc em').allTextContents()).includes('按发生周统计'))
const cell = page.locator('td.n button.num')
check('base:表格里数字列右对齐', await cell.count() === 8)
// 「显示格式由服务端渲染」只附在带出处的数字上:模型自己写的数、表格里的文字取值都是原文
const detailOf = async (locator) => {
await locator.click()
await page.locator('#detail').waitFor({ state: 'visible', timeout: 5000 })
const text = await page.locator('#detail').innerText()
await page.keyboard.press('Escape')
return text
}
const cellNumber = page.locator('td.n button.num[data-kind="number"]').first()
const value = page.locator('button.num[data-kind="value"]').first()
const bare = page.locator('button.num[data-kind="number"][data-result="na"]').filter({ hasText: /^42$/ })
check('base:带出处的数字注明显示格式由服务端渲染', /显示格式由服务端渲染/.test(await detailOf(cellNumber)))
check('base:文字取值、没有出处的数字不注明显示格式', !/显示格式由服务端渲染/.test(await detailOf(value))
&& !/显示格式由服务端渲染/.test(await detailOf(bare)))
check('base:读屏把文字取值读作「取值」、数字读作「数字」', (await value.getAttribute('aria-label')).startsWith('取值 ')
&& (await cellNumber.getAttribute('aria-label')).startsWith('数字 '))
})
// 宽屏:出处面板停在右侧,正文让出这一栏。点开表格里右对齐的数字,它和同列的数字都不被面板盖住
for (const width of [1280, 1440]) {
await sample('base', async (s, page) => {
const cells = page.locator('td.n button.num')
await cells.nth(2).click()
await page.locator('#detail').waitFor({ state: 'visible', timeout: 5000 })
const covered = await cells.nth(2).evaluate((el) => {
const r = el.getBoundingClientRect()
const at = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2)
return !!at && !!at.closest('#detail')
})
const panel = await page.locator('#detail').boundingBox()
const spot = await cells.nth(2).boundingBox()
check(`${width} 宽:点开的数字不被出处面板盖住`, !covered && !!panel && !!spot && spot.x + spot.width <= panel.x,
spot && panel ? `数字 ${Math.round(spot.x)}–${Math.round(spot.x + spot.width)},面板从 ${Math.round(panel.x)} 起` : '')
const next = await cells.nth(4).click({ timeout: 3000 }).then(() => true, () => false)
check(`${width} 宽:面板开着时同列别的数字照样点得到`, next
&& await page.locator('#detail').getAttribute('data-open') === await cells.nth(4).getAttribute('data-num'))
check(`${width} 宽:不横向溢出`, await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth))
}, { width })
}
await sample('two-reports', async (s) => {
check('two-reports:两份报告各自显示,引用链都通过', s.reports.length === 2 && s.checks.chain === 'pass'
&& s.items.filter((i) => i.check === 'chain' && i.text.includes('的文档')).length === 2, JSON.stringify(s.reports.map((r) => r.node)))
check('two-reports:月报的数字全部通过', s.reports.find((r) => r.node === 'monthly')?.result === 'pass')
})
await sample('legacy', async (s, page) => {
check('legacy:总览通过,范围写明按结束事件推算', s.overall === 'pass'
&& (await page.locator('#meta').innerText()).includes('早期运行,范围按结束事件推算'), s.text)
check('legacy:运行结果无法核对,写明早期运行的结束事件不在封存范围内', s.meta.status?.result === 'na'
&& hasItem(s, 'meta', 'na', '早期运行在结束事件落库之前封存'), JSON.stringify(s.meta.status))
check('legacy:运行结束事件列在封存之后', (await page.locator('[data-appended]').allTextContents()).some((t) => t.includes('运行结束')))
})
await sample('failed', async (s, page) => {
check('failed:运行结果「已失败」与运行失败事件对上', s.meta.status?.result === 'pass' && s.meta.status.text.includes('已失败'))
check('failed:封存说明写明之后继续运行会重新封存', await page.locator('#seal-failed').isVisible()
&& (await page.locator('#seal-failed').innerText()).includes('重新封存'))
})
await sample('base', async (_, page) => {
check('成功结束的运行不提重新封存', await page.locator('#seal-failed').isHidden())
})
await sample('degraded', async (s) => {
check('degraded:出具档位认最后一条出具判定(降档出具)', s.meta.tier?.result === 'pass' && s.meta.tier.text.includes('降档出具'),
JSON.stringify(s.meta.tier))
})
await sample('missing-doc', async (s, page) => {
check('missing-doc:总览不是绿色的通过,写明有报告未能核对', s.overall === 'incomplete' && s.text.includes('份报告未能核对'),
`${s.overall}:${s.text}`)
check('missing-doc:报告处写明导出时服务端已没有这份报告文档', s.reports[0]?.why === 'missing'
&& s.reports[0].warn.includes('导出时服务端已没有这份报告文档'), s.reports[0]?.warn)
check('missing-doc:③ ④ 无法核对,原因写明', hasItem(s, 'artifacts', 'na', '导出时服务端已没有这份工件的文件')
&& hasItem(s, 'chain', 'na', '导出时服务端已没有这份报告文档'))
check('missing-doc:总览框不是通过的绿底', await page.locator('#overall').evaluate((el) => {
const probe = document.body.appendChild(document.createElement('div'))
probe.style.background = 'var(--ok-soft)'
const ok = getComputedStyle(probe).backgroundColor
probe.remove()
return getComputedStyle(el).backgroundColor !== ok
}))
})
await sample('missing-card', async (s) => {
check('missing-card:引用口径卡的数字无法核对,写明服务端已没有文件', hasItem(s, 'numbers', 'na', '导出时服务端已没有这份工件的文件'))
check('missing-card:服务端缺文件是正常的无法核对,总览通过', s.overall === 'pass', `${s.overall}:${s.text}`)
})
await sample('nan', async (s) => {
check('nan:内容里有裸 NaN 的事件和工件照常通过', s.overall === 'pass' && s.checks.events === 'pass'
&& !s.items.some((i) => i.result === 'fail'), describe(s))
})
await sample('stray', async (s) => {
check('stray:另一次运行的文档,④ 不通过并写明', s.overall === 'fail' && hasItem(s, 'chain', 'fail', '文档记下的运行或节点与这次不符'))
check('stray:文档没通过核对时数字不予核对', s.checks.numbers === 'na' && hasItem(s, 'numbers', 'na', '所在的报告文档没有通过核对')
&& s.reports[0]?.warn.includes('其中的数字不予核对'), s.checks.numbers)
})
// 不执行脚本(附件预览、快速查看):封存哈希、运行 ID、工作流名照样看得到,状态写「尚未核对」,不是一直「正在核对」
{
const data = dataOf(samples.base)
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, javaScriptEnabled: false })
try {
const page = await ctx.newPage()
await page.goto(pathToFileURL(writePage('sample-nojs.html', samples.base)).href)
check('不执行脚本:封存哈希写在页面上', await page.locator('#seal-hash').innerText() === data.meta.manifest_hash)
check('不执行脚本:运行 ID、工作流名写在页面上', (await page.locator('#seal-note').innerText()).includes(data.meta.run_id)
&& await page.locator('#title').innerText() === data.meta.workflow_name)
check('不执行脚本:状态写「尚未核对」,不显示「正在核对」', await page.locator('.no-js').isVisible()
&& (await page.locator('#overall').innerText()).includes('尚未核对') && !(await page.locator('#overall').innerText()).includes('正在核对'))
} finally {
await ctx.close()
}
}
})
await section('样本页:篡改', async () => {
if (!samples) {
note('没有样本页(原因见上一节),这一节跳过')
return
}
const base = samples.base
const byKind = (data, kind) => data.artifacts.find((a) => a.kind === kind)
// ④ 引用链
await tamper(base, '报告记录指向另一份工件', (data) => {
data.reports[0].doc_artifact = byKind(data, 'metric_set').id
return '报告的文档换成口径卡'
}, (s) => [...turnsRed('chain')(s), ['原因写明', hasItem(s, 'chain', 'fail', '这个节点最后一次报告自查记下的不是这份文档')]])
await tamper(base, '拿掉报告记录', (data) => {
data.reports = []
return '清空 reports'
}, (s) => [...turnsRed('chain')(s), ['原因写明', hasItem(s, 'chain', 'fail', '运行记录里有这个报告节点的自查事件,文件里却没有附带它的文档')]])
await tamper(base, '多一份没人引用的工件', (data) => {
const bytes = Buffer.from(JSON.stringify({ columns: ['n'], rows: [[7]], source: 'shop', sql: 'SELECT 7 AS n' }), 'utf8')
data.artifacts.push({ id: sha(bytes), kind: 'query', node_id: 'fetch', b64: bytes.toString('base64') })
return '追加一份哈希正确的快照'
}, (s) => [...turnsRed('chain', ['artifacts'])(s), ['原因写明', hasItem(s, 'chain', 'fail', '已核对的事件和报告文档里都没有引用这份工件')]])
// 服务端不会给出的状态:只能是改过
await tamper(base, '拿掉被引用的口径卡', (data) => {
data.artifacts = data.artifacts.filter((a) => a.kind !== 'metric_set')
return '删掉口径卡条目'
}, (s) => [...turnsRed('numbers')(s), ['原因写明', hasItem(s, 'numbers', 'fail', '引用的工件不在文件的工件清单里')]])
await tamper(base, '口径卡标成遮罩', (data) => {
const card = byKind(data, 'metric_set')
delete card.b64
card.masked = true
return '口径卡去掉内容、标遮罩'
}, (s) => [...turnsRed('artifacts')(s), ['原因写明', hasItem(s, 'artifacts', 'fail', '只有查询快照会因遮罩不随文件附带')]])
await tamper(base, '报告文档的一个字节', (data) => flipByte(byKind(data, 'report_doc')), (s) => [
...turnsRed('artifacts')(s),
['数字不予核对', s.checks.numbers === 'na' && hasItem(s, 'numbers', 'na', '所在的报告文档没有通过核对')],
['报告处写明没有通过核对', s.reports[0]?.result === 'fail' && s.reports[0].why === 'untrusted'],
])
// ⑥ 运行信息:改元信息不动事件,清单照样对得上,只有 ⑥ 变红,页面上显示的是事件里的值
await tamper(base, '运行结果', (data) => {
data.meta.status = 'failed'
return 'succeeded → failed'
}, (s) => [...turnsRed('meta', ['events', 'manifest'])(s),
['页面显示事件里的「已完成」并标明文件里写的不同', s.meta.status?.result === 'fail' && s.meta.status.text.includes('已完成')
&& s.meta.status.text.includes('已失败'), JSON.stringify(s.meta.status)]])
await tamper(base, '运行类别', (data) => {
data.meta.run_class = 'exploratory'
return 'formal → exploratory'
}, (s) => [...turnsRed('meta')(s), ['原因写明', hasItem(s, 'meta', 'fail', '记下的运行参数不符')]])
await tamper(base, '去掉出具档位', (data) => {
data.meta.tier = null
return 'tier → null'
}, (s) => [...turnsRed('meta')(s), ['页面显示事件里的档位', s.meta.tier?.result === 'fail' && s.meta.tier.text.includes('完整出具')]])
await tamper(samples.degraded, '出具档位改指早先那条', (data) => {
const first = data.events.find((e) => e.type === 'issuance')
data.meta.tier = { value: 'formal', seq: first.seq }
return `指向第 ${first.seq} 条(完整出具)`
}, (s) => [...turnsRed('meta')(s),
['页面显示最后一条的降档出具', s.meta.tier?.result === 'fail' && s.meta.tier.text.includes('降档出具'), JSON.stringify(s.meta.tier)]])
// 只改页面顶部静态写出的封存哈希:数据里的没动,② 的清单照样对得上,但顶上那份对不上,核对后换成核对用的
{
const data = dataOf(base)
const fake = data.meta.manifest_hash.replace(/^./, (c) => (c === '0' ? '1' : '0'))
const opened = await openFile(writePage('tampered-seal.html', withSeal(base, fake)))
try {
const s = await stateOf(opened.page)
check('篡改页面顶部的封存哈希:② 不通过、总览不通过', s.checks.manifest === 'fail' && s.overall === 'fail'
&& hasItem(s, 'manifest', 'fail', '页面被改过'), describe(s))
check('篡改页面顶部的封存哈希:核对后顶上显示核对用的那一份', s.seal === data.meta.manifest_hash)
} finally {
await opened.ctx.close()
}
}
// 重算全部哈希的伪造在样本页上同样露馅:⑤ 不通过,报告徽标跟着不通过
await tamper(base, '样本页上重算全部哈希的伪造', forge, (s) => [
...turnsRed('numbers', ['events', 'manifest', 'artifacts', 'chain', 'meta'])(s),
['报告徽标不通过', s.reports[0]?.result === 'fail' && s.reports[0].parts.includes('不通过 1'), JSON.stringify(s.reports[0])],
], { seal: true })
})
// ---------------------------------------------------------------- 记录页的导出按钮
const SAMPLE_NAME = '门店周报-可验证报告.html'
// 和服务端 api/bundle.py 的 SEAL_BROKEN 一字不差(服务端单测断言那一边):提示里原样显示
const SEAL_BROKEN = '封存核对不一致:封存后有事件被修改、删除或插入,不能导出可验证报告。点「核对」可查看核对结果'
async function preview(syn, { width = 1280, bundle } = {}) {
const ctx = await browser.newContext({ viewport: { width, height: 700 }, acceptDownloads: true })
@@ -436,41 +748,62 @@ await section('记录页的导出按钮', async () => {
}
}
{
const refusal = { status: 409, contentType: 'application/json',
body: JSON.stringify({ detail: '封存核对不一致:事件记录与封存时不一致', code: 'bundle_seal_broken' }) }
const refusal = { status: 409, contentType: 'application/json', body: JSON.stringify({ detail: SEAL_BROKEN, code: 'bundle_seal_broken' }) }
const { ctx, page } = await preview('bundle-provenance-sealed', { bundle: refusal })
try {
await page.locator('[data-bundle-export]').click()
check('服务端回 409 时提示原因', await appears(page.getByText('封存核对不一致:事件记录与封存时不一致')))
check('服务端回 409 时原样提示原因', await appears(page.getByText(SEAL_BROKEN)))
check('提示之后按钮恢复可点', await appears(page.locator('[data-bundle-export="ready"]')))
} finally {
await ctx.close()
}
}
for (const [syn, reason] of [['bundle-provenance-running', '运行结束并封存后才能导出'],
['bundle-provenance-waiting', '等待审批中'], ['bundle-provenance-unsealed', '没有封存清单'],
['bundle-provenance-again', '运行结束并封存后才能导出']]) {
{
// 失败结束的运行:导出前后都说明之后继续运行会重新封存
const { ctx, page } = await preview('bundle-provenance-failed')
try {
const button = page.locator('[data-bundle-export]')
const hover = await button.locator('xpath=..').getAttribute('title')
check('失败结束:可以导出,悬停提示写明继续运行会重新封存', await button.isEnabled() && (hover ?? '').includes('重新封存'), hover ?? '')
const [download] = await Promise.all([page.waitForEvent('download', { timeout: 10_000 }), button.click()])
check('失败结束:导出后的提示写明继续运行会重新封存', !!download
&& await appears(page.getByText(`已导出「${SAMPLE_NAME}」。之后若继续运行,系统会重新封存`, { exact: false })))
} finally {
await ctx.close()
}
}
for (const [syn, reason, shown] of [['bundle-provenance-running', '运行结束并封存后才能导出', false],
['bundle-provenance-waiting', '等待审批中', false], ['bundle-provenance-unsealed', '没有封存清单', false],
['bundle-provenance-again', '运行结束并封存后才能导出', true]]) {
const { ctx, page, hits } = await preview(syn)
const label = syn.replace('bundle-provenance-', '')
try {
const button = page.locator('[data-bundle-export]')
const described = await button.getAttribute('aria-describedby')
const spoken = described ? await page.locator(`[id="${described}"]`).innerText() : ''
const hover = await page.locator('[data-bundle-export]').locator('xpath=..').getAttribute('title')
check(`${syn.replace('bundle-provenance-', '')}:按钮不可点,说明原因`, await button.isDisabled()
check(`${label}:按钮不可点,说明原因`, await button.isDisabled()
&& spoken.includes(reason) && (hover ?? '').includes(reason), spoken || '没有说明')
if (shown) {
// 左边写着「已封存」,按钮却不能点:原因要写在屏幕上,触屏看不到悬停提示
const visible = page.locator('[data-bundle-blocked="shown"]')
const box = await visible.boundingBox()
check(`${label}:左边写着「已封存」时,原因直接写在按钮旁`, await page.locator('[data-seal="sealed"]').isVisible()
&& await visible.isVisible() && !!box && box.width > 20 && (await visible.innerText()).includes(reason))
}
await button.click({ force: true }).catch(() => {})
check(`${syn.replace('bundle-provenance-', '')}:点了也不请求导出接口`, hits.length === 0)
check(`${label}:点了也不请求导出接口`, hits.length === 0)
} finally {
await ctx.close()
}
}
{
const { ctx, page } = await preview('bundle-provenance-sealed', { width: 390 })
for (const syn of ['bundle-provenance-sealed', 'bundle-provenance-again']) {
const { ctx, page } = await preview(syn, { width: 390 })
try {
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
const box = await page.locator('[data-bundle-export]').boundingBox()
check('390 宽:凭证条不横向溢出,导出按钮在屏幕内', overflow <= 0 && !!box && box.x >= 0 && box.x + box.width <= 390,
`溢出 ${overflow}px`)
check(`390 宽(${syn.replace('bundle-provenance-', '')}):凭证条不横向溢出,导出按钮在屏幕内`,
overflow <= 0 && !!box && box.x >= 0 && box.x + box.width <= 390, `溢出 ${overflow}px`)
} finally {
await ctx.close()
}