fix(ui): mask multi-line sensitive values in CodeEditor

Extend the sensitive-key decoration to cover the full logical value, not
just the rest of the current line. Adds value-extent detection for:

- TOML triple-quoted strings (multi-line literal/basic strings)
- TOML arrays ([ ... ] across multiple lines, string-aware)
- YAML block scalars (| and > with indent-based termination)
- JSON arrays and objects (balanced braces, string-aware)

Without this, a secret value spanning multiple lines (multi-line API
key, certificate, private key) was only blurred on its opening line and
the rest stayed readable when the Eye toggle was masked.

Adds regression tests for the TOML triple-quote and array cases.
Flagged by PR-Agent review on #1248.
This commit is contained in:
Tam Nhu Tran
2026-05-14 17:38:06 -04:00
parent 0ce01d4c65
commit eefb76214e
2 changed files with 172 additions and 19 deletions
+137 -19
View File
@@ -77,34 +77,152 @@ function validateToml(code: string): ValidationResult {
}
const SENSITIVE_PATTERNS: Record<'json' | 'yaml' | 'toml', RegExp> = {
json: /"([^"\\]+)"\s*:\s*(?=("[^"\\]*"|true|false|null|-?\d[\d.eE+-]*))/g,
yaml: /^\s*([A-Za-z0-9_.-]+)\s*:\s*(?=\S)/gm,
toml: /^\s*([A-Za-z0-9_.-]+)\s*=\s*(?=\S)/gm,
// 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,
};
/**
* Locate the end offset of the value that begins at `valueStart`. Handles
* multi-line TOML strings (""" … """, ''' … '''), arrays ([ … ]), YAML block
* scalars (| / >), and JSON arrays/objects. Falls back to end-of-line.
*/
function findValueEnd(
doc: EditorView['state']['doc'],
valueStart: number,
language: 'json' | 'yaml' | 'toml',
keyIndent: number
): number {
const docLen = doc.length;
if (valueStart >= docLen) return valueStart;
const lookahead = doc.sliceString(valueStart, Math.min(valueStart + 3, docLen));
if (language === 'toml') {
if (lookahead === '"""' || lookahead === "'''") {
const delim = lookahead;
const rest = doc.sliceString(valueStart + 3, docLen);
const closeIdx = rest.indexOf(delim);
return closeIdx >= 0 ? valueStart + 3 + closeIdx + 3 : docLen;
}
if (lookahead[0] === '[') {
// Track bracket depth, skipping content inside strings.
let depth = 0;
let i = valueStart;
while (i < docLen) {
const ch = doc.sliceString(i, i + 1);
if (ch === '"' || ch === "'") {
const rest = doc.sliceString(i + 1, docLen);
const closeIdx = rest.indexOf(ch);
i = closeIdx >= 0 ? i + 1 + closeIdx + 1 : docLen;
continue;
}
if (ch === '[') depth++;
else if (ch === ']') {
depth--;
if (depth === 0) return i + 1;
}
i++;
}
return docLen;
}
return doc.lineAt(valueStart).to;
}
if (language === 'yaml') {
if (lookahead[0] === '|' || lookahead[0] === '>') {
// Block scalar: include subsequent lines indented deeper than the key.
let line = doc.lineAt(valueStart);
let end = line.to;
while (line.number < doc.lines) {
const next = doc.line(line.number + 1);
const trimmed = next.text.replace(/\s+$/, '');
if (trimmed === '') {
end = next.to;
line = next;
continue;
}
const indent = next.text.search(/\S/);
if (indent <= keyIndent) break;
end = next.to;
line = next;
}
return end;
}
return doc.lineAt(valueStart).to;
}
// JSON: walk balanced braces/brackets when the value starts with one.
if (lookahead[0] === '[' || lookahead[0] === '{') {
const openCh = lookahead[0];
const closeCh = openCh === '[' ? ']' : '}';
let depth = 0;
let i = valueStart;
while (i < docLen) {
const ch = doc.sliceString(i, i + 1);
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++;
}
continue;
}
if (ch === openCh) depth++;
else if (ch === closeCh) {
depth--;
if (depth === 0) return i + 1;
}
i++;
}
return docLen;
}
return doc.lineAt(valueStart).to;
}
function buildSensitiveDecorations(
view: EditorView,
language: 'json' | 'yaml' | 'toml'
): DecorationSet {
const builder = new RangeSetBuilder<Decoration>();
const doc = view.state.doc;
// Scan the entire document (decoration ranges outside the viewport are
// dropped automatically) so a multi-line value beginning above the viewport
// still masks when its tail scrolls into view.
const text = doc.toString();
const pattern = new RegExp(SENSITIVE_PATTERNS[language].source, 'gm');
for (const { from, to } of view.visibleRanges) {
const text = view.state.doc.sliceString(from, to);
let match: RegExpExecArray | null;
while ((match = pattern.exec(text)) !== null) {
const key = match[1];
if (!isSensitiveKey(key)) continue;
const valueStart = from + match.index + match[0].length;
const line = view.state.doc.lineAt(valueStart);
const valueEnd = line.to;
const trimmed = view.state.doc.sliceString(valueStart, valueEnd).replace(/\s+$/, '');
if (!trimmed) continue;
builder.add(
valueStart,
valueStart + trimmed.length,
Decoration.mark({ class: 'cm-sensitive-mask' })
);
let match: RegExpExecArray | null;
while ((match = pattern.exec(text)) !== null) {
let key: string;
let keyIndent: number;
if (language === 'json') {
key = match[1];
keyIndent = 0;
} else {
key = match[2];
keyIndent = match[1].length;
}
if (!isSensitiveKey(key)) continue;
const valueStart = match.index + match[0].length;
if (valueStart >= doc.length) continue;
const valueEnd = findValueEnd(doc, valueStart, language, keyIndent);
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();
}
@@ -97,4 +97,39 @@ describe('CodeEditor', () => {
render(<CodeEditor value={'{ "broken": '} onChange={vi.fn()} language="json" />);
expect(screen.queryByText('Valid JSON')).not.toBeInTheDocument();
});
it('masks the entire multi-line TOML triple-quoted secret value', () => {
const value = [
'name = "demo"',
'API_KEY = """',
'secret-line-one',
'secret-line-two',
'"""',
'other = "visible"',
].join('\n');
const { container } = render(<CodeEditor value={value} onChange={vi.fn()} language="toml" />);
const masks = container.querySelectorAll('.cm-sensitive-mask');
expect(masks.length).toBeGreaterThan(0);
const masked = Array.from(masks)
.map((el) => el.textContent ?? '')
.join('');
expect(masked).toContain('secret-line-one');
expect(masked).toContain('secret-line-two');
expect(masked).not.toContain('visible');
});
it('masks an entire multi-line TOML array value bound to a sensitive key', () => {
const value = ['AUTH_TOKEN = [', ' "first",', ' "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('"first"');
expect(masked).toContain('"second"');
expect(masked).not.toContain('ok');
});
});