mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): verify cluster evidence offline
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user