diff --git a/ui/src/components/shared/code-editor.tsx b/ui/src/components/shared/code-editor.tsx index d660c308..6c2434ea 100644 --- a/ui/src/components/shared/code-editor.tsx +++ b/ui/src/components/shared/code-editor.tsx @@ -76,13 +76,13 @@ 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, +// Line-anchored key patterns for TOML/YAML. Each captures (indent, key). Keys +// may be bare, basic-string ("..."), or literal-string ('...'). JSON uses a +// dedicated state-aware scanner (`findJsonSensitiveRanges`) instead so that +// `"API_KEY":` substrings embedded inside escaped value strings are not +// mis-classified as keys. +const SENSITIVE_PATTERNS: Record<'yaml' | 'toml', RegExp> = { 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, }; @@ -123,6 +123,22 @@ function skipString(text: string, start: number, quote: '"' | "'"): number { return text.length; } +/** + * Advance past a TOML string starting at `start`. Detects triple-quoted + * multi-line strings (""" … """ and ''' … ''') so a `]` or `#` inside the + * string body cannot terminate an enclosing array or pretend to be a comment. + */ +function skipTomlString(text: string, start: number): number { + const ch = text[start]; + if (ch !== '"' && ch !== "'") return start + 1; + if (text.slice(start, start + 3) === ch + ch + ch) { + const triple = ch + ch + ch; + const closeIdx = text.indexOf(triple, start + 3); + return closeIdx >= 0 ? closeIdx + 3 : text.length; + } + return skipString(text, start, ch); +} + /** * Locate the end offset of the value that begins at `valueStart`. Handles * multi-line TOML strings (""" … """, ''' … '''), arrays ([ … ]), YAML block @@ -156,7 +172,9 @@ function findValueEnd( while (i < docLen) { const ch = text[i]; if (ch === '"' || ch === "'") { - i = skipString(text, i, ch); + // TOML strings may be triple-quoted. Skip the whole literal as one + // unit so its body cannot terminate the array. + i = skipTomlString(text, i); continue; } if (ch === '#') { @@ -224,6 +242,66 @@ function findValueEnd( return doc.lineAt(valueStart).to; } +/** + * JSON-specific scanner: walks the document character by character, only + * registering a quoted string as a key when it sits at an object-key position + * (preceded by `{` or `,`, ignoring whitespace) AND is followed by `:`. This + * avoids the regex false-positive where a `"API_KEY":` substring inside an + * escaped value string would be treated as a sensitive key. + */ +function findJsonSensitiveRanges(text: string): Array<{ valueStart: number; key: string }> { + const hits: Array<{ valueStart: number; key: string }> = []; + let i = 0; + let prevSignificant: string | null = null; // last non-whitespace structural char + while (i < text.length) { + const ch = text[i]; + if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') { + i++; + continue; + } + if (ch === '"') { + const stringStart = i; + const stringEnd = skipString(text, i, '"'); + // Skip whitespace to peek at the next significant character. + let j = stringEnd; + while ( + j < text.length && + (text[j] === ' ' || text[j] === '\t' || text[j] === '\n' || text[j] === '\r') + ) { + j++; + } + const isKeyPos = + prevSignificant === '{' || prevSignificant === ',' || prevSignificant === null; + if (isKeyPos && text[j] === ':') { + const rawKey = text.slice(stringStart + 1, stringEnd - 1); + const key = rawKey.replace(/\\(.)/g, '$1'); + // Advance past the colon and any whitespace to the value start. + let valueStart = j + 1; + while ( + valueStart < text.length && + (text[valueStart] === ' ' || + text[valueStart] === '\t' || + text[valueStart] === '\n' || + text[valueStart] === '\r') + ) { + valueStart++; + } + hits.push({ valueStart, key }); + prevSignificant = '"'; + i = j + 1; + continue; + } + // Not a key position; treat the string as a value scalar. + prevSignificant = '"'; + i = stringEnd; + continue; + } + prevSignificant = ch; + i++; + } + return hits; +} + function buildSensitiveDecorations( view: EditorView, language: 'json' | 'yaml' | 'toml' @@ -234,18 +312,28 @@ function buildSensitiveDecorations( // dropped automatically) so a multi-line value beginning above the viewport // still masks when its tail scrolls into view. const text = doc.toString(); + + if (language === 'json') { + for (const { valueStart, key } of findJsonSensitiveRanges(text)) { + if (!isSensitiveKey(key)) continue; + if (valueStart >= doc.length) continue; + const valueEnd = findValueEnd(doc, valueStart, 'json', 0); + const trimmedLen = doc.sliceString(valueStart, valueEnd).replace(/\s+$/, '').length; + if (trimmedLen === 0) continue; + builder.add( + valueStart, + valueStart + trimmedLen, + Decoration.mark({ class: 'cm-sensitive-mask' }) + ); + } + return builder.finish(); + } + const pattern = new RegExp(SENSITIVE_PATTERNS[language].source, 'gm'); let match: RegExpExecArray | null; while ((match = pattern.exec(text)) !== null) { - let key: string; - let keyIndent: number; - if (language === 'json') { - key = normalizeKey(`"${match[1]}"`); - keyIndent = 0; - } else { - key = normalizeKey(match[2]); - keyIndent = match[1].length; - } + const key = normalizeKey(match[2]); + const keyIndent = match[1].length; if (!isSensitiveKey(key)) continue; const valueStart = match.index + match[0].length; if (valueStart >= doc.length) continue; diff --git a/ui/tests/unit/components/ui/code-editor.test.tsx b/ui/tests/unit/components/ui/code-editor.test.tsx index 938039d1..578c2501 100644 --- a/ui/tests/unit/components/ui/code-editor.test.tsx +++ b/ui/tests/unit/components/ui/code-editor.test.tsx @@ -131,6 +131,34 @@ describe('CodeEditor', () => { expect(masked).not.toContain('ok'); }); + it('does not terminate TOML array masking on a `]` inside a triple-quoted string', () => { + const value = ['AUTH_TOKEN = [', ' """abc]xyz""",', ' "second",', ']', 'visible = "ok"'].join( + '\n' + ); + + const { container } = render(); + const masked = Array.from(container.querySelectorAll('.cm-sensitive-mask')) + .map((el) => el.textContent ?? '') + .join(''); + expect(masked).toContain('abc]xyz'); + expect(masked).toContain('"second"'); + expect(masked).not.toContain('ok'); + }); + + it('ignores JSON pseudo-keys embedded inside escaped value strings', () => { + const value = '{"description": "use \\"API_KEY\\": header", "API_KEY": "real-secret"}'; + + const { container } = render(); + const masked = Array.from(container.querySelectorAll('.cm-sensitive-mask')) + .map((el) => el.textContent ?? '') + .join(''); + // Only the real API_KEY value gets masked, not the embedded `API_KEY` text + // inside the `description` value. + expect(masked).toContain('real-secret'); + expect(masked).not.toContain('use '); + expect(masked).not.toContain('header'); + }); + it('does not terminate TOML array masking on a `]` inside a comment', () => { const value = [ 'AUTH_TOKEN = [',