fix(ui): honor TOML quoted keys and escaped quotes in sensitive masking

Two remaining gaps in the sensitive-value detector flagged by PR-Agent
on #1248:

1. The TOML/YAML key regex only matched bare keys, so values written
   under quoted keys like `"API_KEY" = "secret"` were never masked.
   Accept basic-string and literal-string quoted keys and normalize the
   captured key (strip quotes, unescape) before isSensitiveKey.

2. The string-skip helper used indexOf for the next quote character, so
   a value like `AUTH_TOKEN = ["a \"quoted\" value", "x"]` could
   terminate scanning early and leak unrelated text or under-mask the
   secret. Introduce skipString that honors backslash escapes for basic
   strings and is used by both the TOML array walker and the JSON
   balanced-brace walker.

Adds regression tests for both edge cases.
This commit is contained in:
Tam Nhu Tran
2026-05-14 17:44:52 -04:00
parent eefb76214e
commit bac51ce1ff
2 changed files with 77 additions and 25 deletions
+53 -25
View File
@@ -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;
@@ -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(<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('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(<CodeEditor value={value} onChange={vi.fn()} language="toml" />);
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');