203 lines
7.7 KiB
TypeScript
203 lines
7.7 KiB
TypeScript
|
|
const ESCAPE_SEQUENCE = "\\x" + "1b";
|
|||
|
|
const BELL_SEQUENCE = "\\x" + "07";
|
|||
|
|
const ANSI_CONTROL_PATTERN = new RegExp(`${ESCAPE_SEQUENCE}\\[[0-?]*[ -/]*[@-~]`, 'gu');
|
|||
|
|
const OSC_CONTROL_PATTERN = new RegExp(
|
|||
|
|
`${ESCAPE_SEQUENCE}\\][^${BELL_SEQUENCE}]*(?:${BELL_SEQUENCE}|${ESCAPE_SEQUENCE}\\\\)`,
|
|||
|
|
'gu',
|
|||
|
|
);
|
|||
|
|
const MAX_PROMPT_SECURITY_TAIL_CHARS = 2_048;
|
|||
|
|
|
|||
|
|
const SENSITIVE_ENGLISH_LABEL = [
|
|||
|
|
String.raw`pass(?:word|phrase|code)`,
|
|||
|
|
String.raw`one[\s-]?time(?:\s+(?:password|passcode|code|token))?`,
|
|||
|
|
String.raw`\botp\b`,
|
|||
|
|
String.raw`verification(?:\s+(?:code|token|passcode))?`,
|
|||
|
|
String.raw`authentication\s+(?:code|token|passcode)`,
|
|||
|
|
String.raw`security\s+(?:code|token|passcode|pin)`,
|
|||
|
|
String.raw`\bpin\b`,
|
|||
|
|
String.raw`\btoken\b`,
|
|||
|
|
String.raw`2fa`,
|
|||
|
|
String.raw`two[\s-]?factor`,
|
|||
|
|
String.raw`multi[\s-]?factor`,
|
|||
|
|
String.raw`\bmfa\b`,
|
|||
|
|
String.raw`second\s+factor`,
|
|||
|
|
String.raw`secondary(?:\s+\w+){0,3}\s+passw(?:ord)?`,
|
|||
|
|
String.raw`second(?:\s+\w+){0,3}\s+passw(?:ord)?`,
|
|||
|
|
String.raw`additional(?:\s+\w+){0,3}\s+passw(?:ord)?`,
|
|||
|
|
String.raw`re[-\s]?enter\s+passw(?:ord)?`,
|
|||
|
|
String.raw`confirm\s+passw(?:ord)?`,
|
|||
|
|
String.raw`\bedr\b`,
|
|||
|
|
String.raw`\bduo\b`,
|
|||
|
|
].join('|');
|
|||
|
|
|
|||
|
|
const SENSITIVE_CJK_LABEL = [
|
|||
|
|
'\u5bc6\u7801',
|
|||
|
|
'\u53e3\u4ee4',
|
|||
|
|
'\u52a8\u6001',
|
|||
|
|
'\u4e00\u6b21\u6027',
|
|||
|
|
'\u9a8c\u8bc1\u7801',
|
|||
|
|
'\u9a8c\u8bc1\u4fe1\u606f',
|
|||
|
|
'\u4ee4\u724c',
|
|||
|
|
'\u53cc\u56e0\u7d20',
|
|||
|
|
'\u591a\u56e0\u7d20',
|
|||
|
|
'\u77ed\u4fe1\u9a8c\u8bc1',
|
|||
|
|
'\u624b\u673a\u9a8c\u8bc1',
|
|||
|
|
'\u4e8c\u6b21',
|
|||
|
|
'\u5b89\u5168\u5bc6\u7801',
|
|||
|
|
'\u6311\u6218\u7801',
|
|||
|
|
].join('|');
|
|||
|
|
|
|||
|
|
const SENSITIVE_LABEL_PATTERN = new RegExp(
|
|||
|
|
`(?:${SENSITIVE_ENGLISH_LABEL}|${SENSITIVE_CJK_LABEL})`,
|
|||
|
|
'iu',
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
function stripTerminalControlSequences(value: string): string {
|
|||
|
|
return value.replace(OSC_CONTROL_PATTERN, '').replace(ANSI_CONTROL_PATTERN, '');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function lastLogicalLine(value: string): string {
|
|||
|
|
const plain = stripTerminalControlSequences(value);
|
|||
|
|
const boundary = Math.max(plain.lastIndexOf('\n'), plain.lastIndexOf('\r'));
|
|||
|
|
return plain.slice(boundary + 1).slice(-MAX_PROMPT_SECURITY_TAIL_CHARS);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Keep enough raw output to recognize authentication prompts split across
|
|||
|
|
* transport chunks without retaining terminal history or unbounded data.
|
|||
|
|
*/
|
|||
|
|
export function appendTerminalPromptSecurityTail(previous: string, chunk: string): string {
|
|||
|
|
const combined = `${previous}${chunk}`;
|
|||
|
|
const boundary = Math.max(combined.lastIndexOf('\n'), combined.lastIndexOf('\r'));
|
|||
|
|
return combined.slice(boundary + 1).slice(-MAX_PROMPT_SECURITY_TAIL_CHARS);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Detect a prompt-shaped authentication challenge. Vocabulary intentionally
|
|||
|
|
* matches the SSH keyboard-interactive boundary and also covers PIN/auth-code
|
|||
|
|
* variants used by local, Mosh, bastion, and device sessions.
|
|||
|
|
*/
|
|||
|
|
export function isSensitiveTerminalChallenge(value: string): boolean {
|
|||
|
|
const line = lastLogicalLine(value).trim();
|
|||
|
|
if (!line) return false;
|
|||
|
|
const label = SENSITIVE_LABEL_PATTERN.exec(line);
|
|||
|
|
if (!label) return false;
|
|||
|
|
const prefix = line.slice(0, label.index ?? 0).trim();
|
|||
|
|
const suffix = line.slice((label.index ?? 0) + label[0].length);
|
|||
|
|
if (suffix.trim().length === 0) {
|
|||
|
|
return prefix.length === 0
|
|||
|
|
|| /(?:^|\s)(?:enter|input|provide|type|scan|please|your|current|new|old)\s*$/iu.test(prefix)
|
|||
|
|
|| /(?:\u8f93\u5165|\u8bf7\u8f93\u5165|\u8bf7)\s*$/u.test(prefix);
|
|||
|
|
}
|
|||
|
|
if (/^\s+(?:for|of)\s+[^\r\n]{1,96}$/iu.test(suffix)) return true;
|
|||
|
|
return /^[^\r\n::>›»]{0,96}[::>›»]\s*[^\r\n]{0,1024}$/u.test(suffix);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type ConfirmedPromptOptions = {
|
|||
|
|
/** Network-device shells commonly use a bare host name followed by `>`. */
|
|||
|
|
allowHostStyleGreaterThan?: boolean;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Positive policy for data that may cross the ordinary completion Provider
|
|||
|
|
* boundary. Generic prompt parsing remains permissive for host UX/history,
|
|||
|
|
* while third-party Providers require a recognizable shell/device prompt.
|
|||
|
|
*/
|
|||
|
|
export function isConfirmedTerminalShellPrompt(
|
|||
|
|
promptText: string,
|
|||
|
|
options: ConfirmedPromptOptions = {},
|
|||
|
|
): boolean {
|
|||
|
|
const prompt = lastLogicalLine(promptText).trim();
|
|||
|
|
if (!prompt || isSensitiveTerminalChallenge(prompt)) return false;
|
|||
|
|
if (/[❯❮→➜➤⟩»›]/u.test(prompt)) return true;
|
|||
|
|
for (const character of prompt) {
|
|||
|
|
const code = character.charCodeAt(0);
|
|||
|
|
if (code >= 0xE000 && code <= 0xF8FF) return true;
|
|||
|
|
}
|
|||
|
|
if (/[$#%]$/u.test(prompt)) return true;
|
|||
|
|
if (!prompt.endsWith('>')) return false;
|
|||
|
|
if (/^(?:PS\s+)?[A-Za-z]:[\\/].*>$/u.test(prompt)) return true;
|
|||
|
|
if (/[@\\/~:]\S*>$/u.test(prompt)) return true;
|
|||
|
|
return options.allowHostStyleGreaterThan === true
|
|||
|
|
&& /^[A-Za-z0-9_.-]+(?:\([^)]{1,128}\))?>$/u.test(prompt);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function shouldUsePluginTerminalCompletionProvider(input: {
|
|||
|
|
sensitiveInputActive: boolean;
|
|||
|
|
promptText: string;
|
|||
|
|
allowHostStyleGreaterThan?: boolean;
|
|||
|
|
}): boolean {
|
|||
|
|
return !input.sensitiveInputActive
|
|||
|
|
&& !isSensitiveTerminalChallenge(input.promptText)
|
|||
|
|
&& isConfirmedTerminalShellPrompt(input.promptText, {
|
|||
|
|
allowHostStyleGreaterThan: input.allowHostStyleGreaterThan,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Body text before a `$` / `#` / `%` terminator that is plausible as a shell
|
|||
|
|
* PS1 (bare marker, user@host, path, shell-version, or lowercase identity).
|
|||
|
|
* Rejects English challenge labels that only happen to end with those markers
|
|||
|
|
* (`Challenge #`, `Account $`).
|
|||
|
|
*/
|
|||
|
|
function isPlausibleShellPromptBody(body: string): boolean {
|
|||
|
|
const trimmed = body.trim();
|
|||
|
|
if (!trimmed) return true;
|
|||
|
|
if (/@|[/\\~:]/u.test(trimmed)) return true;
|
|||
|
|
if (/^(?:bash|zsh|sh|fish|ksh|csh|tcsh|dash)(?:-[\d.]+)?$/iu.test(trimmed)) return true;
|
|||
|
|
// Single lowercase identity token: root, ubuntu, pi — not Title-Case labels.
|
|||
|
|
return /^[a-z0-9_.-]+$/u.test(trimmed);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* True when a confirmed shell/device prompt is followed by typed text on the
|
|||
|
|
* same logical line (e.g. `user@host:~$ lsof -i:`). Trailing `:` / `>` in that
|
|||
|
|
* typed text is ordinary command input, not an authentication boundary.
|
|||
|
|
*
|
|||
|
|
* Requires a real prompt boundary (terminator + whitespace) so mid-label
|
|||
|
|
* shapes like `Challenge #1:` stay fail-closed. For `$`/`#`/`%`, also requires
|
|||
|
|
* a plausible PS1 body so `Challenge # 1:` / `Account $ code:` stay untrusted.
|
|||
|
|
*/
|
|||
|
|
function hasTypedInputAfterConfirmedPrompt(
|
|||
|
|
line: string,
|
|||
|
|
options: ConfirmedPromptOptions = {},
|
|||
|
|
): boolean {
|
|||
|
|
for (let i = 0; i < line.length - 1; i += 1) {
|
|||
|
|
const ch = line[i];
|
|||
|
|
// Only terminators that can end a confirmed prompt without relying on a
|
|||
|
|
// glyph appearing later in the typed command.
|
|||
|
|
if (ch !== '$' && ch !== '#' && ch !== '%' && ch !== '>') continue;
|
|||
|
|
const rest = line.slice(i + 1);
|
|||
|
|
// Prompt terminators are followed by whitespace before typed input.
|
|||
|
|
if (!/^\s+\S/u.test(rest)) continue;
|
|||
|
|
const candidate = line.slice(0, i + 1);
|
|||
|
|
if (!isConfirmedTerminalShellPrompt(candidate, options)) continue;
|
|||
|
|
if ((ch === '$' || ch === '#' || ch === '%') && !isPlausibleShellPromptBody(candidate.slice(0, -1))) {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Fail closed for prompt-shaped input boundaries that are not positively
|
|||
|
|
* identified as an ordinary shell/device prompt. This protects custom PAM,
|
|||
|
|
* bastion, and appliance challenges whose labels contain no known vocabulary.
|
|||
|
|
*/
|
|||
|
|
export function isUntrustedTerminalInputPrompt(
|
|||
|
|
value: string,
|
|||
|
|
options: ConfirmedPromptOptions = {},
|
|||
|
|
): boolean {
|
|||
|
|
const prompt = lastLogicalLine(value).trim();
|
|||
|
|
if (!prompt) return false;
|
|||
|
|
if (isSensitiveTerminalChallenge(prompt)) return true;
|
|||
|
|
if (!/[::>›»]\s*$/u.test(prompt)) return false;
|
|||
|
|
// Mid-command punctuation after a real shell prompt must keep broadcasting
|
|||
|
|
// (#2709). Standalone `Label:` / `Custom>` challenges still fail closed.
|
|||
|
|
if (hasTypedInputAfterConfirmedPrompt(prompt, options)) return false;
|
|||
|
|
return !isConfirmedTerminalShellPrompt(prompt, options);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export { MAX_PROMPT_SECURITY_TAIL_CHARS };
|