Files
NetMesh/domain/terminalPromptSecurity.ts

203 lines
7.7 KiB
TypeScript
Raw Normal View History

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 };