mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-23 03:18:09 +08:00
feat(ql3): export redacted cluster evidence bundle
This commit is contained in:
@@ -315,6 +315,25 @@ h3 {
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.evidence-actions {
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
.ledger-meta {
|
||||
font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace;
|
||||
font-size: 0.64rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.evidence-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, auto));
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.evidence-buttons button {
|
||||
padding: 0.52rem 0.65rem;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.status-chip {
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 999px;
|
||||
@@ -483,6 +502,17 @@ footer {
|
||||
.evidence-panel {
|
||||
padding: 1.1rem;
|
||||
}
|
||||
.evidence-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
.evidence-actions {
|
||||
width: 100%;
|
||||
justify-items: start;
|
||||
}
|
||||
.evidence-buttons {
|
||||
width: 100%;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.ledger-entry {
|
||||
padding-left: 2.8rem;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
workflow_event_list: 'Workflow Run Events',
|
||||
workflow_step_list: 'Workflow Run Steps',
|
||||
});
|
||||
const bundleApi = globalThis.QingLongEvidenceBundle;
|
||||
const sessionForm = document.getElementById('session-form');
|
||||
const sessionInput = document.getElementById('session-token');
|
||||
const controls = document.getElementById('console-controls');
|
||||
@@ -39,8 +40,14 @@
|
||||
const emptyState = document.getElementById('empty-state');
|
||||
const message = document.getElementById('message');
|
||||
const statusChip = document.getElementById('status-chip');
|
||||
const ledgerMeta = document.getElementById('ledger-meta');
|
||||
const exportButton = document.getElementById('export-evidence');
|
||||
const clearButton = document.getElementById('clear-evidence');
|
||||
const evidenceRecords = [];
|
||||
let sessionToken = '';
|
||||
let busy = false;
|
||||
let exporting = false;
|
||||
let evidenceBytes = 0;
|
||||
|
||||
const value = function (id) {
|
||||
return document.getElementById(id).value.trim();
|
||||
@@ -55,6 +62,21 @@
|
||||
message.dataset.tone = tone || 'neutral';
|
||||
};
|
||||
|
||||
const updateLedgerState = function () {
|
||||
const count = evidenceRecords.length;
|
||||
ledgerMeta.textContent =
|
||||
String(count) +
|
||||
'/' +
|
||||
String(bundleApi.limits.maximumRecords) +
|
||||
' 条 · ' +
|
||||
String(Math.ceil(evidenceBytes / 1024)) +
|
||||
' KiB 原始事实';
|
||||
emptyState.hidden = count !== 0;
|
||||
ledger.hidden = count === 0;
|
||||
exportButton.disabled = busy || exporting || count === 0;
|
||||
clearButton.disabled = busy || exporting || count === 0;
|
||||
};
|
||||
|
||||
const setBusy = function (next) {
|
||||
busy = next;
|
||||
document.querySelectorAll('[data-read]').forEach(function (button) {
|
||||
@@ -67,6 +89,7 @@
|
||||
statusChip.textContent = '只读就绪';
|
||||
statusChip.dataset.tone = 'success';
|
||||
}
|
||||
updateLedgerState();
|
||||
};
|
||||
|
||||
const base = function (operation) {
|
||||
@@ -179,6 +202,14 @@
|
||||
|
||||
const appendEvidence = function (operation, request, response) {
|
||||
const fact = response.result.result;
|
||||
const observedAtMs = Date.now();
|
||||
const record = {
|
||||
operation: operation,
|
||||
observedAtMs: observedAtMs,
|
||||
request: request,
|
||||
fact: fact,
|
||||
};
|
||||
const recordBytes = bundleApi.measureClusterConsoleEvidenceRecord(record);
|
||||
const entry = document.createElement('li');
|
||||
entry.className = 'ledger-entry';
|
||||
const header = document.createElement('header');
|
||||
@@ -189,7 +220,7 @@
|
||||
time.textContent = new Intl.DateTimeFormat('zh-CN', {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'medium',
|
||||
}).format(new Date());
|
||||
}).format(new Date(observedAtMs));
|
||||
output.tabIndex = 0;
|
||||
output.textContent = JSON.stringify(fact, null, 2);
|
||||
header.append(title, time);
|
||||
@@ -205,8 +236,20 @@
|
||||
entry.append(button);
|
||||
}
|
||||
ledger.prepend(entry);
|
||||
emptyState.hidden = true;
|
||||
ledger.hidden = false;
|
||||
evidenceRecords.push({ record: record, bytes: recordBytes, entry: entry });
|
||||
evidenceBytes += recordBytes;
|
||||
let evicted = 0;
|
||||
while (
|
||||
evidenceRecords.length > bundleApi.limits.maximumRecords ||
|
||||
evidenceBytes > bundleApi.limits.maximumRawBytes
|
||||
) {
|
||||
const oldest = evidenceRecords.shift();
|
||||
evidenceBytes -= oldest.bytes;
|
||||
oldest.entry.remove();
|
||||
evicted += 1;
|
||||
}
|
||||
updateLedgerState();
|
||||
return evicted;
|
||||
};
|
||||
|
||||
const execute = async function (operation, prepared) {
|
||||
@@ -236,9 +279,13 @@
|
||||
: 'console_request_failed',
|
||||
);
|
||||
}
|
||||
appendEvidence(operation, body, responseBody);
|
||||
const evicted = appendEvidence(operation, body, responseBody);
|
||||
setMessage(
|
||||
labels[operation] + ' 已加入本页证据账本。刷新页面会清空。',
|
||||
labels[operation] +
|
||||
' 已加入本页证据账本。' +
|
||||
(evicted === 0
|
||||
? '刷新页面会清空。'
|
||||
: '为保持容量上限,已淘汰最旧记录。'),
|
||||
'success',
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -254,6 +301,72 @@
|
||||
}
|
||||
};
|
||||
|
||||
const clearEvidence = function () {
|
||||
for (const evidence of evidenceRecords) evidence.entry.remove();
|
||||
evidenceRecords.length = 0;
|
||||
evidenceBytes = 0;
|
||||
statusChip.textContent = '账本已清空';
|
||||
statusChip.dataset.tone = 'success';
|
||||
updateLedgerState();
|
||||
setMessage('本页证据账本已清空;没有向服务端发送请求。', 'success');
|
||||
};
|
||||
|
||||
const exportEvidence = async function () {
|
||||
if (busy || exporting || evidenceRecords.length === 0) return;
|
||||
exporting = true;
|
||||
updateLedgerState();
|
||||
setMessage('正在本页内存中生成脱敏证据包…');
|
||||
try {
|
||||
const generatedAtMs = Date.now();
|
||||
const bundle = await bundleApi.createClusterConsoleEvidenceBundle(
|
||||
evidenceRecords.map(function (evidence) {
|
||||
return evidence.record;
|
||||
}),
|
||||
generatedAtMs,
|
||||
);
|
||||
const encoded = bundleApi.serializeClusterConsoleEvidenceBundle(bundle);
|
||||
const blob = new Blob([encoded], {
|
||||
type: 'application/json;charset=utf-8',
|
||||
});
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
try {
|
||||
const anchor = document.createElement('a');
|
||||
anchor.download =
|
||||
'qinglong-cluster-evidence-' +
|
||||
new Date(generatedAtMs).toISOString().replaceAll(':', '-') +
|
||||
'.json';
|
||||
anchor.href = objectUrl;
|
||||
anchor.rel = 'noopener';
|
||||
document.body.append(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
} finally {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
statusChip.textContent = '脱敏包已生成';
|
||||
statusChip.dataset.tone = 'success';
|
||||
setMessage(
|
||||
'已下载 ' +
|
||||
String(bundle.source.entryCount) +
|
||||
' 条脱敏事实;未发起额外 Cluster 读取。',
|
||||
'success',
|
||||
);
|
||||
} catch (error) {
|
||||
statusChip.textContent = '导出失败';
|
||||
statusChip.dataset.tone = 'failed';
|
||||
setMessage(
|
||||
'无法导出:' +
|
||||
(error instanceof Error
|
||||
? error.message
|
||||
: 'cluster_evidence_bundle_failed'),
|
||||
'error',
|
||||
);
|
||||
} finally {
|
||||
exporting = false;
|
||||
updateLedgerState();
|
||||
}
|
||||
};
|
||||
|
||||
sessionForm.addEventListener('submit', function (event) {
|
||||
event.preventDefault();
|
||||
const candidate = sessionInput.value.trim();
|
||||
@@ -290,8 +403,18 @@
|
||||
});
|
||||
});
|
||||
|
||||
exportButton.addEventListener('click', function () {
|
||||
void exportEvidence();
|
||||
});
|
||||
|
||||
clearButton.addEventListener('click', clearEvidence);
|
||||
|
||||
window.addEventListener('pagehide', function () {
|
||||
sessionToken = '';
|
||||
evidenceRecords.length = 0;
|
||||
evidenceBytes = 0;
|
||||
ledger.textContent = '';
|
||||
});
|
||||
|
||||
updateLedgerState();
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,641 @@
|
||||
'use strict';
|
||||
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module && module.exports) {
|
||||
module.exports = api;
|
||||
}
|
||||
if (root && typeof root === 'object') {
|
||||
Object.defineProperty(root, 'QingLongEvidenceBundle', {
|
||||
configurable: false,
|
||||
enumerable: false,
|
||||
value: api,
|
||||
writable: false,
|
||||
});
|
||||
}
|
||||
})(typeof globalThis === 'object' ? globalThis : this, function () {
|
||||
const schema = 'qinglong/cluster-console-redacted-evidence-bundle@v1';
|
||||
const requestSchema = 'qinglong/cluster-copilot-console-read-request@v1';
|
||||
const limits = Object.freeze({
|
||||
maximumArrayItems: 64,
|
||||
maximumBundleBytes: 512 * 1024,
|
||||
maximumDepth: 16,
|
||||
maximumEntryFactBytes: 2 * 1024 * 1024 + 4 * 1024,
|
||||
maximumObjectKeys: 256,
|
||||
maximumRawBytes: 8 * 1024 * 1024,
|
||||
maximumRecords: 16,
|
||||
});
|
||||
const operations = Object.freeze([
|
||||
'inspect',
|
||||
'output',
|
||||
'run_list',
|
||||
'run_read',
|
||||
'run_event_list',
|
||||
'run_step_list',
|
||||
'task_list',
|
||||
'task_read',
|
||||
'workflow_list',
|
||||
'workflow_run_list',
|
||||
'workflow_run_read',
|
||||
'workflow_event_list',
|
||||
'workflow_step_list',
|
||||
]);
|
||||
const operationSet = new Set(operations);
|
||||
const requestFields = Object.freeze({
|
||||
inspect: ['projectId', 'requestId', 'sourceRunId'],
|
||||
output: ['projectId', 'requestId', 'sourceRunId'],
|
||||
run_list: [
|
||||
'afterCreatedAtMs',
|
||||
'afterRunId',
|
||||
'limit',
|
||||
'projectId',
|
||||
'requestId',
|
||||
],
|
||||
run_read: ['projectId', 'requestId', 'runId'],
|
||||
run_event_list: [
|
||||
'afterSequence',
|
||||
'limit',
|
||||
'projectId',
|
||||
'requestId',
|
||||
'runId',
|
||||
],
|
||||
run_step_list: [
|
||||
'afterStepKey',
|
||||
'afterStepRunId',
|
||||
'limit',
|
||||
'projectId',
|
||||
'requestId',
|
||||
'runId',
|
||||
],
|
||||
task_list: ['afterTaskId', 'limit', 'projectId', 'requestId'],
|
||||
task_read: ['projectId', 'requestId', 'taskId'],
|
||||
workflow_list: ['packageName', 'projectId', 'requestId'],
|
||||
workflow_run_list: [
|
||||
'afterAdmittedAtMs',
|
||||
'afterRunId',
|
||||
'limit',
|
||||
'packageName',
|
||||
'projectId',
|
||||
'requestId',
|
||||
'workflowId',
|
||||
],
|
||||
workflow_run_read: [
|
||||
'packageName',
|
||||
'projectId',
|
||||
'requestId',
|
||||
'runId',
|
||||
'workflowId',
|
||||
],
|
||||
workflow_event_list: [
|
||||
'afterSequence',
|
||||
'limit',
|
||||
'packageName',
|
||||
'projectId',
|
||||
'requestId',
|
||||
'runId',
|
||||
'workflowId',
|
||||
],
|
||||
workflow_step_list: [
|
||||
'afterStepKey',
|
||||
'afterStepRunId',
|
||||
'limit',
|
||||
'packageName',
|
||||
'projectId',
|
||||
'requestId',
|
||||
'runId',
|
||||
'workflowId',
|
||||
],
|
||||
});
|
||||
const identifierDomains = Object.freeze({
|
||||
afterRunId: 'run',
|
||||
afterStepKey: 'step',
|
||||
afterStepRunId: 'step',
|
||||
afterTaskId: 'task',
|
||||
artifactId: 'artifact',
|
||||
contentDigest: 'digest',
|
||||
diagnosisRunId: 'run',
|
||||
executionId: 'execution',
|
||||
id: 'identifier',
|
||||
modelId: 'model',
|
||||
outputRef: 'artifact',
|
||||
packageName: 'package',
|
||||
projectId: 'project',
|
||||
providerId: 'provider',
|
||||
requestId: 'request',
|
||||
runId: 'run',
|
||||
sourceRunId: 'run',
|
||||
stepKey: 'step',
|
||||
stepRunId: 'step',
|
||||
taskId: 'task',
|
||||
triggerId: 'trigger',
|
||||
workflowId: 'workflow',
|
||||
workerId: 'worker',
|
||||
});
|
||||
const safeContainers = new Set([
|
||||
'attempts',
|
||||
'counts',
|
||||
'events',
|
||||
'items',
|
||||
'metadata',
|
||||
'next',
|
||||
'reference',
|
||||
'run',
|
||||
'runs',
|
||||
'source',
|
||||
'step',
|
||||
'steps',
|
||||
'summary',
|
||||
'target',
|
||||
'task',
|
||||
'tasks',
|
||||
'usage',
|
||||
'workflow',
|
||||
'workflows',
|
||||
]);
|
||||
const safeBooleans = new Set([
|
||||
'active',
|
||||
'archived',
|
||||
'available',
|
||||
'cancelRequested',
|
||||
'enabled',
|
||||
'hasMore',
|
||||
'outputAvailable',
|
||||
'ready',
|
||||
'replayed',
|
||||
'tailComplete',
|
||||
'terminal',
|
||||
'truncated',
|
||||
]);
|
||||
const safeEnumKeys = new Set([
|
||||
'finishReason',
|
||||
'kind',
|
||||
'operation',
|
||||
'outcome',
|
||||
'stage',
|
||||
'status',
|
||||
]);
|
||||
const safeEnumValues = new Set([
|
||||
'accepted',
|
||||
'active',
|
||||
'admission',
|
||||
'available',
|
||||
'blocked',
|
||||
'cancelled',
|
||||
'completed',
|
||||
'completion',
|
||||
'dispatch',
|
||||
'dispatching',
|
||||
'disabled',
|
||||
'enabled',
|
||||
'execution',
|
||||
'failed',
|
||||
'finalization',
|
||||
'installed',
|
||||
'local',
|
||||
'lost',
|
||||
'missing',
|
||||
'model',
|
||||
'not_found',
|
||||
'pending',
|
||||
'post_model',
|
||||
'pre_model',
|
||||
'prompt',
|
||||
'quarantined',
|
||||
'queued',
|
||||
'ready',
|
||||
'recovery',
|
||||
'rejected',
|
||||
'remote',
|
||||
'retained',
|
||||
'retired',
|
||||
'retry_wait',
|
||||
'run',
|
||||
'running',
|
||||
'skipped',
|
||||
'staged',
|
||||
'staging',
|
||||
'step',
|
||||
'stop',
|
||||
'succeeded',
|
||||
'system',
|
||||
'task',
|
||||
'terminal',
|
||||
'timed_out',
|
||||
'tool',
|
||||
'trigger',
|
||||
'unknown',
|
||||
'unavailable',
|
||||
'workflow',
|
||||
]);
|
||||
const sensitiveKey =
|
||||
/credential|token|authorization|secret|session|password|cookie|private|keyring/iu;
|
||||
const freeTextKey =
|
||||
/text|content|stdout|stderr|command|input|output|environment|reason|error|message|description|name|path|url|uri|host|endpoint/iu;
|
||||
const numericKey =
|
||||
/^(?:schemaVersion|version|revision|sequence|attempt|priority|limit|offset|size|total|count|[A-Za-z0-9_]*(?:AtMs|TimeMs|DurationMs|Bytes|Tokens|Micros|Sequence|Version|Count|Limit|Offset|Size|Total))$/u;
|
||||
const schemaValue = /^[a-z0-9][a-z0-9./_-]{0,126}@[a-z0-9._-]{1,16}$/u;
|
||||
|
||||
class ClusterConsoleEvidenceBundleError extends TypeError {
|
||||
constructor() {
|
||||
super('Cluster Console evidence bundle input is invalid');
|
||||
this.name = 'ClusterConsoleEvidenceBundleError';
|
||||
this.code = 'QL3_CLUSTER_CONSOLE_EVIDENCE_BUNDLE_INVALID';
|
||||
}
|
||||
}
|
||||
|
||||
const invalid = function () {
|
||||
throw new ClusterConsoleEvidenceBundleError();
|
||||
};
|
||||
|
||||
const plainObject = function (value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
};
|
||||
|
||||
const safeInteger = function (value) {
|
||||
return Number.isSafeInteger(value) && value >= 0;
|
||||
};
|
||||
|
||||
const canonicalValue = function (value, depth, stack) {
|
||||
if (depth > limits.maximumDepth) return invalid();
|
||||
if (value === null) return 'null';
|
||||
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value)) return invalid();
|
||||
return JSON.stringify(Object.is(value, -0) ? 0 : value);
|
||||
}
|
||||
if (typeof value === 'string') return JSON.stringify(value);
|
||||
if (!value || typeof value !== 'object') return invalid();
|
||||
if (stack.has(value)) return invalid();
|
||||
stack.add(value);
|
||||
try {
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length > limits.maximumArrayItems) return invalid();
|
||||
return (
|
||||
'[' +
|
||||
value
|
||||
.map(function (item) {
|
||||
return canonicalValue(item, depth + 1, stack);
|
||||
})
|
||||
.join(',') +
|
||||
']'
|
||||
);
|
||||
}
|
||||
if (!plainObject(value)) return invalid();
|
||||
const keys = Object.keys(value).sort();
|
||||
if (keys.length > limits.maximumObjectKeys) return invalid();
|
||||
return (
|
||||
'{' +
|
||||
keys
|
||||
.map(function (key) {
|
||||
return (
|
||||
JSON.stringify(key) +
|
||||
':' +
|
||||
canonicalValue(value[key], depth + 1, stack)
|
||||
);
|
||||
})
|
||||
.join(',') +
|
||||
'}'
|
||||
);
|
||||
} finally {
|
||||
stack.delete(value);
|
||||
}
|
||||
};
|
||||
|
||||
const canonicalize = function (value) {
|
||||
return canonicalValue(value, 0, new WeakSet());
|
||||
};
|
||||
|
||||
const utf8Bytes = function (value) {
|
||||
return new TextEncoder().encode(value);
|
||||
};
|
||||
|
||||
const sha256 = async function (value, cryptography) {
|
||||
if (
|
||||
!cryptography ||
|
||||
!cryptography.subtle ||
|
||||
typeof cryptography.subtle.digest !== 'function'
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
const bytes = utf8Bytes(value);
|
||||
const digest = await cryptography.subtle.digest('SHA-256', bytes);
|
||||
return Array.from(new Uint8Array(digest), function (byte) {
|
||||
return byte.toString(16).padStart(2, '0');
|
||||
}).join('');
|
||||
};
|
||||
|
||||
const exactKeys = function (value, expected) {
|
||||
if (!plainObject(value)) return false;
|
||||
const actual = Object.keys(value).sort();
|
||||
const normalized = expected.slice().sort();
|
||||
return (
|
||||
actual.length === normalized.length &&
|
||||
actual.every(function (key, index) {
|
||||
return key === normalized[index];
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const validateRecord = function (record) {
|
||||
if (
|
||||
!exactKeys(record, ['fact', 'observedAtMs', 'operation', 'request']) ||
|
||||
!operationSet.has(record.operation) ||
|
||||
!safeInteger(record.observedAtMs) ||
|
||||
!plainObject(record.fact)
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
const fields = requestFields[record.operation];
|
||||
if (
|
||||
!exactKeys(record.request, ['operation', 'schema'].concat(fields)) ||
|
||||
record.request.schema !== requestSchema ||
|
||||
record.request.operation !== record.operation
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
canonicalize(record.request);
|
||||
const factCanonical = canonicalize(record.fact);
|
||||
const factBytes = utf8Bytes(factCanonical).byteLength;
|
||||
if (factBytes < 2 || factBytes > limits.maximumEntryFactBytes) {
|
||||
return invalid();
|
||||
}
|
||||
return Object.freeze({ factBytes, factCanonical });
|
||||
};
|
||||
|
||||
const createAliaser = function () {
|
||||
const counters = new Map();
|
||||
const values = new Map();
|
||||
return function (domain, value) {
|
||||
if (value === null) return null;
|
||||
if (typeof value !== 'string' || value.length < 1 || value.length > 512) {
|
||||
return invalid();
|
||||
}
|
||||
const identity = domain + '\0' + value;
|
||||
const existing = values.get(identity);
|
||||
if (existing) return existing;
|
||||
const next = (counters.get(domain) || 0) + 1;
|
||||
counters.set(domain, next);
|
||||
const alias = domain + '-' + String(next).padStart(3, '0');
|
||||
values.set(identity, alias);
|
||||
return alias;
|
||||
};
|
||||
};
|
||||
|
||||
const sanitizeValue = function (value, operation, alias, state, depth) {
|
||||
if (depth > limits.maximumDepth) return invalid();
|
||||
if (Array.isArray(value)) {
|
||||
return value.slice(0, limits.maximumArrayItems).map(function (item) {
|
||||
if (plainObject(item) || Array.isArray(item)) {
|
||||
return sanitizeValue(item, operation, alias, state, depth + 1);
|
||||
}
|
||||
state.omittedFieldCount += 1;
|
||||
return null;
|
||||
});
|
||||
}
|
||||
if (!plainObject(value)) return invalid();
|
||||
const result = {};
|
||||
for (const key of Object.keys(value).sort()) {
|
||||
const candidate = value[key];
|
||||
const domain = identifierDomains[key];
|
||||
if (sensitiveKey.test(key)) {
|
||||
state.omittedFieldCount += 1;
|
||||
continue;
|
||||
}
|
||||
if (domain) {
|
||||
if (candidate === null || typeof candidate === 'string') {
|
||||
result[key] = alias(domain, candidate);
|
||||
} else {
|
||||
state.omittedFieldCount += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (/Digest$/u.test(key)) {
|
||||
if (candidate === null || typeof candidate === 'string') {
|
||||
result[key] = alias('digest', candidate);
|
||||
} else {
|
||||
state.omittedFieldCount += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (key === 'schema') {
|
||||
if (typeof candidate === 'string' && schemaValue.test(candidate)) {
|
||||
result[key] = candidate;
|
||||
} else {
|
||||
state.omittedFieldCount += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (safeEnumKeys.has(key)) {
|
||||
if (candidate === null) {
|
||||
result[key] = null;
|
||||
} else if (
|
||||
typeof candidate === 'string' &&
|
||||
(key === 'operation'
|
||||
? operationSet.has(candidate)
|
||||
: safeEnumValues.has(candidate))
|
||||
) {
|
||||
result[key] = candidate;
|
||||
} else {
|
||||
state.omittedFieldCount += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (safeBooleans.has(key)) {
|
||||
if (typeof candidate === 'boolean' || candidate === null) {
|
||||
result[key] = candidate;
|
||||
} else {
|
||||
state.omittedFieldCount += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (numericKey.test(key)) {
|
||||
if (safeInteger(candidate) || candidate === null) {
|
||||
result[key] = candidate;
|
||||
} else {
|
||||
state.omittedFieldCount += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (freeTextKey.test(key) || key === 'result') {
|
||||
state.omittedFieldCount += 1;
|
||||
continue;
|
||||
}
|
||||
if (safeContainers.has(key)) {
|
||||
if (plainObject(candidate) || Array.isArray(candidate)) {
|
||||
result[key] = sanitizeValue(
|
||||
candidate,
|
||||
operation,
|
||||
alias,
|
||||
state,
|
||||
depth + 1,
|
||||
);
|
||||
} else if (candidate === null) {
|
||||
result[key] = null;
|
||||
} else {
|
||||
state.omittedFieldCount += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
state.omittedFieldCount += 1;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const deepFreeze = function (value) {
|
||||
if (!value || typeof value !== 'object' || Object.isFrozen(value)) {
|
||||
return value;
|
||||
}
|
||||
Object.freeze(value);
|
||||
for (const child of Object.values(value)) deepFreeze(child);
|
||||
return value;
|
||||
};
|
||||
|
||||
const measureClusterConsoleEvidenceRecord = function (record) {
|
||||
return validateRecord(record).factBytes;
|
||||
};
|
||||
|
||||
const createClusterConsoleEvidenceBundle = async function (
|
||||
records,
|
||||
generatedAtMs,
|
||||
cryptography,
|
||||
) {
|
||||
if (
|
||||
!Array.isArray(records) ||
|
||||
records.length < 1 ||
|
||||
records.length > limits.maximumRecords ||
|
||||
!safeInteger(generatedAtMs)
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
const cryptoProvider = cryptography || globalThis.crypto;
|
||||
const alias = createAliaser();
|
||||
let totalRawBytes = 0;
|
||||
const entries = [];
|
||||
for (let index = 0; index < records.length; index += 1) {
|
||||
const record = records[index];
|
||||
const validated = validateRecord(record);
|
||||
totalRawBytes += validated.factBytes;
|
||||
if (totalRawBytes > limits.maximumRawBytes) return invalid();
|
||||
const state = { omittedFieldCount: 0 };
|
||||
const target = sanitizeValue(
|
||||
record.request,
|
||||
record.operation,
|
||||
alias,
|
||||
state,
|
||||
0,
|
||||
);
|
||||
const fact = sanitizeValue(
|
||||
record.fact,
|
||||
record.operation,
|
||||
alias,
|
||||
state,
|
||||
0,
|
||||
);
|
||||
entries.push({
|
||||
sequence: index + 1,
|
||||
observedAtMs: record.observedAtMs,
|
||||
operation: record.operation,
|
||||
target,
|
||||
fact,
|
||||
rawFact: {
|
||||
canonicalBytes: validated.factBytes,
|
||||
sha256: await sha256(validated.factCanonical, cryptoProvider),
|
||||
},
|
||||
sanitizer: {
|
||||
omittedFieldCount: state.omittedFieldCount,
|
||||
rawContentIncluded: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
const unsigned = {
|
||||
schema,
|
||||
classification: 'low_sensitive_redacted',
|
||||
generatedAtMs,
|
||||
generatedBy: 'browser_local',
|
||||
actionAuthority: 'none',
|
||||
attestation: 'none',
|
||||
source: {
|
||||
surface: 'cluster_field_ledger',
|
||||
collection: 'explicit_user_reads_only',
|
||||
entryCount: entries.length,
|
||||
totalRawCanonicalBytes: totalRawBytes,
|
||||
},
|
||||
redaction: {
|
||||
policy: 'fixed_allowlist_v1',
|
||||
identifiers: 'per_bundle_typed_alias_without_mapping',
|
||||
freeTextIncluded: false,
|
||||
copilotOutputIncluded: false,
|
||||
unknownFieldsIncluded: false,
|
||||
},
|
||||
integrity: {
|
||||
algorithm: 'sha256',
|
||||
scope: 'canonical_bundle_without_contentDigest',
|
||||
serverSignature: false,
|
||||
durableAudit: false,
|
||||
},
|
||||
entries,
|
||||
};
|
||||
const contentDigest = await sha256(canonicalize(unsigned), cryptoProvider);
|
||||
const bundle = { ...unsigned, contentDigest };
|
||||
const encoded = JSON.stringify(bundle, null, 2) + '\n';
|
||||
if (utf8Bytes(encoded).byteLength > limits.maximumBundleBytes) {
|
||||
return invalid();
|
||||
}
|
||||
return deepFreeze(bundle);
|
||||
};
|
||||
|
||||
const serializeClusterConsoleEvidenceBundle = function (bundle) {
|
||||
if (
|
||||
!plainObject(bundle) ||
|
||||
bundle.schema !== schema ||
|
||||
typeof bundle.contentDigest !== 'string' ||
|
||||
!/^[0-9a-f]{64}$/u.test(bundle.contentDigest)
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
const encoded = JSON.stringify(bundle, null, 2) + '\n';
|
||||
if (utf8Bytes(encoded).byteLength > limits.maximumBundleBytes) {
|
||||
return invalid();
|
||||
}
|
||||
return encoded;
|
||||
};
|
||||
|
||||
const verifyClusterConsoleEvidenceBundle = async function (
|
||||
bundle,
|
||||
cryptography,
|
||||
) {
|
||||
if (!plainObject(bundle) || typeof bundle.contentDigest !== 'string') {
|
||||
return false;
|
||||
}
|
||||
const unsigned = {};
|
||||
for (const key of Object.keys(bundle)) {
|
||||
if (key !== 'contentDigest') unsigned[key] = bundle[key];
|
||||
}
|
||||
try {
|
||||
return (
|
||||
/^[0-9a-f]{64}$/u.test(bundle.contentDigest) &&
|
||||
(await sha256(
|
||||
canonicalize(unsigned),
|
||||
cryptography || globalThis.crypto,
|
||||
)) === bundle.contentDigest
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return Object.freeze({
|
||||
ClusterConsoleEvidenceBundleError,
|
||||
createClusterConsoleEvidenceBundle,
|
||||
limits,
|
||||
measureClusterConsoleEvidenceRecord,
|
||||
operations,
|
||||
schema,
|
||||
serializeClusterConsoleEvidenceBundle,
|
||||
verifyClusterConsoleEvidenceBundle,
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@
|
||||
<meta name="description" content="QingLong 3.0 Cluster 只读现场记录台" />
|
||||
<title>QingLong 3.0 · Cluster 现场记录台</title>
|
||||
<link rel="stylesheet" href="/app.css" />
|
||||
<script src="/evidence-bundle.js" defer></script>
|
||||
<script src="/app.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
@@ -109,7 +110,7 @@
|
||||
|
||||
<aside class="trust-note">
|
||||
<span>Authority boundary</span>
|
||||
<p>Cluster credential 只由本机进程从私有文件读取。浏览器无法提交任意路径,也没有 start、cancel 或 diagnose 权限入口。</p>
|
||||
<p>Cluster credential 只由本机进程从私有文件读取。浏览器无法提交任意路径,也没有 start、cancel 或 diagnose 权限入口。脱敏导出只处理本页已读事实,不补读或上传。</p>
|
||||
</aside>
|
||||
</aside>
|
||||
|
||||
@@ -119,12 +120,19 @@
|
||||
<p class="eyebrow">Explicit read ledger</p>
|
||||
<h2 id="evidence-title">本页证据账本</h2>
|
||||
</div>
|
||||
<span id="status-chip" class="status-chip" data-tone="idle">等待读取</span>
|
||||
<div class="evidence-actions">
|
||||
<span id="status-chip" class="status-chip" data-tone="idle">等待读取</span>
|
||||
<span id="ledger-meta" class="ledger-meta">0/16 条 · 0 KiB 原始事实</span>
|
||||
<div class="evidence-buttons">
|
||||
<button id="export-evidence" type="button" class="primary" disabled>导出脱敏包</button>
|
||||
<button id="clear-evidence" type="button" disabled>清空本页</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="empty-state" class="empty-state">
|
||||
<div class="evidence-rail" aria-hidden="true"><i></i><i></i><i></i><i></i></div>
|
||||
<h3>一次读取,一条可复核事实</h3>
|
||||
<p>先解锁页面并选择坐标。返回值按读取顺序保留在当前内存,reload 后清空。</p>
|
||||
<p>先解锁页面并选择坐标。最近 16 条、最多 8 MiB 原始事实只保留在当前内存,reload 后清空。</p>
|
||||
</div>
|
||||
<ol id="ledger" class="ledger" aria-live="polite" hidden></ol>
|
||||
<div id="message" class="message" role="status" aria-live="polite"></div>
|
||||
@@ -133,7 +141,7 @@
|
||||
|
||||
<footer>
|
||||
<span>Loopback only · explicit reads · zero polling</span>
|
||||
<span>QingLong 3.0 incubation / D-329</span>
|
||||
<span>QingLong 3.0 incubation / D-330</span>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user