fix: redact USERPROFILE support paths

This commit is contained in:
Gustavo Madeira Santana
2026-04-22 18:08:23 -04:00
parent 328a173527
commit 7f2c9f83c4
2 changed files with 113 additions and 19 deletions

View File

@@ -14,7 +14,11 @@ import {
stopDiagnosticStabilityRecorder,
} from "./diagnostic-stability.js";
import { writeDiagnosticSupportExport } from "./diagnostic-support-export.js";
import { redactTextForSupport } from "./diagnostic-support-redaction.js";
import {
redactSupportString,
redactTextForSupport,
sanitizeSupportSnapshotValue,
} from "./diagnostic-support-redaction.js";
import type { LogTailPayload } from "./log-tail.js";
async function readZipTextEntries(file: string): Promise<Record<string, string>> {
@@ -410,6 +414,47 @@ describe("diagnostic support export", () => {
}
});
it("redacts Windows USERPROFILE paths when HOME is unset", () => {
const userProfile = "C:\\Users\\support-user";
const stateDir = `${userProfile}\\AppData\\Roaming\\openclaw`;
const redaction = {
env: {
USERPROFILE: userProfile,
OPENCLAW_STATE_DIR: stateDir,
},
stateDir,
};
expect(redactSupportString(`${stateDir}\\logs\\gateway.log`, redaction)).toBe(
"$OPENCLAW_STATE_DIR\\logs\\gateway.log",
);
expect(
redactSupportString(`failed at ${userProfile}\\Documents\\snapshot-error.txt`, redaction),
).toBe("failed at ~\\Documents\\snapshot-error.txt");
const status = sanitizeSupportSnapshotValue(
{
service: {
command: {
programArguments: [
"node",
`${userProfile}\\openclaw\\dist\\index.js`,
"--config",
`${stateDir}\\openclaw.json`,
],
sourcePath: `${userProfile}\\AppData\\Local\\openclaw\\gateway-service.json`,
},
},
},
redaction,
);
const serialized = JSON.stringify(status);
expect(serialized).not.toContain("support-user");
expect(serialized).toContain("~\\\\openclaw\\\\dist\\\\index.js");
expect(serialized).toContain("$OPENCLAW_STATE_DIR\\\\openclaw.json");
expect(serialized).toContain("~\\\\AppData\\\\Local\\\\openclaw\\\\gateway-service.json");
});
it("keeps writing when status and health snapshots fail", async () => {
const fakeToken = "sk-test-support-export-secret-token-1234567890";
const outputPath = path.join(tempDir, "support-failed-snapshots.zip");

View File

@@ -40,6 +40,11 @@ type RedactSupportStringOptions = {
truncationSuffix?: string;
};
type PathRedactionPrefix = {
prefix: string;
label: string;
};
function asRecord(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
@@ -76,31 +81,75 @@ function privateMapEntryLabel(key: string): string {
return normalized.endsWith("s") ? normalized.slice(0, -1) : normalized;
}
function pathRedactionPrefixes(options: SupportRedactionContext): Array<{
prefix: string;
label: string;
}> {
const home = options.env.HOME ? path.resolve(options.env.HOME) : undefined;
return [
{ prefix: path.resolve(options.stateDir), label: "$OPENCLAW_STATE_DIR" },
...(home ? [{ prefix: home, label: "~" }] : []),
].toSorted((a, b) => b.prefix.length - a.prefix.length);
function isWindowsAbsolutePath(value: string): boolean {
return /^(?:[A-Za-z]:[\\/]|\\\\)/u.test(value);
}
function normalizePathPrefix(value: string): string {
return isWindowsAbsolutePath(value) ? path.win32.resolve(value) : path.resolve(value);
}
function addPathPrefixVariants(
prefixes: Map<string, string>,
value: string | undefined,
label: string,
): void {
if (!value) {
return;
}
const normalized = normalizePathPrefix(value);
if (!prefixes.has(normalized)) {
prefixes.set(normalized, label);
}
if (isWindowsAbsolutePath(normalized)) {
const forwardSlashPrefix = normalized.replaceAll("\\", "/");
if (!prefixes.has(forwardSlashPrefix)) {
prefixes.set(forwardSlashPrefix, label);
}
}
}
function pathRedactionPrefixes(options: SupportRedactionContext): PathRedactionPrefix[] {
const prefixes = new Map<string, string>();
addPathPrefixVariants(prefixes, options.stateDir, "$OPENCLAW_STATE_DIR");
addPathPrefixVariants(prefixes, options.env.HOME, "~");
addPathPrefixVariants(prefixes, options.env.USERPROFILE, "~");
return [...prefixes.entries()]
.map(([prefix, label]) => ({ prefix, label }))
.toSorted((a, b) => b.prefix.length - a.prefix.length);
}
function pathCandidates(file: string): string[] {
return isWindowsAbsolutePath(file)
? [path.win32.resolve(file), path.win32.resolve(file).replaceAll("\\", "/")]
: [path.resolve(file)];
}
function matchPathPrefix(file: string, prefix: string): string | undefined {
if (file === prefix) {
return "";
}
const next = file[prefix.length];
return next === "/" || next === "\\" ? file.slice(prefix.length) : undefined;
}
function isSupportAbsolutePath(value: string): boolean {
return path.isAbsolute(value) || isWindowsAbsolutePath(value);
}
export function redactPathForSupport(file: string, options: SupportRedactionContext): string {
if (file.startsWith("$")) {
return file;
}
const next = path.resolve(file);
for (const { prefix, label } of pathRedactionPrefixes(options)) {
if (next === prefix) {
return label;
}
if (next.startsWith(`${prefix}${path.sep}`)) {
return `${label}${next.slice(prefix.length)}`;
for (const next of pathCandidates(file)) {
for (const { prefix, label } of pathRedactionPrefixes(options)) {
const suffix = matchPathPrefix(next, prefix);
if (suffix !== undefined) {
return `${label}${suffix}`;
}
}
}
return redactSensitiveTextForSupport(next);
return redactSensitiveTextForSupport(pathCandidates(file)[0] ?? file);
}
function redactKnownPathPrefixesForSupport(
@@ -168,7 +217,7 @@ export function redactSupportString(
const maxLength = options.maxLength ?? MAX_SUPPORT_STRING_LENGTH;
const truncationSuffix = options.truncationSuffix ?? DEFAULT_TRUNCATION_SUFFIX;
const redacted = redactTextForSupport(value);
const pathRedacted = path.isAbsolute(redacted)
const pathRedacted = isSupportAbsolutePath(redacted)
? redactPathForSupport(redacted, redaction)
: redactKnownPathPrefixesForSupport(redacted, redaction);
if (pathRedacted.length <= maxLength) {