mirror of
https://github.com/Vateron-Media/XC_VM.git
synced 2026-10-03 12:02:29 +02:00
Semgrep (php.lang.security.exec-use) blocked stopMovie: it killed the encode with a `kill -9 $(ps | grep -F <path>)` pipeline and deleted the files with `rm <path>.*`, both built from strings. The inputs were an int id and a constant path, but nothing in the code proved that to a scanner, and one careless edit would have made it injectable. Find the encodes through /proc with ProcessManager::findProcessPIDs and kill them with ProcessManager::kill; delete the files with glob() and unlink(). Behaviour is unchanged: the trailing dot still keeps movie 5 from matching movie 50, and a no-transcode movie's symlink is removed without touching its source. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0135TSeD5tJGsD9peTZskAVM
100 lines
3.6 KiB
PHP
100 lines
3.6 KiB
PHP
<?php
|
|
|
|
use PHPUnit\Framework\TestCase;
|
|
use XcVm\Domain\Stream\StreamProcess;
|
|
|
|
/**
|
|
* Stopping a movie ends the encodes writing that movie's output and, when
|
|
* forced, deletes its files — and nothing belonging to another movie whose id
|
|
* merely starts with the same digits (5 vs 50). Both run without a shell.
|
|
*/
|
|
final class StreamProcessStopMovieTest extends TestCase {
|
|
|
|
/** @var array<int> */
|
|
private array $rChildren = [];
|
|
|
|
protected function tearDown(): void {
|
|
foreach ($this->rChildren as $rPID) {
|
|
posix_kill($rPID, 9);
|
|
pcntl_waitpid($rPID, $rStatus);
|
|
}
|
|
$this->rChildren = [];
|
|
foreach (glob(VOD_PATH . '*') ?: [] as $rFile) {
|
|
@unlink($rFile);
|
|
}
|
|
}
|
|
|
|
private static function call(string $rMethod, ...$rArgs) {
|
|
$m = new ReflectionMethod(StreamProcess::class, $rMethod);
|
|
$m->setAccessible(true);
|
|
return $m->invoke(null, ...$rArgs);
|
|
}
|
|
|
|
/**
|
|
* A stand-in encode: a forked child whose command line (its process title)
|
|
* names `$rOutput`. The child only sleeps and is killed — it never returns
|
|
* into the test runner.
|
|
*/
|
|
private function encode(string $rOutput): int {
|
|
$rPID = pcntl_fork();
|
|
$this->assertGreaterThanOrEqual(0, $rPID, 'fork');
|
|
if ($rPID === 0) {
|
|
cli_set_process_title('encode ' . $rOutput);
|
|
sleep(30);
|
|
posix_kill(posix_getpid(), 9);
|
|
}
|
|
$this->rChildren[] = $rPID;
|
|
for ($i = 0; $i < 100 && strpos((string) @file_get_contents('/proc/' . $rPID . '/cmdline'), $rOutput) === false; $i++) {
|
|
usleep(10000);
|
|
}
|
|
$this->assertStringContainsString($rOutput, (string) file_get_contents('/proc/' . $rPID . '/cmdline'), 'the child shows the output path');
|
|
return $rPID;
|
|
}
|
|
|
|
private static function running(int $rPID): bool {
|
|
$rStat = @file_get_contents('/proc/' . $rPID . '/stat');
|
|
// A killed child stays a zombie (state Z) until proc_close reaps it.
|
|
return $rStat !== false && !preg_match('/^\d+ \(.*\) Z /', $rStat);
|
|
}
|
|
|
|
public function testKillsOnlyThisMoviesEncodes(): void {
|
|
$rMovie5 = $this->encode(VOD_PATH . '5.mp4');
|
|
$rMovie50 = $this->encode(VOD_PATH . '50.mp4');
|
|
|
|
$rKilled = self::call('killMovieEncodes', 5);
|
|
|
|
$this->assertSame([$rMovie5], $rKilled);
|
|
for ($i = 0; $i < 100 && self::running($rMovie5); $i++) {
|
|
usleep(10000);
|
|
}
|
|
$this->assertFalse(self::running($rMovie5), 'movie 5 encode is gone');
|
|
$this->assertTrue(self::running($rMovie50), 'movie 50 encode keeps running');
|
|
}
|
|
|
|
public function testDeletesOnlyThisMoviesFiles(): void {
|
|
foreach (['5.mp4', '5.errors', '50.mp4', '5_.pid'] as $rName) {
|
|
touch(VOD_PATH . $rName);
|
|
}
|
|
// A movie served without transcoding is a symlink to its source.
|
|
$rSource = tempnam(sys_get_temp_dir(), 'xcvm-src');
|
|
symlink($rSource, VOD_PATH . '5.mkv');
|
|
|
|
self::call('deleteMovieFiles', 5);
|
|
|
|
$this->assertFalse(is_link(VOD_PATH . '5.mkv'), 'the symlink is removed');
|
|
$this->assertFileExists($rSource, 'its source is not');
|
|
unlink($rSource);
|
|
$this->assertFileDoesNotExist(VOD_PATH . '5.mp4');
|
|
$this->assertFileDoesNotExist(VOD_PATH . '5.errors');
|
|
$this->assertFileExists(VOD_PATH . '50.mp4');
|
|
$this->assertFileExists(VOD_PATH . '5_.pid', 'the old rm pattern 5.* never matched the pid file either');
|
|
}
|
|
|
|
/** Semgrep flags any non-constant command; stopMovie must not start a shell at all. */
|
|
public function testStopMovieRunsNoShellCommand(): void {
|
|
$m = new ReflectionMethod(StreamProcess::class, 'stopMovie');
|
|
$rLines = array_slice(file($m->getFileName()), $m->getStartLine() - 1, $m->getEndLine() - $m->getStartLine() + 1);
|
|
$this->assertDoesNotMatchRegularExpression('/\b(shell_exec|exec|system|passthru|popen|proc_open)\s*\(/', implode('', $rLines));
|
|
}
|
|
}
|