From b6e805ffe055d340d84489ab5e3ab7876e557ca1 Mon Sep 17 00:00:00 2001 From: Dextheking1 Date: Fri, 23 Oct 2026 05:54:38 +0200 Subject: [PATCH] fail closed on interpreter inline code and variable command words in sponsored refusal walk --- .../ads/sponsored-command-refusals.test.ts | 38 ++++++++++++++ common/src/ads/sponsored-command-refusals.ts | 50 +++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/common/src/ads/sponsored-command-refusals.test.ts b/common/src/ads/sponsored-command-refusals.test.ts index c0548627ed..383094ced2 100644 --- a/common/src/ads/sponsored-command-refusals.test.ts +++ b/common/src/ads/sponsored-command-refusals.test.ts @@ -477,6 +477,44 @@ describe('a line the walk cannot finish is refused, not allowed', () => { } }) + it('refuses code handed to an interpreter as an argument', () => { + for (const command of [ + 'node -e "require(\'child_process\').execSync(\'docker compose down -v\')"', + 'bun -e "Bun.spawnSync([\'docker\',\'compose\',\'down\'])"', + 'python3 -c "import os; os.system(\'docker compose down -v\')"', + 'php -r "shell_exec(\'docker compose down -v\');"', + 'perl -e "system(\'docker compose down -v\')"', + 'ruby -e "system(\'docker compose down -v\')"', + ]) { + expect({ command, kind: kindOf(command) }).toEqual({ + command, + kind: 'unverifiable', + }) + } + }) + + it('refuses a command word built from a variable', () => { + for (const command of [ + '$D compose down -v', + '${D} compose down -v', + 'D=docker $D compose down -v', + ]) { + expect({ command, kind: kindOf(command) }).toEqual({ + command, + kind: 'unverifiable', + }) + } + }) + + it('still allows interpreters running scripts and judged modules', () => { + expectAllowed([ + 'node server.js', + 'python3 scripts/setup.py', + 'python3 -m django check', + 'php artisan migrate:status', + ]) + }) + it('refuses an -EncodedCommand that does not decode', () => { expect(kindOf('powershell -EncodedCommand !!not-base64!!', 'win32')).toBe( 'unverifiable', diff --git a/common/src/ads/sponsored-command-refusals.ts b/common/src/ads/sponsored-command-refusals.ts index 856c0b940f..8f07de6e1d 100644 --- a/common/src/ads/sponsored-command-refusals.ts +++ b/common/src/ads/sponsored-command-refusals.ts @@ -765,6 +765,21 @@ const SCRIPT_RUNNER_VALUE_OPTIONS: ReadonlySet = new Set([ ]) const PYTHONS = /^(python[\d.]*|py)$/ +/** + * Flags that hand an interpreter code as an argument instead of a command + * line: `node -e`, `python -c`, `php -r`. The code is data to the walk, not + * a command line it can read, so a spelling behind one of these flags is + * judged the way a PowerShell script file is: unverifiable. + */ +const INLINE_CODE_FLAGS: ReadonlyMap = new Map([ + ['node', ['-e', '--eval', '-p', '--print']], + ['nodejs', ['-e', '--eval', '-p', '--print']], + ['bun', ['-e', '--eval']], + ['perl', ['-e', '-E']], + ['ruby', ['-e']], + ['php', ['-r']], +]) + /** Decode `-EncodedCommand`'s base64 UTF-16LE, or null if it is not that. */ function decodePowerShellCommand(encoded: string): string | null { try { @@ -933,6 +948,20 @@ function analyzeSegment( if (found) return found } + // A command word the line itself builds, from a variable or a + // substitution, runs whatever the variable holds. The walk cannot read + // it, so it fails closed like the other unreadable shapes. + if ( + headToken.text.includes('$') || + headToken.text.includes('`') || + /^%[^%]+%$/.test(headToken.text) + ) { + return { + kind: 'unverifiable', + invocation: 'a command word built from a variable', + } + } + const linuxCommand = wslCommand(head, args, platform) if (linuxCommand !== null) { return analyzeRest(linuxCommand, 'linux', depth + 1, judge) @@ -1015,6 +1044,27 @@ function analyzeSegment( ) return analyzeSegment(rest, platform, depth + 1, judge) } + // Code handed to an interpreter as an argument is not a command line the + // walk can read. `eval` and `iex` strings are re-analysed above because + // they hold shell text; interpreter code does not, so it fails closed + // instead of passing unread. + const inlineFlags = PYTHONS.test(head) + ? ['-c'] + : INLINE_CODE_FLAGS.get(head) + if (inlineFlags) { + const flag = args.find( + (token) => + !token.quoted && + inlineFlags.some( + (candidate) => + token.text === candidate || + token.text.startsWith(`${candidate}=`), + ), + ) + if (flag) { + return { kind: 'unverifiable', invocation: `${head} ${flag.text}` } + } + } if (COMMAND_LIST_RUNNERS.has(head)) { for (const token of args) { const shorthand = /^(npm|pnpm|yarn|bun):(.+)$/i.exec(token.text)