Repository navigation
feat(daemon): redact secrets in ACP command approval displays #2778
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,275 @@ | ||
| import { createHash } from 'node:crypto'; | ||
| import { getAcpCommandIdentity } from '@hyperneo/shared/acp'; | ||
| import { parseAcpCommandWithSpans } from '@hyperneo/shared/acp'; | ||
|
|
||
| export { getAcpCommandIdentity, parseAcpCommand } from '@hyperneo/shared/acp'; | ||
|
|
||
| /* @public - consumed by ACP provider sync in a later stack PR */ | ||
| const SECRET_ARG_NAME_PATTERN = | ||
| /(?:^|[-_])(?:token|secret|password|passphrase|credential|api[-_]?key|bearer)$/i; | ||
|
|
||
| const SECRET_HEADER_NAME_PATTERN = | ||
| /(?:^|[-_])(?:authorization|auth|cookie|session|token|secret|password|credential|api[-_]?key|bearer)$/i; | ||
|
|
||
| const SHELL_COMMAND_NAMES = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh']); | ||
| const CURL_COMMAND_NAMES = new Set(['curl']); | ||
| const ENV_COMMAND_NAMES = new Set(['env', 'export']); | ||
| const SHELL_OPERATORS = new Set(['&&', '||', ';', '|', '&', '(', ')']); | ||
|
|
||
| function commandBaseName(command: string): string { | ||
| return (command.split('/').pop() ?? command).toLowerCase(); | ||
| } | ||
|
|
||
| function isSecretArgName(name: string): boolean { | ||
| return SECRET_ARG_NAME_PATTERN.test(name.replace(/^-+/, '')); | ||
| } | ||
|
|
||
| function isSecretHeaderValue(value: string): boolean { | ||
| const headerName = value.split(':', 1)[0]?.trim() ?? ''; | ||
| return SECRET_HEADER_NAME_PATTERN.test(headerName); | ||
| } | ||
|
|
||
| function isHeaderArgName(name: string): boolean { | ||
| const stripped = name.replace(/^-+/, ''); | ||
| return /^H$/i.test(stripped) || /^header$/i.test(stripped); | ||
| } | ||
|
|
||
| function isUserArgName(name: string): boolean { | ||
| const stripped = name.replace(/^-+/, ''); | ||
| return /^u$/i.test(stripped) || /^user$/i.test(stripped); | ||
| } | ||
|
|
||
| function isSecretEnvAssignment(arg: string): boolean { | ||
| const equalsIndex = arg.indexOf('='); | ||
| if (equalsIndex <= 0) return false; | ||
| return isSecretArgName(arg.slice(0, equalsIndex)); | ||
| } | ||
|
|
||
| export function shellQuote(value: string): string { | ||
| return /^[A-Za-z0-9_./:@%+=,-]+$/.test(value) ? value : `'${value.replaceAll("'", `'\\''`)}'`; | ||
| } | ||
|
|
||
| function displayQuote(token: string): string { | ||
| if (!/\s/.test(token)) return token; | ||
| if (/[$`&|;<>()*?]/.test(token)) return token; | ||
| return shellQuote(token); | ||
| } | ||
|
|
||
| const URL_USERINFO_PATTERN = /^([A-Za-z][A-Za-z0-9+.-]*:\/\/[^:/@\s]+:)([^@/\s]+)(@.+)$/; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For a valid userinfo URL such as Useful? React with 👍 / 👎. |
||
|
|
||
| function redactUrlUserinfo(value: string): string { | ||
| const match = URL_USERINFO_PATTERN.exec(value); | ||
| return match ? `${match[1]}[redacted]${match[3]}` : value; | ||
| } | ||
|
|
||
| const SCRIPT_SENSITIVITY_PATTERN = | ||
| /(^|\s)--?[^\s'"]*(?:token|secret|password|passphrase|credential|api[-_]?key|bearer)|(^|\s)-[hu]\b/i; | ||
|
|
||
| function redactShellCommand(script: string, depth: number): string { | ||
| if (depth <= 0) { | ||
| return SCRIPT_SENSITIVITY_PATTERN.test(script) ? '[redacted script]' : script; | ||
|
Comment on lines
+67
to
+68
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Once nested Useful? React with 👍 / 👎. |
||
| } | ||
| try { | ||
| const parsed = parseAcpCommandWithSpans(script); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For a multiline script such as Useful? React with 👍 / 👎. |
||
| const tokens = [parsed.command, ...parsed.args]; | ||
| const redactedArgs = redactCommandSecrets(parsed.command, parsed.args, depth - 1, { | ||
| shellScript: true, | ||
| }); | ||
| const redactedCommand = isSecretEnvAssignment(parsed.command) | ||
| ? `${parsed.command.slice(0, parsed.command.indexOf('='))}=[redacted]` | ||
| : parsed.command; | ||
| const redactedTokens = [redactedCommand, ...redactedArgs]; | ||
| let result = script; | ||
| let changed = false; | ||
| for (let index = redactedTokens.length - 1; index >= 0; index--) { | ||
| if (redactedTokens[index] === tokens[index]) continue; | ||
| const span = parsed.rawSpans[index]; | ||
| if (!span) { | ||
| changed = false; | ||
| break; | ||
| } | ||
| const rawText = result.slice(span.start, span.end); | ||
| const at = rawText.indexOf(tokens[index]); | ||
| if (at >= 0) { | ||
| result = | ||
| result.slice(0, span.start) + | ||
| rawText.slice(0, at) + | ||
| redactedTokens[index] + | ||
| rawText.slice(at + tokens[index].length) + | ||
| result.slice(span.end); | ||
| } else { | ||
| result = result.slice(0, span.start) + redactedTokens[index] + result.slice(span.end); | ||
| } | ||
| changed = true; | ||
| } | ||
| if (changed) return result; | ||
| if (redactedTokens.some((token, index) => token !== tokens[index])) { | ||
| return redactedTokens.map(displayQuote).join(' '); | ||
| } | ||
| return script; | ||
| } catch { | ||
| return script; | ||
|
Comment on lines
+108
to
+109
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an outer ACP command is valid but its shell script is malformed or unsupported by this tokenizer—for example an inner script containing Useful? React with 👍 / 👎. |
||
| } | ||
| } | ||
|
|
||
| export function redactCommandSecrets( | ||
| command: string, | ||
| args: string[], | ||
| depth = 3, | ||
| options: { shellScript?: boolean } = {} | ||
| ): string[] { | ||
| const isEnv = ENV_COMMAND_NAMES.has(commandBaseName(command)); | ||
| const assignmentsAllowed = isEnv || options.shellScript === true; | ||
| let inLeadingAssignments = | ||
| options.shellScript === true ? command.indexOf('=') > 0 || isEnv : true; | ||
| let currentCommand = command; | ||
| let awaitingCommandWord = options.shellScript === true || isEnv; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When redacting a nested shell script such as Useful? React with 👍 / 👎. |
||
| let endOfOptions = false; | ||
| const redacted: string[] = []; | ||
| for (let index = 0; index < args.length; index++) { | ||
| const arg = args[index]; | ||
| if (options.shellScript === true && (SHELL_OPERATORS.has(arg) || /[;&|]$/.test(arg))) { | ||
| inLeadingAssignments = true; | ||
| endOfOptions = false; | ||
| awaitingCommandWord = true; | ||
| const word = arg.replace(/[;&|]+$/, ''); | ||
| if (word && !SHELL_OPERATORS.has(word)) currentCommand = word; | ||
| redacted.push(arg); | ||
| continue; | ||
| } | ||
| if (arg === '--') { | ||
| endOfOptions = true; | ||
| redacted.push(arg); | ||
| continue; | ||
| } | ||
| if (endOfOptions) { | ||
| redacted.push(redactUrlUserinfo(arg)); | ||
| continue; | ||
| } | ||
| if (!arg.startsWith('-')) { | ||
| const looksLikeUrl = /^[a-z][a-z0-9+.-]*:/i.test(arg); | ||
| if (options.shellScript === true && !looksLikeUrl && /[;&|]/.test(arg)) { | ||
| inLeadingAssignments = true; | ||
| endOfOptions = false; | ||
| awaitingCommandWord = true; | ||
| redacted.push( | ||
| arg.replace(/(^|[;&|])([A-Za-z_][\w-]*)=([^;&|\s]*)/g, (whole, pre, name) => | ||
| isSecretEnvAssignment(`${name}=`) ? `${pre}${name}=[redacted]` : whole | ||
| ) | ||
| ); | ||
| continue; | ||
| } | ||
| const isAssignmentShaped = arg.indexOf('=') > 0; | ||
| if (inLeadingAssignments && assignmentsAllowed && isSecretEnvAssignment(arg)) { | ||
| redacted.push(`${arg.slice(0, arg.indexOf('='))}=[redacted]`); | ||
| } else { | ||
| if (!isAssignmentShaped) { | ||
| inLeadingAssignments = false; | ||
| if (awaitingCommandWord) { | ||
| currentCommand = arg; | ||
| awaitingCommandWord = false; | ||
| } | ||
| } | ||
| redacted.push(redactUrlUserinfo(arg)); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For a valid wrapper command such as Useful? React with 👍 / 👎. |
||
| } | ||
| continue; | ||
| } | ||
| const tokenCommandName = commandBaseName(currentCommand); | ||
| const tokenCurl = CURL_COMMAND_NAMES.has(tokenCommandName); | ||
| const tokenShell = SHELL_COMMAND_NAMES.has(tokenCommandName); | ||
| if (tokenCurl && /^-[a-z]*$/i.test(arg)) { | ||
| const letters = arg.slice(1).toLowerCase(); | ||
| const valueFlag = letters.charAt(letters.length - 1); | ||
| const next = args[index + 1]; | ||
| if ( | ||
| (valueFlag === 'h' || valueFlag === 'u') && | ||
| next !== undefined && | ||
| !next.startsWith('--') | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When curl credentials themselves begin with two hyphens, as in Useful? React with 👍 / 👎. |
||
| ) { | ||
| if (valueFlag === 'u' || isSecretHeaderValue(next)) { | ||
| redacted.push(arg, '[redacted]'); | ||
| index++; | ||
| continue; | ||
| } | ||
| } | ||
| redacted.push(arg); | ||
| continue; | ||
| } | ||
| if (tokenCurl && /^-[a-z]*[hu]/i.test(arg)) { | ||
| const lowered = arg.toLowerCase(); | ||
| let carrierIndex = -1; | ||
| for (let scan = 1; scan < lowered.length; scan++) { | ||
| const ch = lowered[scan]; | ||
| if (ch === 'h' || ch === 'u') { | ||
| carrierIndex = scan; | ||
| break; | ||
| } | ||
| if (!/[a-z]/i.test(ch)) break; | ||
| } | ||
| if (carrierIndex > 0) { | ||
| const value = arg.slice(carrierIndex + 1); | ||
| const secret = lowered[carrierIndex] === 'u' || isSecretHeaderValue(value); | ||
| if (secret && value) { | ||
| redacted.push(`${arg.slice(0, carrierIndex + 1)}[redacted]`); | ||
| continue; | ||
|
Comment on lines
+196
to
+212
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The scan treats the first Useful? React with 👍 / 👎. |
||
| } | ||
| } | ||
| } | ||
| if (tokenShell && /^-[a-z]*c[a-z]*$/i.test(arg)) { | ||
| const next = args[index + 1]; | ||
| if (next !== undefined) { | ||
| redacted.push(arg, redactShellCommand(next, depth)); | ||
| index++; | ||
| continue; | ||
| } | ||
| } | ||
| if (tokenShell && /^-[a-z]*c/i.test(arg)) { | ||
| const cIndex = arg.toLowerCase().lastIndexOf('c'); | ||
| const attachedScript = arg.slice(cIndex + 1); | ||
| if (attachedScript && !/[a-zA-Z]/.test(attachedScript)) { | ||
| redacted.push(`${arg.slice(0, cIndex + 1)}${redactShellCommand(attachedScript, depth)}`); | ||
| continue; | ||
| } | ||
| } | ||
| const equalsIndex = arg.indexOf('='); | ||
| if (equalsIndex >= 0) { | ||
| const name = arg.slice(0, equalsIndex); | ||
| const value = arg.slice(equalsIndex + 1); | ||
| if (isSecretArgName(name)) { | ||
| redacted.push(`${name}=[redacted]`); | ||
| } else if (tokenCurl && isHeaderArgName(name) && isSecretHeaderValue(value)) { | ||
| redacted.push(`${name}=[redacted]`); | ||
| } else if (tokenCurl && isUserArgName(name)) { | ||
| redacted.push(`${name}=[redacted]`); | ||
| } else { | ||
| const redactedValue = redactUrlUserinfo(value); | ||
| redacted.push(redactedValue === value ? arg : `${name}=${redactedValue}`); | ||
| } | ||
| continue; | ||
| } | ||
| const next = args[index + 1]; | ||
| if (isSecretArgName(arg) && next !== undefined && !next.startsWith('--')) { | ||
| redacted.push(arg, '[redacted]'); | ||
| index++; | ||
| continue; | ||
| } | ||
| if (tokenCurl && isHeaderArgName(arg) && next !== undefined && isSecretHeaderValue(next)) { | ||
| redacted.push(arg, '[redacted]'); | ||
| index++; | ||
| continue; | ||
| } | ||
| if (tokenCurl && isUserArgName(arg) && next !== undefined && !next.startsWith('--')) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For Useful? React with 👍 / 👎. |
||
| redacted.push(arg, '[redacted]'); | ||
| index++; | ||
| continue; | ||
| } | ||
| redacted.push(arg); | ||
| } | ||
| return redacted; | ||
| } | ||
|
|
||
| export function getAcpCommandIdentityDigest(commandLine: string): string { | ||
| return createHash('sha256').update(getAcpCommandIdentity(commandLine)).digest('hex'); | ||
| const { command, args } = parseAcpCommandWithSpans(commandLine); | ||
| const identity = JSON.stringify([command, ...redactCommandSecrets(command, args)]); | ||
| return createHash('sha256').update(identity).digest('hex'); | ||
| } | ||
|
|
||
| const ACP_SAFE_ENV_KEYS = [ | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
On Windows, commands such as
C:\Windows\System32\curl.exe -H "Authorization: Bearer topsecret" https://apinever matchCURL_COMMAND_NAMESbecause this helper only splits on/and retains the.exesuffix. The authorization value is therefore returned unchanged; the same issue preventsbash.exe -c ...from receiving nested-script redaction. Normalize Windows path separators and executable suffixes before comparing command names.Useful? React with 👍 / 👎.