mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 22:14:22 +00:00
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:
1 parent
a633ee0af4
commit
15a1faf3ab
2 files changed
+132
-16
No files matched your search
@@ -76,13 +76,13 @@ function validateToml(code: string): ValidationResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const SENSITIVE_PATTERNS: Record<'json' | 'yaml' | 'toml', RegExp> = {
|
// Line-anchored key patterns for TOML/YAML. Each captures (indent, key). Keys
|
||||||
// For JSON we scan the whole document text (not anchored to line starts) so
|
// may be bare, basic-string ("..."), or literal-string ('...'). JSON uses a
|
||||||
// we can catch keys inside nested objects without re-parsing.
|
// dedicated state-aware scanner (`findJsonSensitiveRanges`) instead so that
|
||||||
json: /"((?:[^"\\]|\\.)+)"\s*:\s*/g,
|
// `"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,
|
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,
|
toml: /^(\s*)("(?:[^"\\]|\\.)*"|'[^']*'|[A-Za-z0-9_.-]+)\s*=\s*/gm,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -123,6 +123,22 @@ function skipString(text: string, start: number, quote: '"' | "'"): number {
|
|||||||
return text.length;
|
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
|
* Locate the end offset of the value that begins at `valueStart`. Handles
|
||||||
* multi-line TOML strings (""" … """, ''' … '''), arrays ([ … ]), YAML block
|
* multi-line TOML strings (""" … """, ''' … '''), arrays ([ … ]), YAML block
|
||||||
@@ -156,7 +172,9 @@ function findValueEnd(
|
|||||||
while (i < docLen) {
|
while (i < docLen) {
|
||||||
const ch = text[i];
|
const ch = text[i];
|
||||||
if (ch === '"' || ch === "'") {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
if (ch === '#') {
|
if (ch === '#') {
|
||||||
@@ -224,6 +242,66 @@ function findValueEnd(
|
|||||||
return doc.lineAt(valueStart).to;
|
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(
|
function buildSensitiveDecorations(
|
||||||
view: EditorView,
|
view: EditorView,
|
||||||
language: 'json' | 'yaml' | 'toml'
|
language: 'json' | 'yaml' | 'toml'
|
||||||
@@ -234,18 +312,28 @@ function buildSensitiveDecorations(
|
|||||||
// dropped automatically) so a multi-line value beginning above the viewport
|
// dropped automatically) so a multi-line value beginning above the viewport
|
||||||
// still masks when its tail scrolls into view.
|
// still masks when its tail scrolls into view.
|
||||||
const text = doc.toString();
|
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');
|
const pattern = new RegExp(SENSITIVE_PATTERNS[language].source, 'gm');
|
||||||
let match: RegExpExecArray | null;
|
let match: RegExpExecArray | null;
|
||||||
while ((match = pattern.exec(text)) !== null) {
|
while ((match = pattern.exec(text)) !== null) {
|
||||||
let key: string;
|
const key = normalizeKey(match[2]);
|
||||||
let keyIndent: number;
|
const keyIndent = match[1].length;
|
||||||
if (language === 'json') {
|
|
||||||
key = normalizeKey(`"${match[1]}"`);
|
|
||||||
keyIndent = 0;
|
|
||||||
} else {
|
|
||||||
key = normalizeKey(match[2]);
|
|
||||||
keyIndent = match[1].length;
|
|
||||||
}
|
|
||||||
if (!isSensitiveKey(key)) continue;
|
if (!isSensitiveKey(key)) continue;
|
||||||
const valueStart = match.index + match[0].length;
|
const valueStart = match.index + match[0].length;
|
||||||
if (valueStart >= doc.length) continue;
|
if (valueStart >= doc.length) continue;
|
||||||
|
|||||||
@@ -131,6 +131,34 @@ describe('CodeEditor', () => {
|
|||||||
expect(masked).not.toContain('ok');
|
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', () => {
|
it('does not terminate TOML array masking on a `]` inside a comment', () => {
|
||||||
const value = [
|
const value = [
|
||||||
'AUTH_TOKEN = [',
|
'AUTH_TOKEN = [',
|
||||||
|
|||||||
Reference in new issue
Block a user