VYPR
Low severityOSV Advisory· Published Dec 30, 2025· Updated Dec 30, 2025

Composer vulnerable to ANSI sequence injection

CVE-2025-67746

Description

Composer is a dependency manager for PHP. In versions on the 2.x branch prior to 2.2.26 and 2.9.3, attackers controlling remote sources that Composer downloads from might in some way inject ANSI control characters in the terminal output of various Composer commands, causing mangled output and potentially leading to confusion or DoS of the terminal application. There is no proven exploit and this has thus a low severity but we still publish a CVE as it has potential for abuse, and we want to be on the safe side informing users that they should upgrade. Versions 2.2.26 and 2.9.3 contain a patch for the issue.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
composer/composerPackagist
>= 2.0.0, < 2.2.262.2.26
composer/composerPackagist
>= 2.3.0, < 2.9.32.9.3

Affected products

1

Patches

2
1d40a95c9d39

Merge commit from fork

https://github.com/composer/composerJordi BoggianoDec 30, 2025via ghsa
1 file changed · +41 5
  • src/Composer/IO/ConsoleIO.php+41 5 modified
    @@ -12,6 +12,7 @@
     
     namespace Composer\IO;
     
    +use Composer\Pcre\Preg;
     use Composer\Question\StrictConfirmationQuestion;
     use Symfony\Component\Console\Helper\HelperSet;
     use Symfony\Component\Console\Helper\ProgressBar;
    @@ -121,6 +122,8 @@ public function isDebug()
          */
         public function write($messages, $newline = true, $verbosity = self::NORMAL)
         {
    +        $messages = self::sanitize($messages);
    +
             $this->doWrite($messages, $newline, false, $verbosity);
         }
     
    @@ -129,6 +132,8 @@ public function write($messages, $newline = true, $verbosity = self::NORMAL)
          */
         public function writeError($messages, $newline = true, $verbosity = self::NORMAL)
         {
    +        $messages = self::sanitize($messages);
    +
             $this->doWrite($messages, $newline, true, $verbosity);
         }
     
    @@ -270,7 +275,7 @@ public function ask($question, $default = null)
         {
             /** @var \Symfony\Component\Console\Helper\QuestionHelper $helper */
             $helper = $this->helperSet->get('question');
    -        $question = new Question($question, $default);
    +        $question = new Question(self::sanitize($question), is_string($default) ? self::sanitize($default) : $default);
     
             return $helper->ask($this->input, $this->getErrorOutput(), $question);
         }
    @@ -282,7 +287,7 @@ public function askConfirmation($question, $default = true)
         {
             /** @var \Symfony\Component\Console\Helper\QuestionHelper $helper */
             $helper = $this->helperSet->get('question');
    -        $question = new StrictConfirmationQuestion($question, $default);
    +        $question = new StrictConfirmationQuestion(self::sanitize($question), is_string($default) ? self::sanitize($default) : $default);
     
             return $helper->ask($this->input, $this->getErrorOutput(), $question);
         }
    @@ -294,7 +299,7 @@ public function askAndValidate($question, $validator, $attempts = null, $default
         {
             /** @var \Symfony\Component\Console\Helper\QuestionHelper $helper */
             $helper = $this->helperSet->get('question');
    -        $question = new Question($question, $default);
    +        $question = new Question(self::sanitize($question), is_string($default) ? self::sanitize($default) : $default);
             $question->setValidator($validator);
             $question->setMaxAttempts($attempts);
     
    @@ -308,7 +313,7 @@ public function askAndHideAnswer($question)
         {
             /** @var \Symfony\Component\Console\Helper\QuestionHelper $helper */
             $helper = $this->helperSet->get('question');
    -        $question = new Question($question);
    +        $question = new Question(self::sanitize($question));
             $question->setHidden(true);
     
             return $helper->ask($this->input, $this->getErrorOutput(), $question);
    @@ -321,7 +326,7 @@ public function select($question, $choices, $default, $attempts = false, $errorM
         {
             /** @var \Symfony\Component\Console\Helper\QuestionHelper $helper */
             $helper = $this->helperSet->get('question');
    -        $question = new ChoiceQuestion($question, $choices, $default);
    +        $question = new ChoiceQuestion(self::sanitize($question), self::sanitize($choices), is_string($default) ? self::sanitize($default) : $default);
             $question->setMaxAttempts($attempts ?: null); // IOInterface requires false, and Question requires null or int
             $question->setErrorMessage($errorMessage);
             $question->setMultiselect($multiselect);
    @@ -353,4 +358,35 @@ private function getErrorOutput()
     
             return $this->output;
         }
    +
    +    /**
    +     * Sanitize string to remove control characters
    +     *
    +     * If $allowNewlines is true, \x0A (\n) and \x0D\x0A (\r\n) are let through. Single \r are still sanitized away to prevent overwriting whole lines.
    +     *
    +     * All other control chars (except NULL bytes) as well as ANSI escape sequences are removed.
    +     *
    +     * @param string|iterable<string> $messages
    +     * @return string|array<string>
    +     * @phpstan-return ($messages is string ? string : array<string>)
    +     */
    +    public static function sanitize($messages, $allowNewlines = true)
    +    {
    +        // Match ANSI escape sequences:
    +        // - CSI (Control Sequence Introducer): ESC [ params intermediate final
    +        // - OSC (Operating System Command): ESC ] ... ESC \ or BEL
    +        // - Other ESC sequences: ESC followed by any character
    +        $escapePattern = '\x1B\[[\x30-\x3F]*[\x20-\x2F]*[\x40-\x7E]|\x1B\].*?(?:\x1B\\\\|\x07)|\x1B.';
    +        $pattern = $allowNewlines ? "{{$escapePattern}|[\x01-\x09\x0B\x0C\x0E-\x1A]|\r(?!\n)}u" : "{{$escapePattern}|[\x01-\x1A]}u";
    +        if (is_string($messages)) {
    +            return Preg::replace($pattern, '', $messages);
    +        }
    +
    +        $sanitized = array();
    +        foreach ($messages as $key => $message) {
    +            $sanitized[$key] = Preg::replace($pattern, '', $message);
    +        }
    +
    +        return $sanitized;
    +    }
     }
    
5db1876a76fd

Merge commit from fork

https://github.com/composer/composerJordi BoggianoDec 30, 2025via ghsa
3 files changed · +243 7
  • src/Composer/Advisory/Auditor.php+2 2 modified
    @@ -341,7 +341,7 @@ private function outputAdvisoriesTable(ConsoleIO $io, array $advisories): void
                     $io->getTable()
                         ->setHorizontal()
                         ->setHeaders($headers)
    -                    ->addRow($row)
    +                    ->addRow(ConsoleIO::sanitize($row))
                         ->setColumnWidth(1, 80)
                         ->setColumnMaxWidth(1, 80)
                         ->render();
    @@ -412,7 +412,7 @@ private function outputAbandonedPackages(IOInterface $io, array $packages, strin
     
             foreach ($packages as $pkg) {
                 $replacement = $pkg->getReplacementPackage() !== null ? $pkg->getReplacementPackage() : 'none';
    -            $table->addRow([$this->getPackageNameWithLink($pkg), $replacement]);
    +            $table->addRow(ConsoleIO::sanitize([$this->getPackageNameWithLink($pkg), $replacement]));
             }
     
             $table->render();
    
  • src/Composer/IO/ConsoleIO.php+41 5 modified
    @@ -12,6 +12,7 @@
     
     namespace Composer\IO;
     
    +use Composer\Pcre\Preg;
     use Composer\Question\StrictConfirmationQuestion;
     use Symfony\Component\Console\Helper\HelperSet;
     use Symfony\Component\Console\Helper\ProgressBar;
    @@ -120,6 +121,8 @@ public function isDebug()
          */
         public function write($messages, bool $newline = true, int $verbosity = self::NORMAL)
         {
    +        $messages = self::sanitize($messages);
    +
             $this->doWrite($messages, $newline, false, $verbosity);
         }
     
    @@ -128,6 +131,8 @@ public function write($messages, bool $newline = true, int $verbosity = self::NO
          */
         public function writeError($messages, bool $newline = true, int $verbosity = self::NORMAL)
         {
    +        $messages = self::sanitize($messages);
    +
             $this->doWrite($messages, $newline, true, $verbosity);
         }
     
    @@ -252,7 +257,7 @@ public function ask($question, $default = null)
         {
             /** @var \Symfony\Component\Console\Helper\QuestionHelper $helper */
             $helper = $this->helperSet->get('question');
    -        $question = new Question($question, $default);
    +        $question = new Question(self::sanitize($question), is_string($default) ? self::sanitize($default) : $default);
     
             return $helper->ask($this->input, $this->getErrorOutput(), $question);
         }
    @@ -264,7 +269,7 @@ public function askConfirmation($question, $default = true)
         {
             /** @var \Symfony\Component\Console\Helper\QuestionHelper $helper */
             $helper = $this->helperSet->get('question');
    -        $question = new StrictConfirmationQuestion($question, $default);
    +        $question = new StrictConfirmationQuestion(self::sanitize($question), is_string($default) ? self::sanitize($default) : $default);
     
             return $helper->ask($this->input, $this->getErrorOutput(), $question);
         }
    @@ -276,7 +281,7 @@ public function askAndValidate($question, $validator, $attempts = null, $default
         {
             /** @var \Symfony\Component\Console\Helper\QuestionHelper $helper */
             $helper = $this->helperSet->get('question');
    -        $question = new Question($question, $default);
    +        $question = new Question(self::sanitize($question), is_string($default) ? self::sanitize($default) : $default);
             $question->setValidator($validator);
             $question->setMaxAttempts($attempts);
     
    @@ -290,7 +295,7 @@ public function askAndHideAnswer($question)
         {
             /** @var \Symfony\Component\Console\Helper\QuestionHelper $helper */
             $helper = $this->helperSet->get('question');
    -        $question = new Question($question);
    +        $question = new Question(self::sanitize($question));
             $question->setHidden(true);
     
             return $helper->ask($this->input, $this->getErrorOutput(), $question);
    @@ -303,7 +308,7 @@ public function select($question, $choices, $default, $attempts = false, $errorM
         {
             /** @var \Symfony\Component\Console\Helper\QuestionHelper $helper */
             $helper = $this->helperSet->get('question');
    -        $question = new ChoiceQuestion($question, $choices, $default);
    +        $question = new ChoiceQuestion(self::sanitize($question), self::sanitize($choices), is_string($default) ? self::sanitize($default) : $default);
             $question->setMaxAttempts($attempts ?: null); // IOInterface requires false, and Question requires null or int
             $question->setErrorMessage($errorMessage);
             $question->setMultiselect($multiselect);
    @@ -342,4 +347,35 @@ private function getErrorOutput(): OutputInterface
     
             return $this->output;
         }
    +
    +    /**
    +     * Sanitize string to remove control characters
    +     *
    +     * If $allowNewlines is true, \x0A (\n) and \x0D\x0A (\r\n) are let through. Single \r are still sanitized away to prevent overwriting whole lines.
    +     *
    +     * All other control chars (except NULL bytes) as well as ANSI escape sequences are removed.
    +     *
    +     * @param string|iterable<string> $messages
    +     * @return string|array<string>
    +     * @phpstan-return ($messages is string ? string : array<string>)
    +     */
    +    public static function sanitize($messages, bool $allowNewlines = true)
    +    {
    +        // Match ANSI escape sequences:
    +        // - CSI (Control Sequence Introducer): ESC [ params intermediate final
    +        // - OSC (Operating System Command): ESC ] ... ESC \ or BEL
    +        // - Other ESC sequences: ESC followed by any character
    +        $escapePattern = '\x1B\[[\x30-\x3F]*[\x20-\x2F]*[\x40-\x7E]|\x1B\].*?(?:\x1B\\\\|\x07)|\x1B.';
    +        $pattern = $allowNewlines ? "{{$escapePattern}|[\x01-\x09\x0B\x0C\x0E-\x1A]|\r(?!\n)}u" : "{{$escapePattern}|[\x01-\x1A]}u";
    +        if (is_string($messages)) {
    +            return Preg::replace($pattern, '', $messages);
    +        }
    +
    +        $sanitized = [];
    +        foreach ($messages as $key => $message) {
    +            $sanitized[$key] = Preg::replace($pattern, '', $message);
    +        }
    +
    +        return $sanitized;
    +    }
     }
    
  • tests/Composer/Test/IO/ConsoleIOTest.php+200 0 modified
    @@ -296,4 +296,204 @@ public function testHasAuthentication(): void
             self::assertTrue($consoleIO->hasAuthentication('repoName'));
             self::assertFalse($consoleIO->hasAuthentication('repoName2'));
         }
    +
    +    /**
    +     * @dataProvider sanitizeProvider
    +     * @param string|string[] $input
    +     * @param string|string[] $expected
    +     */
    +    public function testSanitize($input, bool $allowNewlines, $expected): void
    +    {
    +        self::assertSame($expected, ConsoleIO::sanitize($input, $allowNewlines));
    +    }
    +
    +    /**
    +     * @return array<string, array{input: string|string[], allowNewlines: bool, expected: string|string[]}>
    +     */
    +    public static function sanitizeProvider(): array
    +    {
    +        return [
    +            // String input with allowNewlines=true
    +            'string with \n allowed' => [
    +                'input' => "Hello\nWorld",
    +                'allowNewlines' => true,
    +                'expected' => "Hello\nWorld",
    +            ],
    +            'string with \r\n allowed' => [
    +                'input' => "Hello\r\nWorld",
    +                'allowNewlines' => true,
    +                'expected' => "Hello\r\nWorld",
    +            ],
    +            'string with standalone \r removed' => [
    +                'input' => "Hello\rWorld",
    +                'allowNewlines' => true,
    +                'expected' => "HelloWorld",
    +            ],
    +            'string with escape sequence removed' => [
    +                'input' => "Hello\x1B[31mWorld",
    +                'allowNewlines' => true,
    +                'expected' => "HelloWorld",
    +            ],
    +            'string with control chars removed' => [
    +                'input' => "Hello\x01\x08\x09World",
    +                'allowNewlines' => true,
    +                'expected' => "HelloWorld",
    +            ],
    +            'string with mixed control chars and newlines' => [
    +                'input' => "Line1\n\x1B[32mLine2\x08\rLine3",
    +                'allowNewlines' => true,
    +                'expected' => "Line1\nLine2Line3",
    +            ],
    +            'string with null bytes are allowed' => [
    +                'input' => "Hello\x00World",
    +                'allowNewlines' => true,
    +                'expected' => "Hello\x00World",
    +            ],
    +
    +            // String input with allowNewlines=false
    +            'string with \n removed' => [
    +                'input' => "Hello\nWorld",
    +                'allowNewlines' => false,
    +                'expected' => "HelloWorld",
    +            ],
    +            'string with \r\n removed' => [
    +                'input' => "Hello\r\nWorld",
    +                'allowNewlines' => false,
    +                'expected' => "HelloWorld",
    +            ],
    +            'string with escape sequence removed (no newlines)' => [
    +                'input' => "Hello\x1B[31mWorld",
    +                'allowNewlines' => false,
    +                'expected' => "HelloWorld",
    +            ],
    +            'string with all control chars removed' => [
    +                'input' => "Hello\x01\x08\x09\x0A\x0DWorld",
    +                'allowNewlines' => false,
    +                'expected' => "HelloWorld",
    +            ],
    +
    +            // Array input with allowNewlines=true
    +            'array with newlines allowed' => [
    +                'input' => ["Hello\nWorld", "Foo\r\nBar"],
    +                'allowNewlines' => true,
    +                'expected' => ["Hello\nWorld", "Foo\r\nBar"],
    +            ],
    +            'array with control chars removed' => [
    +                'input' => ["Hello\x1B[31mWorld", "Foo\x08Bar\r"],
    +                'allowNewlines' => true,
    +                'expected' => ["HelloWorld", "FooBar"],
    +            ],
    +
    +            // Array input with allowNewlines=false
    +            'array with newlines removed' => [
    +                'input' => ["Hello\nWorld", "Foo\r\nBar"],
    +                'allowNewlines' => false,
    +                'expected' => ["HelloWorld", "FooBar"],
    +            ],
    +            'array with all control chars removed' => [
    +                'input' => ["Test\x01\x0A", "Data\x1B[m\x0D"],
    +                'allowNewlines' => false,
    +                'expected' => ["Test", "Data"],
    +            ],
    +
    +            // Edge cases
    +            'empty string' => [
    +                'input' => '',
    +                'allowNewlines' => true,
    +                'expected' => '',
    +            ],
    +            'empty array' => [
    +                'input' => [],
    +                'allowNewlines' => true,
    +                'expected' => [],
    +            ],
    +            'string with no control chars' => [
    +                'input' => 'Hello World',
    +                'allowNewlines' => true,
    +                'expected' => 'Hello World',
    +            ],
    +            'string with unicode' => [
    +                'input' => "Hello 世界\nTest",
    +                'allowNewlines' => true,
    +                'expected' => "Hello 世界\nTest",
    +            ],
    +
    +            // Various ANSI escape sequences
    +            'CSI with multiple parameters' => [
    +                'input' => "Text\x1B[1;31;40mColored\x1B[0mNormal",
    +                'allowNewlines' => true,
    +                'expected' => "TextColoredNormal",
    +            ],
    +            'CSI SGR reset' => [
    +                'input' => "Before\x1B[mAfter",
    +                'allowNewlines' => true,
    +                'expected' => "BeforeAfter",
    +            ],
    +            'CSI cursor positioning' => [
    +                'input' => "Line\x1B[2J\x1B[H\x1B[10;5HText",
    +                'allowNewlines' => true,
    +                'expected' => "LineText",
    +            ],
    +            'OSC with BEL terminator' => [
    +                'input' => "Text\x1B]0;Window Title\x07More",
    +                'allowNewlines' => true,
    +                'expected' => "TextMore",
    +            ],
    +            'OSC with ST terminator' => [
    +                'input' => "Text\x1B]2;Title\x1B\\More",
    +                'allowNewlines' => true,
    +                'expected' => "TextMore",
    +            ],
    +            'Simple ESC sequences' => [
    +                'input' => "Text\x1B7Saved\x1B8Restored\x1BcReset",
    +                'allowNewlines' => true,
    +                'expected' => "TextSavedRestoredReset",
    +            ],
    +            'ESC D (Index)' => [
    +                'input' => "Line1\x1BDLine2",
    +                'allowNewlines' => true,
    +                'expected' => "Line1Line2",
    +            ],
    +            'ESC E (Next Line)' => [
    +                'input' => "Line1\x1BELine2",
    +                'allowNewlines' => true,
    +                'expected' => "Line1Line2",
    +            ],
    +            'ESC M (Reverse Index)' => [
    +                'input' => "Text\x1BMMore",
    +                'allowNewlines' => true,
    +                'expected' => "TextMore",
    +            ],
    +            'ESC N (SS2) and ESC O (SS3)' => [
    +                'input' => "Text\x1BNchar\x1BOanother",
    +                'allowNewlines' => true,
    +                'expected' => "Textcharanother",
    +            ],
    +            'Multiple escape sequences in sequence' => [
    +                'input' => "\x1B[1m\x1B[31m\x1B[44mBold Red on Blue\x1B[0m",
    +                'allowNewlines' => true,
    +                'expected' => "Bold Red on Blue",
    +            ],
    +            'CSI with question mark (private mode)' => [
    +                'input' => "Text\x1B[?25lHidden\x1B[?25hVisible",
    +                'allowNewlines' => true,
    +                'expected' => "TextHiddenVisible",
    +            ],
    +            'CSI erase sequences' => [
    +                'input' => "Clear\x1B[2J\x1B[K\x1B[1KScreen",
    +                'allowNewlines' => true,
    +                'expected' => "ClearScreen",
    +            ],
    +            'Hyperlink OSC 8' => [
    +                'input' => "Click \x1B]8;;https://example.com\x1B\\here\x1B]8;;\x1B\\ for link",
    +                'allowNewlines' => true,
    +                'expected' => "Click here for link",
    +            ],
    +            'Mixed content with complex sequences' => [
    +                'input' => "\x1B[1;33mWarning:\x1B[0m File\x1B[31m not\x1B[0m found\n\x1B[2KRetrying...",
    +                'allowNewlines' => true,
    +                'expected' => "Warning: File not found\nRetrying...",
    +            ],
    +        ];
    +    }
     }
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

7

News mentions

0

No linked articles in our index yet.