feat(ql3): verify cluster evidence offline

This commit is contained in:
whyour
2026-08-16 07:40:55 +08:00
parent 4f62a86d15
commit 33b9eb2f5e
16 changed files with 1422 additions and 13 deletions
@@ -21,6 +21,10 @@ const COMMANDS = Object.freeze([
name: 'copilot-console',
usage: 'Usage:\n ql3-copilot-console --config ',
}),
Object.freeze({
name: 'evidence-verify',
usage: 'Usage: ql3-copilot-evidence-verify --bundle=',
}),
Object.freeze({
name: 'package',
usage: 'Usage: ql3-plugin-package-client ',
@@ -322,6 +326,79 @@ child.stdout.on('data', (chunk) => {
}
}
function runEvidenceVerifierContract(image) {
const source = String.raw`
const { spawnSync } = require('node:child_process');
const { webcrypto } = require('node:crypto');
const { readdirSync, readFileSync, writeFileSync } = require('node:fs');
const facade = '/opt/qinglong/node_modules/@qinglong/cluster-admin/dist/product-cli/cli.js';
const bundleApi = require('/opt/qinglong/node_modules/@qinglong/cluster-admin/assets/copilot-console/evidence-bundle.js');
const bundleFile = '/tmp/evidence.json';
(async () => {
const bundle = await bundleApi.createClusterConsoleEvidenceBundle([{
operation: 'run_read',
observedAtMs: 1700000000000,
request: { schema: 'qinglong/cluster-copilot-console-read-request@v1', operation: 'run_read', projectId: 'private-project', requestId: 'private-request', runId: 'private-run' },
fact: { schema: 'qinglong/bounded-run-projection@v1', schemaVersion: 1, status: 'succeeded', projectId: 'private-project', runId: 'private-run', message: 'must-not-survive' },
}], 1700000001000, webcrypto);
writeFileSync(bundleFile, bundleApi.serializeClusterConsoleEvidenceBundle(bundle), { mode: 0o600 });
const before = readdirSync('/tmp').sort();
const verified = spawnSync(process.execPath, [facade, 'evidence-verify', '--bundle=' + bundleFile], { encoding: 'utf8' });
const after = readdirSync('/tmp').sort();
let fact;
try { fact = JSON.parse(verified.stdout); } catch { process.exit(71); }
if (verified.status !== 0 || verified.stderr !== '' || fact.status !== 'verified' || fact.integrity.bundleDigest !== 'verified' || fact.integrity.rawFactDigests !== 'not_recomputed_without_raw_facts' || fact.claims.serverSignature !== 'not_verified' || fact.claims.actionAuthority !== 'none' || fact.execution.networkAccess !== false || fact.execution.mutation !== false || fact.execution.fileWrites !== false || JSON.stringify(before) !== JSON.stringify(after) || verified.stdout.includes('private-project') || verified.stdout.includes(bundleFile)) process.exit(72);
const tampered = JSON.parse(readFileSync(bundleFile, 'utf8'));
tampered.entries[0].fact.status = 'failed';
writeFileSync(bundleFile, JSON.stringify(tampered, null, 2) + '\n', { mode: 0o600 });
const rejected = spawnSync(process.execPath, [facade, 'evidence-verify', '--bundle=' + bundleFile], { encoding: 'utf8' });
let failure;
try { failure = JSON.parse(rejected.stderr); } catch { process.exit(73); }
if (rejected.status !== 65 || rejected.stdout !== '' || failure.code !== 'QL3_CLUSTER_CONSOLE_EVIDENCE_VERIFICATION_INVALID' || rejected.stderr.includes(bundleFile) || rejected.stderr.includes('private-project')) process.exit(74);
process.stdout.write(JSON.stringify({ verified: true, tamperRejected: true, noVerifierWrites: true }));
})().catch(() => process.exit(75));
`;
const output = docker([
'run',
'--rm',
'--read-only',
'--network',
'none',
'--cap-drop',
'ALL',
'--security-opt',
'no-new-privileges',
'--user',
'10001:10001',
'--pids-limit',
'32',
'--memory',
'128m',
'--cpus',
'0.25',
'--tmpfs',
'/tmp:rw,noexec,nosuid,nodev,size=8m,mode=700,uid=10001,gid=10001',
'--entrypoint',
'node',
image,
'-e',
source,
]);
let result;
try {
result = JSON.parse(output);
} catch {
fail('offline evidence verifier result is invalid');
}
if (
result?.verified !== true ||
result?.tamperRejected !== true ||
result?.noVerifierWrites !== true
) {
fail('offline evidence verifier contract drifted');
}
}
function runPublishedConsoleContract(image) {
const suffix = `${process.pid}-${Date.now()}`;
const network = `ql3-console-live-${suffix}`;
@@ -534,6 +611,7 @@ function main() {
if (version !== '3.0.0-alpha.0') fail('product version contract drifted');
runOperatorContextContract(image);
runConsoleContract(image);
runEvidenceVerifierContract(image);
runPublishedConsoleContract(image);
process.stdout.write(
@@ -550,6 +628,7 @@ function main() {
consoleLoopback: true,
consoleAssets: true,
consoleEvidenceBundle: true,
evidenceVerifier: true,
consolePublishedHostAddress: '127.0.0.1',
consoleDistributionEmbedded: true,
isolation: Object.freeze({
+63 -2
View File
@@ -11,6 +11,8 @@ const REQUIRED_FILES = Object.freeze([
CONSOLE_ROOT + '/assets.ts',
CONSOLE_ROOT + '/cli.ts',
CONSOLE_ROOT + '/contracts.ts',
CONSOLE_ROOT + '/evidenceVerifier.ts',
CONSOLE_ROOT + '/evidenceVerifierCli.ts',
CONSOLE_ROOT + '/server.ts',
CLIENT_FILE,
ASSET_ROOT + '/index.html',
@@ -164,6 +166,48 @@ function auditClusterCopilotConsole(options = {}) {
'diagnose',
'cancel',
]);
expectFragments(CONSOLE_ROOT + '/evidenceVerifier.ts', [
'qinglong/cluster-console-evidence-verification@v1',
'maximumBundleBytes: 512 * 1024',
'maximumRawBytes: 8 * 1024 * 1024',
"rawFactDigests: 'not_recomputed_without_raw_facts'",
"serverSignature: 'not_verified'",
"attestation: 'not_verified'",
"durableAudit: 'not_verified'",
"actionAuthority: 'none'",
'networkAccess: false',
'mutation: false',
'fileWrites: false',
'constants.O_RDONLY',
'constants.O_NOFOLLOW',
"createHash('sha256')",
]);
rejectFragments(CONSOLE_ROOT + '/evidenceVerifier.ts', [
'node:http',
'node:https',
'node:net',
'node:child_process',
'writeFile',
'appendFile',
'createWriteStream',
'process.env',
'process.stdin',
'fetch(',
]);
expectFragments(CONSOLE_ROOT + '/evidenceVerifierCli.ts', [
'ql3-copilot-evidence-verify --bundle=/absolute/evidence.json',
'verifyClusterConsoleEvidenceBundleFile',
'process.exitCode = 64',
'process.exitCode = 65',
'Cluster Console evidence bundle verification failed',
]);
rejectFragments(CONSOLE_ROOT + '/evidenceVerifierCli.ts', [
'process.env',
'process.stdin',
'node:http',
'node:https',
'writeFile',
]);
expectFragments(ASSET_ROOT + '/index.html', [
'沿着证据读,不替集群做决定。',
'本机只读 BFF',
@@ -265,6 +309,9 @@ function auditClusterCopilotConsole(options = {}) {
'consoleLoopback: true',
'consoleAssets: true',
'consoleEvidenceBundle: true',
'evidenceVerifier: true',
'function runEvidenceVerifierContract(image)',
"[facade, 'evidence-verify', '--bundle=' + bundleFile]",
"started.origin + '/evidence-bundle.js'",
"consolePublishedHostAddress: '127.0.0.1'",
'consoleDistributionEmbedded: true',
@@ -284,6 +331,8 @@ function auditClusterCopilotConsole(options = {}) {
}
if (
manifest?.bin?.['ql3-copilot-console'] !== 'dist/copilot-console/cli.js' ||
manifest?.bin?.['ql3-copilot-evidence-verify'] !==
'dist/copilot-console/evidenceVerifierCli.js' ||
manifest?.exports?.['./copilot-console']?.require !==
'./dist/copilot-console/server.js' ||
!Array.isArray(manifest?.files) ||
@@ -307,7 +356,10 @@ function auditClusterCopilotConsole(options = {}) {
if (
!productCommand.includes("name: 'copilot-console'") ||
!productCommand.includes("binary: 'ql3-copilot-console'") ||
!productCommand.includes("target: 'copilot-console/cli.js'")
!productCommand.includes("target: 'copilot-console/cli.js'") ||
!productCommand.includes("name: 'evidence-verify'") ||
!productCommand.includes("binary: 'ql3-copilot-evidence-verify'") ||
!productCommand.includes("target: 'copilot-console/evidenceVerifierCli.js'")
) {
findings.push(
finding(
@@ -395,7 +447,16 @@ function auditClusterCopilotConsole(options = {}) {
attestation: 'none',
actionAuthority: 'none',
}),
sourceFileCount: 4,
offlineVerifier: Object.freeze({
lifecycle: 'operator-local-explicit-file-read',
bundleDigest: 'recomputed',
rawFactDigests: 'not_recomputed_without_raw_facts',
serverSignature: 'not_verified',
mutation: false,
networkAccess: false,
fileWrites: false,
}),
sourceFileCount: 6,
findings: Object.freeze(findings),
compatible: findings.length === 0,
});