Files
openclaw/ui/src/lib/browser-redact.ts
T
Peter Steinberger 0ab2443692 fix(ui): eliminate core contract drift (#117817)
* fix(ui): eliminate core contract drift

* fix(ui): resolve cleanup contract checks
2026-08-01 22:10:22 -07:00

85 lines
3.2 KiB
TypeScript

// Browser-safe redaction for tool details rendered by the Control UI.
import { isSensitiveUrlQueryParamName } from "@openclaw/net-policy/redact-sensitive-url";
import { sliceUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { DEFAULT_REDACT_PATTERNS } from "../../../src/logging/redact-patterns.js";
const URL_QUERY_PAIR_RE = /([?&])([^=&#\s]+)=([^&#\s"'<>]+)/gu;
const SECRET_DETAIL_PATTERNS = DEFAULT_REDACT_PATTERNS.map((source) => {
const literal = source.match(/^\/(.+)\/([gimsuy]*)$/);
if (!literal) {
return new RegExp(source, "gi");
}
const patternSource = literal[1] ?? "";
const patternFlags = literal[2] ?? "";
const flags = patternFlags.includes("g") ? patternFlags : `${patternFlags}g`;
return new RegExp(patternSource, flags);
});
function redactToken(value: string): string {
if (value.length <= 10) {
return "***";
}
// A 6/4-shaped hint is intentionally idempotent: every non-separator
// character is already inside the visible prefix or suffix budget.
return `${sliceUtf16Safe(value, 0, 6)}...${sliceUtf16Safe(value, -4)}`;
}
function redactPemBlock(block: string): string {
const lines = block.split(/\r?\n/).filter(Boolean);
if (lines.length < 2) {
return "***";
}
return `${lines[0]}\n...redacted...\n${lines[lines.length - 1]}`;
}
function redactMatch(
match: string,
groups: Array<string | undefined>,
followingText: string,
): string {
if (match.includes("PRIVATE KEY-----")) {
return redactPemBlock(match);
}
const token = groups.findLast((group) => typeof group === "string" && group.length > 0) ?? match;
// Replacement-template syntax can make a later pattern see only the leading `$` after an
// earlier pattern has already masked the value. Preserve that literal shell marker.
if (token === "$" && followingText.startsWith("`")) {
return match;
}
const masked = redactToken(token);
if (token === match) {
return masked;
}
const tokenOffset = match.lastIndexOf(token);
if (tokenOffset < 0) {
return "***";
}
return `${match.slice(0, tokenOffset)}${masked}${match.slice(tokenOffset + token.length)}`;
}
function redactUrlQueryPairs(detail: string): string {
return detail.replace(URL_QUERY_PAIR_RE, (match, boundary: string, key: string, value: string) =>
isSensitiveUrlQueryParamName(key) ? `${boundary}${key}=${redactToken(value)}` : match,
);
}
export function redactToolDetail(detail: string): string {
let redacted = redactUrlQueryPairs(detail);
for (const pattern of SECRET_DETAIL_PATTERNS) {
redacted = redacted.replace(pattern, (...args: unknown[]) => {
const match = typeof args[0] === "string" ? args[0] : "";
const offsetCandidate = args[args.length - 2];
const inputCandidate = args[args.length - 1];
const offset = typeof offsetCandidate === "number" ? offsetCandidate : -1;
const input = typeof inputCandidate === "string" ? inputCandidate : "";
const groups = args
.slice(1, -2)
.map((value) => (typeof value === "string" ? value : undefined));
return redactMatch(match, groups, offset < 0 ? "" : input.slice(offset + match.length));
});
}
return redacted;
}
export const redactToolPayloadText = redactToolDetail;