210 lines
10 KiB
JavaScript
210 lines
10 KiB
JavaScript
|
|
/**
|
|||
|
|
* 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();
|