mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-09 03:22:40 +00:00
fix(qa): redact capture payload previews
This commit is contained in:
@@ -255,4 +255,152 @@ describe("QA Lab UI evidence render", () => {
|
||||
expect(html).not.toContain("<video controls");
|
||||
expect(html).not.toContain('data-evidence-entry-id="null"');
|
||||
});
|
||||
|
||||
it("redacts secret-like capture payload fields in raw previews", () => {
|
||||
const payload =
|
||||
'{"message":"visible context","message":"duplicate context","completion_tokens":100,"cookies":["session=abc"],"apiToken":"secret-token","tokenValue":"token-value-secret","authTokens":["auth-token-secret"],"tokens":{"refresh":"refresh-token-secret"},"AWS_SECRET_ACCESS_KEY":"aws-secret","secretAccessKey":"access-secret","x-goog-api-key":"goog-secret","nested":{"password":"secret-password"}}';
|
||||
const html = renderQaLabUi(
|
||||
evidenceState({
|
||||
activeTab: "capture",
|
||||
captureDetailView: "payload",
|
||||
capturePayloadDetailLayout: "raw",
|
||||
captureEvents: [
|
||||
{
|
||||
contentType: "application/json",
|
||||
dataText: payload,
|
||||
direction: "outbound",
|
||||
flowId: "flow-1",
|
||||
host: "api.example.test",
|
||||
id: 1,
|
||||
kind: "request",
|
||||
method: "POST",
|
||||
path: "/v1/messages",
|
||||
payloadPreview: payload,
|
||||
protocol: "https",
|
||||
provider: "mock",
|
||||
ts: 1,
|
||||
},
|
||||
],
|
||||
selectedCaptureEventKey: "1:flow-1:1:request",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(html).toContain("visible context");
|
||||
expect(html).toContain("duplicate context");
|
||||
expect(html).toContain("completion_tokens");
|
||||
expect(html).toContain("100");
|
||||
expect(html).toContain("apiToken");
|
||||
expect(html).toContain("nested");
|
||||
expect(html).toContain("[redacted]");
|
||||
expect(html).not.toContain("session=abc");
|
||||
expect(html).not.toContain("secret-token");
|
||||
expect(html).not.toContain("token-value-secret");
|
||||
expect(html).not.toContain("auth-token-secret");
|
||||
expect(html).not.toContain("refresh-token-secret");
|
||||
expect(html).not.toContain("aws-secret");
|
||||
expect(html).not.toContain("access-secret");
|
||||
expect(html).not.toContain("goog-secret");
|
||||
expect(html).not.toContain("secret-password");
|
||||
});
|
||||
|
||||
it("redacts secret-like fields when captured JSON previews are truncated", () => {
|
||||
const payload =
|
||||
'{"apiToken":"secret-token","nested":{"password":"secret-password"},"message":"visible context"';
|
||||
for (const capturePayloadDetailLayout of ["raw", "formatted"] as const) {
|
||||
const html = renderQaLabUi(
|
||||
evidenceState({
|
||||
activeTab: "capture",
|
||||
captureDetailView: "payload",
|
||||
capturePayloadDetailLayout,
|
||||
captureEvents: [
|
||||
{
|
||||
contentType: "application/json",
|
||||
dataText: payload,
|
||||
direction: "outbound",
|
||||
flowId: "flow-1",
|
||||
host: "api.example.test",
|
||||
id: 1,
|
||||
kind: "request",
|
||||
method: "POST",
|
||||
path: "/v1/messages",
|
||||
payloadPreview: payload,
|
||||
protocol: "https",
|
||||
provider: "mock",
|
||||
ts: 1,
|
||||
},
|
||||
],
|
||||
selectedCaptureEventKey: "1:flow-1:1:request",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(html).toContain("visible context");
|
||||
expect(html).toContain("[redacted]");
|
||||
expect(html).not.toContain("secret-token");
|
||||
expect(html).not.toContain("secret-password");
|
||||
}
|
||||
});
|
||||
|
||||
it("redacts secret-like SSE data fields in formatted payloads", () => {
|
||||
const payload = 'event: message\ndata: {"apiToken":"secret-token","message":"visible"}';
|
||||
const html = renderQaLabUi(
|
||||
evidenceState({
|
||||
activeTab: "capture",
|
||||
captureDetailView: "payload",
|
||||
capturePayloadDetailLayout: "formatted",
|
||||
captureEvents: [
|
||||
{
|
||||
contentType: "text/event-stream",
|
||||
dataText: payload,
|
||||
direction: "inbound",
|
||||
flowId: "flow-1",
|
||||
host: "api.example.test",
|
||||
id: 1,
|
||||
kind: "response",
|
||||
path: "/v1/messages",
|
||||
payloadPreview: payload,
|
||||
protocol: "https",
|
||||
provider: "mock",
|
||||
ts: 1,
|
||||
},
|
||||
],
|
||||
selectedCaptureEventKey: "1:flow-1:1:response",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(html).toContain("visible");
|
||||
expect(html).toContain("[redacted]");
|
||||
expect(html).not.toContain("secret-token");
|
||||
});
|
||||
|
||||
it("redacts secret-like fields when capture cuts inside a JSON value", () => {
|
||||
const payload = '{"apiToken":"secret-token';
|
||||
const html = renderQaLabUi(
|
||||
evidenceState({
|
||||
activeTab: "capture",
|
||||
captureDetailView: "payload",
|
||||
capturePayloadDetailLayout: "raw",
|
||||
captureEvents: [
|
||||
{
|
||||
contentType: "application/json",
|
||||
dataText: payload,
|
||||
direction: "outbound",
|
||||
flowId: "flow-1",
|
||||
host: "api.example.test",
|
||||
id: 1,
|
||||
kind: "request",
|
||||
method: "POST",
|
||||
path: "/v1/messages",
|
||||
payloadPreview: payload,
|
||||
protocol: "https",
|
||||
provider: "mock",
|
||||
ts: 1,
|
||||
},
|
||||
],
|
||||
selectedCaptureEventKey: "1:flow-1:1:request",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(html).toContain("[redacted]");
|
||||
expect(html).not.toContain("secret-token");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -531,9 +531,32 @@ function renderCaptureHeaders(raw: string | undefined, mode: UiState["captureHea
|
||||
);
|
||||
}
|
||||
|
||||
const NON_SECRET_CAPTURE_TOKEN_FIELDS = new Set([
|
||||
"completiontokens",
|
||||
"inputtokens",
|
||||
"maxcompletiontokens",
|
||||
"maxtokens",
|
||||
"outputtokens",
|
||||
"prompttokens",
|
||||
"reasoningtokens",
|
||||
"totaltokens",
|
||||
]);
|
||||
|
||||
function isSensitiveCaptureField(label: string): boolean {
|
||||
return /authorization|proxy-authorization|cookie|set-cookie|api[-_]?key|x[-_]?api[-_]?key|token|secret|password|session/i.test(
|
||||
label,
|
||||
const normalized = label.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
||||
const tokenMarker =
|
||||
!NON_SECRET_CAPTURE_TOKEN_FIELDS.has(normalized) &&
|
||||
normalized !== "tokenizer" &&
|
||||
normalized.includes("token");
|
||||
return (
|
||||
normalized.includes("authorization") ||
|
||||
normalized.includes("cookie") ||
|
||||
normalized.includes("apikey") ||
|
||||
normalized.includes("accesskey") ||
|
||||
normalized.includes("secret") ||
|
||||
normalized.includes("password") ||
|
||||
normalized.includes("session") ||
|
||||
tokenMarker
|
||||
);
|
||||
}
|
||||
|
||||
@@ -558,6 +581,9 @@ function redactCaptureValue(value: unknown, label?: string): unknown {
|
||||
if (typeof value === "string") {
|
||||
return redactCaptureScalar(value, label);
|
||||
}
|
||||
if (label && isSensitiveCaptureField(label)) {
|
||||
return "[redacted]";
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => redactCaptureValue(entry, label));
|
||||
}
|
||||
@@ -571,6 +597,115 @@ function redactCaptureValue(value: unknown, label?: string): unknown {
|
||||
return out;
|
||||
}
|
||||
|
||||
function readCaptureQuotedSpan(
|
||||
value: string,
|
||||
start: number,
|
||||
): { closed: boolean; end: number; raw: string; text: string } {
|
||||
const quote = value[start];
|
||||
let text = "";
|
||||
let index = start + 1;
|
||||
while (index < value.length) {
|
||||
const char = value[index];
|
||||
if (char === "\\") {
|
||||
text += value.slice(index, Math.min(index + 2, value.length));
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (char === quote) {
|
||||
return {
|
||||
closed: true,
|
||||
end: index + 1,
|
||||
raw: value.slice(start, index + 1),
|
||||
text,
|
||||
};
|
||||
}
|
||||
text += char;
|
||||
index += 1;
|
||||
}
|
||||
return {
|
||||
closed: false,
|
||||
end: value.length,
|
||||
raw: value.slice(start),
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
function skipCaptureInlineWhitespace(value: string, start: number): number {
|
||||
let index = start;
|
||||
while (/\s/.test(value[index] ?? "")) {
|
||||
index += 1;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
function consumeCaptureJsonishValue(value: string, start: number): number {
|
||||
const opener = value[start];
|
||||
if (opener === '"' || opener === "'") {
|
||||
return readCaptureQuotedSpan(value, start).end;
|
||||
}
|
||||
if (opener === "{" || opener === "[") {
|
||||
const stack = [opener === "{" ? "}" : "]"];
|
||||
let index = start + 1;
|
||||
while (index < value.length && stack.length > 0) {
|
||||
const char = value[index];
|
||||
if (char === '"' || char === "'") {
|
||||
index = readCaptureQuotedSpan(value, index).end;
|
||||
continue;
|
||||
}
|
||||
if (char === "{" || char === "[") {
|
||||
stack.push(char === "{" ? "}" : "]");
|
||||
} else if (char === stack.at(-1)) {
|
||||
stack.pop();
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
let index = start;
|
||||
while (index < value.length && !/[,\n\r}\]]/.test(value[index] ?? "")) {
|
||||
index += 1;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
function redactCaptureJsonishSecretFields(value: string): string {
|
||||
let redacted = "";
|
||||
let index = 0;
|
||||
while (index < value.length) {
|
||||
const char = value[index];
|
||||
if (char !== '"' && char !== "'") {
|
||||
redacted += char;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
const key = readCaptureQuotedSpan(value, index);
|
||||
const colonIndex = skipCaptureInlineWhitespace(value, key.end);
|
||||
if (!key.closed || value[colonIndex] !== ":" || !isSensitiveCaptureField(key.text)) {
|
||||
redacted += key.raw;
|
||||
index = key.end;
|
||||
continue;
|
||||
}
|
||||
const valueStart = skipCaptureInlineWhitespace(value, colonIndex + 1);
|
||||
const valueEnd = consumeCaptureJsonishValue(value, valueStart);
|
||||
const valueQuote =
|
||||
value[valueStart] === "'" || value[valueStart] === '"' ? value[valueStart] : '"';
|
||||
redacted += `${key.raw}${value.slice(key.end, valueStart)}${valueQuote}[redacted]${valueQuote}`;
|
||||
index = valueEnd;
|
||||
}
|
||||
return redacted;
|
||||
}
|
||||
|
||||
function redactCaptureInlineSecretPairs(value: string): string {
|
||||
return redactCaptureJsonishSecretFields(value).replace(
|
||||
/\b([A-Za-z][A-Za-z0-9_-]{0,64})=([^&\s"',}\]]+)/gu,
|
||||
(match: string, key: string) => (isSensitiveCaptureField(key) ? `${key}=[redacted]` : match),
|
||||
);
|
||||
}
|
||||
|
||||
function redactCapturePayloadPreview(payload: string): string {
|
||||
return redactCaptureScalar(redactCaptureInlineSecretPairs(payload));
|
||||
}
|
||||
|
||||
function formatCaptureFieldValue(value: unknown, label?: string): string {
|
||||
const redacted = redactCaptureValue(value, label);
|
||||
if (typeof redacted === "string") {
|
||||
@@ -596,7 +731,7 @@ function renderCaptureFormPayload(payload: string): string {
|
||||
}));
|
||||
return rows.length > 0
|
||||
? renderCaptureKeyValueGrid(rows)
|
||||
: `<pre class="report-pre capture-pre">${esc(redactCaptureScalar(payload))}</pre>`;
|
||||
: `<pre class="report-pre capture-pre">${esc(redactCapturePayloadPreview(payload))}</pre>`;
|
||||
}
|
||||
|
||||
function renderCaptureSsePayload(
|
||||
@@ -621,7 +756,10 @@ function renderCaptureSsePayload(
|
||||
const label =
|
||||
separatorIndex >= 0 ? line.slice(0, separatorIndex).trim() || "field" : "line";
|
||||
const value = separatorIndex >= 0 ? line.slice(separatorIndex + 1).trim() : line;
|
||||
return { label, value: redactCaptureScalar(value, label) };
|
||||
return {
|
||||
label,
|
||||
value: redactCaptureScalar(redactCaptureInlineSecretPairs(value), label),
|
||||
};
|
||||
});
|
||||
const eventName = rows.find((row) => row.label === "event")?.value || "message";
|
||||
const dataText = rows
|
||||
@@ -658,7 +796,7 @@ function renderCaptureSsePayload(
|
||||
});
|
||||
if (frames.length === 0) {
|
||||
return {
|
||||
body: `<pre class="report-pre capture-pre">${esc(redactCaptureScalar(payload))}</pre>`,
|
||||
body: `<pre class="report-pre capture-pre">${esc(redactCapturePayloadPreview(payload))}</pre>`,
|
||||
eventCount: 0,
|
||||
visibleCount: 0,
|
||||
};
|
||||
@@ -753,7 +891,7 @@ function renderCapturePayload(
|
||||
}
|
||||
}
|
||||
return {
|
||||
body: `<pre class="report-pre capture-pre">${esc(redactCaptureScalar(payload))}</pre>`,
|
||||
body: `<pre class="report-pre capture-pre">${esc(redactCapturePayloadPreview(payload))}</pre>`,
|
||||
mode: "text",
|
||||
byteLength,
|
||||
looksStructured: false,
|
||||
@@ -2647,7 +2785,9 @@ function renderCaptureView(state: UiState): string {
|
||||
].filter((row) => row.value.trim().length > 0)
|
||||
: [];
|
||||
const rawPayloadBody = selectedEvent?.dataText?.length
|
||||
? `<pre class="report-pre capture-pre">${esc(redactCaptureScalar(selectedEvent.dataText))}</pre>`
|
||||
? `<pre class="report-pre capture-pre">${esc(
|
||||
redactCapturePayloadPreview(selectedEvent.dataText),
|
||||
)}</pre>`
|
||||
: '<div class="empty-state">No inline payload preview for this event.</div>';
|
||||
const availableDetailViews: Array<{
|
||||
value: UiState["captureDetailView"];
|
||||
@@ -3678,7 +3818,11 @@ function renderCaptureView(state: UiState): string {
|
||||
selectedLaneEvent.errorText
|
||||
? `<div class="capture-timeline-quick-preview-error">${esc(selectedLaneEvent.errorText)}</div>`
|
||||
: selectedLaneEvent.payloadPreview
|
||||
? `<div class="capture-timeline-quick-preview-snippet">${esc(selectedLaneEvent.payloadPreview)}</div>`
|
||||
? `<div class="capture-timeline-quick-preview-snippet">${esc(
|
||||
redactCapturePayloadPreview(
|
||||
selectedLaneEvent.payloadPreview,
|
||||
),
|
||||
)}</div>`
|
||||
: ""
|
||||
}
|
||||
</div>`
|
||||
@@ -3868,7 +4012,11 @@ function renderCaptureView(state: UiState): string {
|
||||
${paired ? '<div class="capture-pair-badge">paired counterpart</div>' : ""}
|
||||
${
|
||||
event.payloadPreview
|
||||
? `<div class="capture-event-card-preview">${esc(event.payloadPreview)}</div>`
|
||||
? `<div class="capture-event-card-preview">${esc(
|
||||
redactCapturePayloadPreview(
|
||||
event.payloadPreview,
|
||||
),
|
||||
)}</div>`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
@@ -3917,7 +4065,9 @@ function renderCaptureView(state: UiState): string {
|
||||
}
|
||||
${
|
||||
event.payloadPreview
|
||||
? `<div class="capture-event-card-preview">${esc(event.payloadPreview)}</div>`
|
||||
? `<div class="capture-event-card-preview">${esc(
|
||||
redactCapturePayloadPreview(event.payloadPreview),
|
||||
)}</div>`
|
||||
: ""
|
||||
}
|
||||
${event.errorText ? `<div class="capture-error" style="margin-top:8px">${esc(event.errorText)}</div>` : ""}
|
||||
|
||||
Reference in New Issue
Block a user