aps-agent/scripts/a4-review.mjs

210 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* A4-G 控制面安全复核算例(人工评审用,不是自动化测试)。
*
* 用法:
* pnpm build && node scripts/a4-review.mjs
*
* 输出每一类的实际行为与判定(PASS / FAIL)。评审人要回答的不是"代码写了吗",
* 而是"这套控制面能不能对着真实 APS 做写操作"——所以这里全部走公开接口(HTTP / SSE),
* 不直接调内部函数,并且刻意用攻击者视角发起尝试。
*/
import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const results = [];
const record = (name, ok, detail) => {
results.push({ name, ok, detail });
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? `\n ${detail}` : ''}`);
};
const subject = { userId: 'reviewer', tenantId: 'review' };
const headersFor = (who) => ({ 'content-type': 'application/json', 'x-aps-user': who.userId, 'x-aps-tenant': who.tenantId });
/** 静态检查一:整个仓库只有一处调用 provider.invoke */
function checkSingleInvokeSite() {
const hits = [];
const walk = (dir) => {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (entry === 'node_modules' || entry === 'dist' || entry === '.git') continue;
if (statSync(full).isDirectory()) walk(full);
else if (/\.(ts|mjs|js)$/.test(entry)) {
const text = readFileSync(full, 'utf8');
text.split('\n').forEach((line, index) => {
if (/provider\.invoke\(/.test(line) || /resolved\.provider\.invoke\(/.test(line)) {
hits.push(`${full.replace(process.cwd(), '.').replaceAll('\\', '/')}:${index + 1}`);
}
});
}
}
};
walk(join(process.cwd(), 'server'));
walk(join(process.cwd(), 'providers'));
// 合同测试套件是"给 Provider 出卷子"的测试台,它调 invoke 属于用例本身,不算运行路径
const runtimeHits = hits.filter((hit) => !hit.includes('provider-sdk/contract-suite.ts'));
const only = runtimeHits.filter((hit) => !hit.includes('core/execution/invoke.ts'));
record(
'静态:运行路径上 provider.invoke 只有 Core 一个调用点',
only.length === 0,
only.length === 0
? `调用点:${runtimeHits.join('、') || '(未找到)'}(另有契约测试台 ${hits.length - runtimeHits.length} 处)`
: `发现额外调用点:${only.join('、')}`,
);
}
/** 静态检查二:引擎工具回调的鉴权在入口上 */
function checkInternalCallbackGuard() {
const text = readFileSync(join(process.cwd(), 'server', 'entry', 'http.ts'), 'utf8');
const loopback = /isLoopback/.test(text);
const token = /x-aps-internal-token/.test(text);
record('静态:/internal/tool-call 有 loopback + 内部令牌双校验', loopback && token, `loopback=${loopback} token=${token}`);
}
/** 造一个"真实系统"形态的 Provider:要绑账号、写操作要预览 */
function reviewProvider() {
let calls = 0;
let lastCredentials = null;
const capabilities = [
{
id: 'rev.query',
description: '查询状态(只读)',
effect: 'read',
mode: 'sync',
inputSchema: { type: 'object' },
outputSchema: { type: 'object' },
idempotency: 'none',
errors: ['invalid_argument', 'forbidden'],
},
{
id: 'rev.publish',
description: '发布计划(写入)',
effect: 'write',
mode: 'sync',
inputSchema: { type: 'object', properties: { plan: { type: 'string' } } },
outputSchema: { type: 'object' },
idempotency: 'key',
errors: ['invalid_argument', 'forbidden', 'conflict'],
},
];
return {
calls: () => calls,
credentialsSeen: () => lastCredentials,
async describe() {
return { id: 'rev', displayName: '复核用 Provider', version: '1.0.0', platformVersion: 1, requiresIdentity: true, capabilities };
},
async invoke(context, call) {
calls += 1;
lastCredentials = context.credentials ? await context.credentials.read() : null;
return { executionId: context.executionId, status: 'succeeded', result: { capabilityId: call.capabilityId } };
},
async health() {
return { status: 'ok', checkedAt: Date.now() };
},
async preview() {
return { impactSummary: '发布 10 月计划第 3 版,影响 12 个订单', revision: 3, paramsPreview: { plan: '2026-10' } };
},
};
}
async function main() {
if (!statSync(join(process.cwd(), 'server', 'dist', 'index.js'), { throwIfNoEntry: false })) {
console.error('先构建:pnpm build');
process.exitCode = 1;
return;
}
checkSingleInvokeSite();
checkInternalCallbackGuard();
const dir = mkdtempSync(join(tmpdir(), 'a4-review-'));
process.env.APS_CREDENTIAL_KEY_FILE = join(dir, 'secret.key');
delete process.env.APS_DEV_PROVIDER;
const { startServer } = await import('../server/dist/index.js');
const server = await startServer({ host: '127.0.0.1', port: 0, version: 'review', engine: 'fake', databasePath: join(dir, 'aps.sqlite') });
const provider = reviewProvider();
const secret = 'aps-login-token-8f31c2';
try {
await server.service.registerProvider(provider);
const control = server.service.controlPlane;
// 负例 1:未绑定身份就调用
const unbound = await server.service.invoke({ subject, sessionId: 's', projectId: 'p-default', capabilityId: 'rev.query', params: {} });
record('负例1 未绑定身份 → 拒绝', unbound.status === 'failed' && unbound.errorKind === 'forbidden', `${unbound.status}/${unbound.errorKind}:${unbound.result?.message ?? ''}`);
// 绑定 + 放一份凭据(模拟用户完成一次登录绑定)
control.identity.bind({ userId: subject.userId, tenantId: subject.tenantId, providerId: 'rev', accountId: 'E1001' });
control.credentials.put(subject, 'rev', { token: secret });
const sealed = control.credentials.sealedFor(subject, 'rev');
const raw = readFileSync(join(dir, 'aps.sqlite'), 'utf8');
record(
'负例2 凭据不出域:库里没有明文、密文非空',
!raw.includes(secret) && sealed.ciphertext.length > 0 && !JSON.stringify(sealed).includes(secret),
`库文件含明文=${raw.includes(secret)},密文长度=${sealed.ciphertext.length}`,
);
// 写操作:先开确认卡
const pending = await server.service.invoke({ subject, sessionId: 's', projectId: 'p-default', capabilityId: 'rev.publish', params: { plan: '2026-10' }, idempotencyKey: 'idem-review-1' });
record('写操作未经确认不执行', pending.status === 'pending-confirmation' && provider.calls() === 0, `status=${pending.status} provider 调用次数=${provider.calls()}`);
// 负例 3a:拿旧版本号确认
const stale = await server.service.core.approve(subject, pending.approvalId, { decision: 'approve', revision: 99, idempotencyKey: 'idem-review-2' });
record('负例3a 目标版本变了 → 确认失效', stale.decision === 'expired' && provider.calls() === 0, `decision=${stale.decision}`);
// 正常确认
const second = await server.service.invoke({ subject, sessionId: 's', projectId: 'p-default', capabilityId: 'rev.publish', params: { plan: '2026-10' }, idempotencyKey: 'idem-review-3' });
const approved = await server.service.core.approve(subject, second.approvalId, { decision: 'approve', revision: 3, idempotencyKey: 'idem-review-4' });
record('确认后执行一次并成功', approved.receipt.status === 'succeeded' && provider.calls() === 1, `回执=${approved.receipt.status} provider 调用次数=${provider.calls()}`);
// 重放:换幂等键再确认
const replay = await server.service.core.approve(subject, second.approvalId, { decision: 'approve', revision: 3, idempotencyKey: 'idem-review-5' });
record('负例3b 同一确认重放 → 不重复执行', provider.calls() === 1 && replay.receipt.executionId === approved.receipt.executionId, `provider 调用次数=${provider.calls()}`);
// 凭据经句柄到达 Provider,且用完即废
record(
'凭据只经一次性句柄到达 Provider',
provider.credentialsSeen() !== null && provider.credentialsSeen().token === secret && control.credentials.activeGrants() === 0,
`Provider 读到 token=${provider.credentialsSeen() !== null},剩余授权=${control.credentials.activeGrants()}`,
);
// 追溯 + 链完整性
const trace = server.service.controlPlane.audit.trace(second.executionId);
const events = trace.map((entry) => entry.event);
record(
'按 executionId 追到人、参数、审批与回执',
['policy.requires_confirmation', 'approval.requested', 'approval.decided', 'execution.finished'].every((name) => events.includes(name)) &&
trace.every((entry) => entry.actorUserId === subject.userId) &&
trace.some((entry) => entry.paramsDigest?.startsWith('sha256:')),
`事件:${events.join(' → ')}`,
);
record('审计链自校验通过', control.audit.verify().ok === true, '');
// 越权:另一个身份确认别人的动作
const stolen = await server.service.core.approve({ userId: 'someone-else', tenantId: 'review' }, second.approvalId, { decision: 'approve', revision: 3, idempotencyKey: 'idem-review-6' });
record('负例1b 别人不能替发起人确认', stolen.kind === 'forbidden', `kind=${stolen.kind}`);
// 篡改审计:直接改库
const { DatabaseSync } = await import('node:sqlite');
const rawDb = new DatabaseSync(join(dir, 'aps.sqlite'));
rawDb.prepare('update audit_log set decision = ? where seq = 1').run('被改过');
rawDb.close();
const verified = control.audit.verify();
record('篡改审计记录能被发现', verified.ok === false, `brokenAt=${verified.brokenAt} ${verified.reason ?? ''}`);
} finally {
await server.close();
rmSync(dir, { recursive: true, force: true });
delete process.env.APS_CREDENTIAL_KEY_FILE;
}
const failed = results.filter((item) => !item.ok);
console.log(`\n共 ${results.length} 项,通过 ${results.length - failed.length} 项,未通过 ${failed.length} 项`);
if (failed.length > 0) {
console.error('未通过项:');
for (const item of failed) console.error(` - ${item.name}`);
process.exitCode = 1;
} else {
console.log('机械项全部通过。剩下的判断留给评审人:威胁模型、日志与凭据边界、并发与限流(见 A4-G 备注)。');
}
}
await main();