fix(ui): handle TOML triple-quotes in arrays and AST-aware JSON keys

PR-Agent on #1248 flagged two remaining sensitive-mask edge cases:

1. The TOML array walker stepped through triple-quoted strings one
   single quote at a time. With a body containing both quotes and
   structural characters, like """abc"]xyz""", a stray `]` inside the
   string body could escape the string-skip pair and terminate the
   array scan early. Add skipTomlString that recognizes triple-quoted
   strings ("""…""", '''…''') and skips them as one unit.

2. The JSON regex matched any "...":  pattern, so substrings inside
   escaped value strings, like "description": "use \\"API_KEY\\": header",
   could mask unrelated text. Replace the JSON branch with a state-aware
   scanner that only registers a string as a key when it sits at an
   object-key position (preceded by `{` or `,`, followed by `:`).

Adds regression tests for both cases.
This commit is contained in:
Tam Nhu Tran
2026-05-14 18:10:26 -04:00
parent a633ee0af4
commit 15a1faf3ab
2 changed files with 132 additions and 16 deletions
+104 -16
View File
@@ -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;
@@ -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(<CodeEditor value={value} onChange={vi.fn()} language="toml" />);
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(<CodeEditor value={value} onChange={vi.fn()} language="json" />);
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 = [',