diff --git a/ui/src/components/shared/code-editor.tsx b/ui/src/components/shared/code-editor.tsx index 1a65aed4..cccf534e 100644 --- a/ui/src/components/shared/code-editor.tsx +++ b/ui/src/components/shared/code-editor.tsx @@ -79,11 +79,50 @@ function validateToml(code: string): ValidationResult { const SENSITIVE_PATTERNS: Record<'json' | 'yaml' | 'toml', RegExp> = { // For JSON we scan the whole document text (not anchored to line starts) so // we can catch keys inside nested objects without re-parsing. - json: /"([^"\\]+)"\s*:\s*/g, - yaml: /^(\s*)([A-Za-z0-9_.-]+)\s*:\s*/gm, - toml: /^(\s*)([A-Za-z0-9_.-]+)\s*=\s*/gm, + json: /"((?:[^"\\]|\\.)+)"\s*:\s*/g, + yaml: /^(\s*)("(?:[^"\\]|\\.)*"|'[^']*'|[A-Za-z0-9_.-]+)\s*:\s*/gm, + // TOML keys may be bare (`API_KEY`), basic-string ("..."), or literal-string + // ('...'). Capture all three so quoted-key configs are not silently skipped. + toml: /^(\s*)("(?:[^"\\]|\\.)*"|'[^']*'|[A-Za-z0-9_.-]+)\s*=\s*/gm, }; +/** + * Strip the surrounding quotes (and unescape minimally for basic strings) from + * a key captured by the patterns above. + */ +function normalizeKey(raw: string): string { + if (raw.length >= 2 && raw[0] === '"' && raw[raw.length - 1] === '"') { + return raw.slice(1, -1).replace(/\\(.)/g, '$1'); + } + if (raw.length >= 2 && raw[0] === "'" && raw[raw.length - 1] === "'") { + return raw.slice(1, -1); + } + return raw; +} + +/** + * Advance past a TOML/JSON string starting at `start` (whose first char is the + * opening quote). Basic strings (") honor backslash escapes; literal strings + * (') do not. + */ +function skipString(text: string, start: number, quote: '"' | "'"): number { + let i = start + 1; + if (quote === "'") { + const idx = text.indexOf("'", i); + return idx >= 0 ? idx + 1 : text.length; + } + while (i < text.length) { + const ch = text[i]; + if (ch === '\\') { + i += 2; + continue; + } + if (ch === '"') return i + 1; + i++; + } + return text.length; +} + /** * Locate the end offset of the value that begins at `valueStart`. Handles * multi-line TOML strings (""" … """, ''' … '''), arrays ([ … ]), YAML block @@ -107,15 +146,16 @@ function findValueEnd( return closeIdx >= 0 ? valueStart + 3 + closeIdx + 3 : docLen; } if (lookahead[0] === '[') { - // Track bracket depth, skipping content inside strings. + // Track bracket depth, skipping content inside strings. Use the + // backslash-aware skipper so `\"` inside basic strings does not + // prematurely close the string scan. + const text = doc.toString(); let depth = 0; let i = valueStart; while (i < docLen) { - const ch = doc.sliceString(i, i + 1); + const ch = text[i]; if (ch === '"' || ch === "'") { - const rest = doc.sliceString(i + 1, docLen); - const closeIdx = rest.indexOf(ch); - i = closeIdx >= 0 ? i + 1 + closeIdx + 1 : docLen; + i = skipString(text, i, ch); continue; } if (ch === '[') depth++; @@ -157,25 +197,13 @@ function findValueEnd( if (lookahead[0] === '[' || lookahead[0] === '{') { const openCh = lookahead[0]; const closeCh = openCh === '[' ? ']' : '}'; + const text = doc.toString(); let depth = 0; let i = valueStart; while (i < docLen) { - const ch = doc.sliceString(i, i + 1); + const ch = text[i]; if (ch === '"') { - // Skip past JSON string, honoring backslash escapes. - i++; - while (i < docLen) { - const c = doc.sliceString(i, i + 1); - if (c === '\\') { - i += 2; - continue; - } - if (c === '"') { - i++; - break; - } - i++; - } + i = skipString(text, i, '"'); continue; } if (ch === openCh) depth++; @@ -206,10 +234,10 @@ function buildSensitiveDecorations( let key: string; let keyIndent: number; if (language === 'json') { - key = match[1]; + key = normalizeKey(`"${match[1]}"`); keyIndent = 0; } else { - key = match[2]; + key = normalizeKey(match[2]); keyIndent = match[1].length; } if (!isSensitiveKey(key)) continue; diff --git a/ui/tests/unit/components/ui/code-editor.test.tsx b/ui/tests/unit/components/ui/code-editor.test.tsx index 2dc501fa..4ac14eb1 100644 --- a/ui/tests/unit/components/ui/code-editor.test.tsx +++ b/ui/tests/unit/components/ui/code-editor.test.tsx @@ -120,6 +120,30 @@ describe('CodeEditor', () => { expect(masked).not.toContain('visible'); }); + it('masks values whose key uses a TOML quoted-key form', () => { + const value = ['"API_KEY" = "supersecret"', 'visible = "ok"'].join('\n'); + + const { container } = render(); + const masked = Array.from(container.querySelectorAll('.cm-sensitive-mask')) + .map((el) => el.textContent ?? '') + .join(''); + expect(masked).toContain('supersecret'); + expect(masked).not.toContain('ok'); + }); + + it('respects escaped quotes inside TOML array values when masking', () => { + const value = ['AUTH_TOKEN = ["a \\"quoted\\" value", "x"]', 'visible = "ok"'].join('\n'); + + const { container } = render(); + const masked = Array.from(container.querySelectorAll('.cm-sensitive-mask')) + .map((el) => el.textContent ?? '') + .join(''); + // The closing bracket must be reached; both array members and the + // unrelated `visible` line stay on their respective sides of the mask. + expect(masked).toContain('"x"'); + expect(masked).not.toContain('ok'); + }); + it('masks an entire multi-line TOML array value bound to a sensitive key', () => { const value = ['AUTH_TOKEN = [', ' "first",', ' "second",', ']', 'visible = "ok"'].join('\n');