aps-agent/scripts/a4-review.mjs

210 lines
10 KiB
JavaScript
Raw Normal View History

/**
* 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();