mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 08:17:11 +00:00
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:
@@ -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');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user