Files
033f0c84da v4.7 (#12478)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com>
Co-authored-by: Aditya Tripathi <aditya@climactic.co>
2026-11-06 14:25:02 +01:00

791 lines
31 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Services\ImageScan;
use App\Models\Server;
use App\Support\ValidationPatterns;
use InvalidArgumentException;
/**
* Builds the shell commands that run Syft, Grype, and regctl on a server.
*
* Each method returns one command list for `instant_remote_process()` (one `bash -se` batch). The lists
* work as root and through `parseCommandsByLineForSudo()`, and they are safe to replay after an SSH retry:
* each list first removes the container `coolify-scan-{runId}` and the work directory, and writes the
* SBOM to `{name}.json.tmp-{runId}` before it moves it into place.
*
* Output contract (stdout of the batch):
* - Every tool run prints one marker line `coolify-scan-exit {step} {code}`; steps that produce output add
* the full output size in bytes: `coolify-scan-exit {step} {code} {bytes}`. `{code}` is the exit code of
* the run: 0 success, 124 timeout, 137 killed (timeout kill or memory limit), 125 `docker run` failed
* (for example the image could not be pulled), any other value is an error of the tool. A tool whose output is
* captured (Syft, Grype, regctl) runs without `--rm`: after it stops, Docker's `State.OOMKilled` decides whether the
* memory limit stopped it (line `coolify-scan-oom {step}`, before the exit marker), and the container is removed in
* the same script. Only that line means "out of memory"; a 137 without it was a `timeout` kill. Steps: `pull-syft`,
* `pull-grype`, `pull-regctl` (prepare), `pull` (pull), `db-update`, `db-status`, `disk-db` and `disk-sboms` (diskUsage, size = bytes), `inspect`, `save` and `sbom`
* (sbomFromLocal), `sbom` (sbomFromRegistry), `grype`, `packages`, `tags`, `digest`.
* - A failed run does not stop the batch, and the container and the work directory are still removed.
* - Methods with a payload print the line `coolify-scan-payload` after the markers, then at most 10485761
* bytes of the tool output (`head -c`), so the output is cut when `bytes > OUTPUT_LIMIT`. The file that holds the
* output stops at 20 MiB (`ulimit -f`); a run that reached it reports exit code 0 with the size of the file, so it
* reads as cut output, not as a failed run. Use `parseOutput()`:
* it reads markers only before the separator, so the payload cannot fake one. Only setup commands (mkdir, chown,
* chmod) stop the batch when they fail; call `cleanup()` after every scan, also after an exception.
* - `inspect`: one line `{Id}|{RepoDigest or empty}|{os}/{arch}|{size}`. `diskFree`: `{docker root free} {scan dir free}` in bytes.
*
* Containers get only a scratch directory as writable mount. The host never reads, moves, or changes
* the owner of a file that a container could write: tool output goes through stdout into root-owned
* files, and the work directory is removed with `rm -rf`. A `trap` cannot be used: the sudo parser
* would turn it (and `W=$(mktemp ...)`) into `sudo trap`, so the work directory is
* `/data/coolify/image-scans/tmp-{runId}`.
*/
class ScannerCommands
{
public const BASE_DIR = '/data/coolify/image-scans';
public const SBOM_DIR = self::BASE_DIR.'/sboms';
public const DB_VOLUME = 'coolify-grype-db';
public const OUTPUT_LIMIT = 10485760;
/**
* Tool output written to disk stops at this size (`ulimit -f`, in the 512-byte blocks that dash, bash in POSIX
* mode, and BusyBox sh use): 20 MiB for captured output, 1 GiB for an SBOM.
*/
public const OUTPUT_FILE_LIMIT_BLOCKS = 40960;
public const SBOM_FILE_LIMIT_BLOCKS = 2097152;
public const MARKER = 'coolify-scan-exit';
public const PAYLOAD_SEPARATOR = 'coolify-scan-payload';
public const OOM_MARKER = 'coolify-scan-oom';
private const SCANNER_USER = '65534:65534';
private const TIMEOUT_SBOM = 600;
private const TIMEOUT_GRYPE = 300;
private const TIMEOUT_REGISTRY = 30;
private const TIMEOUT_DB_UPDATE = 900;
private const TIMEOUT_DB_STATUS = 60;
private const TIMEOUT_PULL = 600;
private const TIMEOUT_VOLUME_SETUP = 120;
public function __construct(public readonly Server $server, public readonly string $runId)
{
if (preg_match('/\A[a-z0-9]{8,32}\z/', $runId) !== 1) {
throw new InvalidArgumentException('Invalid scan run ID.');
}
}
/**
* Creates the SBOM directory, pulls the scanner images, and sets up the Grype database volume.
*
* @return list<string>
*/
public function prepare(): array
{
return [
$this->removeContainer(),
...$this->sbomDirectory(),
...array_map(
fn (string $tool) => $this->step("pull-{$tool}", 'timeout -k 10 '.self::TIMEOUT_PULL.' docker pull '.escapeshellarg($this->image($tool)).' >/dev/null 2>&1'),
['syft', 'grype', 'regctl', 'init'],
),
$this->databaseVolume(),
];
}
/**
* @return list<string>
*/
public function dbUpdate(?string $dbUrl): array
{
$env = ['GRYPE_DB_CACHE_DIR' => '/db', 'TMPDIR' => '/db/tmp'];
if ($dbUrl !== null) {
$env['GRYPE_DB_UPDATE_URL'] = $this->validDatabaseUrl($dbUrl);
}
return [
...$this->start(['scratch']),
...$this->ownWorkDir(),
$this->databaseVolume(),
$this->step('db-update', $this->dockerRun('grype', self::TIMEOUT_DB_UPDATE, true, $env, ['type=volume,src='.self::DB_VOLUME.',dst=/db'], ['db', 'update']).' >/dev/null 2>&1'),
...$this->finish(),
];
}
/**
* Prints `grype db status -o json` (database build date and schema) as payload.
*
* @return list<string>
*/
public function dbStatus(): array
{
return $this->capture('db-status', $this->dockerRun('grype', self::TIMEOUT_DB_STATUS, false, $this->grypeDatabaseEnv(), [$this->databaseMount()], ['db', 'status', '-o', 'json']));
}
/**
* Pulls an image that is not on the server yet, with the registry login of the server user.
*
* @return list<string>
*/
public function pull(string $imageRef): array
{
$ref = escapeshellarg((string) ImageRef::parse($imageRef));
return [
$this->removeContainer(),
$this->step('pull', serverDockerConfigPrefix($this->server).'timeout -k 10 '.self::TIMEOUT_PULL." docker pull {$ref} >/dev/null 2>&1"),
];
}
/**
* @return list<string>
*/
public function inspect(string $imageRef): array
{
$ref = escapeshellarg((string) ImageRef::parse($imageRef));
$format = escapeshellarg('{{.Id}}|{{if .RepoDigests}}{{index .RepoDigests 0}}{{end}}|{{.Os}}/{{.Architecture}}|{{.Size}}');
return [
$this->removeContainer(),
$this->step('inspect', "docker image inspect --format '{{.Id}}' {$ref} >/dev/null 2>&1"),
'echo '.self::PAYLOAD_SEPARATOR,
"docker image inspect --format {$format} {$ref} 2>/dev/null || true",
];
}
/**
* @return list<string>
*/
public function diskFree(): array
{
$base = escapeshellarg(self::BASE_DIR);
return [
$this->removeContainer(),
"mkdir -p -m 0755 {$base}",
"df -P -B1 \"\$(docker info -f '{{.DockerRootDir}}')\" | awk 'NR==2 {printf \"%s \", \$4}'",
"df -P -B1 {$base} | awk 'NR==2 {print \$4}'",
];
}
/**
* Prints the bytes that scanner data uses on the server: `coolify-scan-exit disk-db 0 {bytes}` for the
* Grype database volume and `coolify-scan-exit disk-sboms 0 {bytes}` for `/data/coolify/image-scans`.
* A missing volume or directory counts as 0 bytes.
*
* @return list<string>
*/
public function diskUsage(): array
{
$mountpoint = "\"\$(docker volume inspect -f '{{.Mountpoint}}' ".self::DB_VOLUME.' 2>/dev/null)"';
return [
$this->removeContainer(),
$this->shell($this->usageMarker('disk-db', $mountpoint)),
$this->shell($this->usageMarker('disk-sboms', escapeshellarg(self::BASE_DIR))),
];
}
/**
* Saves a local image by its ID (not by $imageRef: a tag can move after inspect) and writes its SBOM
* to `sboms/{imageIdHex}.json`. $imageRef is only validated.
*
* @return list<string>
*/
public function sbomFromLocal(string $imageRef, string $imageIdHex): array
{
ImageRef::parse($imageRef);
if (preg_match('/\A[a-f0-9]{64}\z/', $imageIdHex) !== 1) {
throw new InvalidArgumentException('Invalid image ID.');
}
$archive = $this->workDir().'/in/image.tar';
return [
$this->removeContainer(),
'rm -rf '.escapeshellarg($this->workDir()).' || true',
...$this->sbomDirectory(),
'rm -f '.escapeshellarg($this->sbomTmpPath($imageIdHex)).' || true',
'mkdir -p -m 0700 '.escapeshellarg($this->workDir().'/in').' '.escapeshellarg($this->workDir().'/scratch'),
$this->step('save', 'timeout -k 10 '.self::TIMEOUT_SBOM.' docker save -o '.escapeshellarg($archive).' '.escapeshellarg('sha256:'.$imageIdHex).' >/dev/null 2>&1'),
...$this->ownWorkDir(),
$this->writeSbom($imageIdHex, $this->dockerRun('syft', self::TIMEOUT_SBOM, false, [], ['type=bind,src='.$this->workDir().'/in,dst=/in,readonly'], ['scan', 'docker-archive:/in/image.tar', '-o', 'syft-json', '-q'])),
...$this->finish(['rm -f '.escapeshellarg($this->sbomTmpPath($imageIdHex)).' || true']),
];
}
/**
* Writes the SBOM of a registry image to `sboms/{outName}.json` (network on, no socket).
*
* @return list<string>
*/
public function sbomFromRegistry(string $imageRef, string $platform, string $outName, ?string $authConfigB64): array
{
$parsed = ImageRef::parse($imageRef);
$ref = (string) $parsed;
$platform = $this->validPlatform($platform);
$this->validSbomName($outName);
$auth = $this->authConfig($authConfigB64, $parsed);
return [
$this->removeContainer(),
'rm -rf '.escapeshellarg($this->workDir()).' || true',
...$this->sbomDirectory(),
'rm -f '.escapeshellarg($this->sbomTmpPath($outName)).' || true',
...$this->workDirSetup($auth),
$this->writeSbom($outName, $this->dockerRun('syft', self::TIMEOUT_SBOM, true, $this->authEnv($auth), $this->authMounts($auth), ['scan', '--platform', $platform, 'registry:'.$ref, '-o', 'syft-json', '-q'])),
...$this->finish(['rm -f '.escapeshellarg($this->sbomTmpPath($outName)).' || true']),
];
}
/**
* Matches a stored SBOM against the cached database; payload is the NDJSON of `grype.tmpl`.
*
* @return list<string>
*/
public function grype(string $sbomName): array
{
$this->validSbomName($sbomName);
return $this->capture('grype', $this->dockerRun('grype', self::TIMEOUT_GRYPE, false, $this->grypeDatabaseEnv(), [
$this->databaseMount(),
$this->sbomMount($sbomName),
'type=bind,src='.$this->workDir().'/tpl,dst=/tpl,readonly',
], ['sbom:/sbom/sbom.json', '-o', 'template', '-t', '/tpl/grype.tmpl', '-q']), 'grype.tmpl');
}
/**
* Lists the packages of a stored SBOM; payload is the NDJSON of `syft.tmpl`.
*
* @return list<string>
*/
public function packages(string $sbomName): array
{
$this->validSbomName($sbomName);
return $this->capture('packages', $this->dockerRun('syft', self::TIMEOUT_GRYPE, false, [], [
$this->sbomMount($sbomName),
'type=bind,src='.$this->workDir().'/tpl,dst=/tpl,readonly',
], ['convert', '/sbom/sbom.json', '-o', 'template', '-t', '/tpl/syft.tmpl', '-q']), 'syft.tmpl');
}
/**
* Lists the tags of a repository, one per line.
*
* @return list<string>
*/
public function tags(string $repository, ?string $authConfigB64): array
{
$parsed = ImageRef::parse($repository);
$name = $parsed->qualifiedName();
$auth = $this->authConfig($authConfigB64, $parsed);
return $this->capture('tags', $this->dockerRun('regctl', self::TIMEOUT_REGISTRY, true, $this->authEnv($auth), $this->authMounts($auth), ['tag', 'ls', $name]), null, $auth);
}
/**
* Prints the registry digest of an image (the index digest, or the platform manifest digest with $platform).
*
* @return list<string>
*/
public function digest(string $imageRef, ?string $platform, ?string $authConfigB64): array
{
$parsed = ImageRef::parse($imageRef);
$ref = (string) $parsed;
$args = ['image', 'digest'];
if ($platform !== null) {
array_push($args, '--platform', $this->validPlatform($platform));
}
$auth = $this->authConfig($authConfigB64, $parsed);
return $this->capture('digest', $this->dockerRun('regctl', self::TIMEOUT_REGISTRY, true, $this->authEnv($auth), $this->authMounts($auth), [...$args, $ref]), null, $auth);
}
/**
* Removes the container and the work directory of this run, and leftovers of runs that crashed over an hour ago.
*
* @return list<string>
*/
public function cleanup(): array
{
return [
$this->removeContainer(),
'rm -rf '.escapeshellarg($this->workDir()).' || true',
'find '.escapeshellarg(self::BASE_DIR)." -maxdepth 1 -name 'tmp-*' -mmin +60 -exec rm -rf {} + 2>/dev/null || true",
'find '.escapeshellarg(self::SBOM_DIR)." -maxdepth 1 -name '*.json.tmp-*' -mmin +60 -delete 2>/dev/null || true",
];
}
/**
* Removes all SBOMs, the Grype database volume, and the scanner images from the server.
*
* @return list<string>
*/
public function deleteScannerData(): array
{
return [
$this->removeContainer(),
'rm -rf '.escapeshellarg(self::BASE_DIR).' || true',
'docker volume rm -f '.self::DB_VOLUME.' >/dev/null 2>&1 || true',
'docker image rm '.implode(' ', array_map(fn (string $tool) => escapeshellarg($this->image($tool)), ['syft', 'grype', 'regctl'])).' >/dev/null 2>&1 || true',
];
}
/**
* Prints `coolify-scan-exit containers {code}`, then one JSON line per running container as payload:
* `{"id": image ID, "ref": image ref, "labels": {…}}`.
*
* @return list<string>
*/
public function runningContainers(): array
{
$format = escapeshellarg('{"id":{{json .Image}},"ref":{{json .Config.Image}},"labels":{{json .Config.Labels}}}');
return [
$this->removeContainer(),
$this->shell('ids=$(docker ps -q --no-trunc 2>/dev/null); rc=$?; echo "'.self::MARKER.' containers $rc"; echo '.self::PAYLOAD_SEPARATOR.'; if [ -n "$ids" ]; then docker inspect --format '.$format.' $ids 2>/dev/null | head -c '.(self::OUTPUT_LIMIT + 1).'; fi'),
];
}
/**
* Removes the SBOM files of the given image IDs (`sha256:{hex}`) and prints `coolify-scan-exit sbom-delete {code}`.
*
* @param list<string> $imageIds
* @return list<string>
*/
public function deleteSboms(array $imageIds): array
{
if ($imageIds === []) {
throw new InvalidArgumentException('No SBOM to delete.');
}
$paths = array_map(function (string $imageId): string {
if (preg_match('/\Asha256:([a-f0-9]{64})\z/', $imageId, $id) !== 1) {
throw new InvalidArgumentException('Invalid image ID.');
}
return escapeshellarg($this->sbomPath($id[1]));
}, $imageIds);
return [
$this->removeContainer(),
$this->shell('rm -f '.implode(' ', $paths).'; echo "'.self::MARKER.' sbom-delete $?"'),
];
}
/**
* Splits batch output into the marker lines before `coolify-scan-payload` and the payload after it. `oom` lists
* the steps whose container Docker stopped at the memory limit.
*
* @return array{exit_codes: array<string, int>, sizes: array<string, int>, oom: list<string>, payload: string}
*/
public static function parseOutput(?string $output): array
{
$lines = array_map(fn (string $line) => rtrim($line, "\r"), explode("\n", (string) $output));
$separator = array_search(self::PAYLOAD_SEPARATOR, $lines, true);
$exitCodes = [];
$sizes = [];
$oom = [];
foreach ($separator === false ? $lines : array_slice($lines, 0, $separator) as $line) {
if (preg_match('/\A'.self::OOM_MARKER.' ([a-z-]+)\z/', $line, $matches) === 1) {
$oom[] = $matches[1];
continue;
}
if (preg_match('/\A'.self::MARKER.' ([a-z-]+) (\d+)(?: +(\d+))?\z/', $line, $matches) !== 1) {
continue;
}
$exitCodes[$matches[1]] = (int) $matches[2];
if (isset($matches[3])) {
$sizes[$matches[1]] = (int) $matches[3];
}
}
return [
'exit_codes' => $exitCodes,
'sizes' => $sizes,
'oom' => array_values(array_unique($oom)),
'payload' => $separator === false ? '' : implode("\n", array_slice($lines, $separator + 1)),
];
}
private function containerName(): string
{
return 'coolify-scan-'.$this->runId;
}
private function workDir(): string
{
return self::BASE_DIR.'/tmp-'.$this->runId;
}
private function sbomPath(string $name): string
{
return self::SBOM_DIR.'/'.$name.'.json';
}
/**
* Per run, so a replay or a parallel run never moves another run's partial file into place.
*/
private function sbomTmpPath(string $name): string
{
return $this->sbomPath($name).'.tmp-'.$this->runId;
}
private function removeContainer(): string
{
return 'docker rm -f '.$this->containerName().' >/dev/null 2>&1 || true';
}
/**
* Shell script part that runs right after a `dockerRun()` (no `--rm`): prints `coolify-scan-oom {step}` when Docker
* stopped the container at the memory limit, then removes the container. Keeps `$?` of the run in `rc`.
*/
private function oomCheck(string $step): string
{
$name = $this->containerName();
return "if [ \"\$(docker inspect -f '{{.State.OOMKilled}}' {$name} 2>/dev/null)\" = true ]; then echo '".self::OOM_MARKER." {$step}'; fi; docker rm -f {$name} >/dev/null 2>&1";
}
private function image(string $tool): string
{
$repo = config("constants.image_scan.images.{$tool}.repo");
$digest = config("constants.image_scan.images.{$tool}.digest");
if (! is_string($repo) || ! is_string($digest) || ! ValidationPatterns::isValidDockerImageName($repo) || $repo === '' || ! ImageRef::isValidDigest($digest)) {
throw new InvalidArgumentException("The {$tool} scanner image is not pinned to a sha256 digest.");
}
return $repo.'@'.$digest;
}
/**
* `if` keeps a failed run from stopping the batch; `$?` in `else` is the exit code of the run.
*/
private function step(string $step, string $command): string
{
return "if {$command}; then echo '".self::MARKER." {$step} 0'; else echo \"".self::MARKER." {$step} \$?\"; fi";
}
/**
* A `timeout`-wrapped `docker run` with the hardening flags. Values are escaped; mounts use `--mount`, which fails on a missing source.
* Without `--rm`, so `oomCheck()` can read why the container stopped; every list removes the container afterwards.
*
* @param array<string, string> $env
* @param list<string> $mounts
* @param list<string> $args
*/
private function dockerRun(string $tool, int $timeout, bool $network, array $env, array $mounts, array $args): string
{
$env = array_merge([
'SYFT_CHECK_FOR_APP_UPDATE' => 'false',
// By default Syft runs NumCPU × 4 workers (`--cpus` does not lower NumCPU) and records metadata of every
// file a package owns. Together they need more than the 1 GB limit for an app image with many files
// (Laravel with vendor and node_modules). Grype and the SBOM use only the packages, which stay the same.
'SYFT_PARALLELISM' => '1',
'SYFT_FILE_METADATA_SELECTION' => 'none',
'GRYPE_CHECK_FOR_APP_UPDATE' => 'false',
'TMPDIR' => '/scratch',
'XDG_CACHE_HOME' => '/scratch/cache',
], $env);
$mounts = ['type=bind,src='.$this->workDir().'/scratch,dst=/scratch', ...$mounts];
$parts = [
"timeout -k 10 {$timeout} docker run",
'--name '.$this->containerName(),
'--cap-drop ALL --security-opt no-new-privileges --read-only --pids-limit 256',
'--user '.self::SCANNER_USER.' --cpus 1 --memory 1g --memory-swap 1g --log-driver none',
];
if (! $network) {
$parts[] = '--network none';
}
foreach ($env as $key => $value) {
$parts[] = '-e '.escapeshellarg("{$key}={$value}");
}
foreach ($mounts as $mount) {
$parts[] = '--mount '.escapeshellarg($mount);
}
$parts[] = escapeshellarg($this->image($tool));
foreach ($args as $arg) {
$parts[] = escapeshellarg($arg);
}
return implode(' ', $parts);
}
/**
* Runs a tool whose stdout is the payload: stdout goes to a root-owned file in the work directory
* (not mounted into the container), then at most 10485761 bytes of it are printed.
*
* @param array<string, mixed>|null $auth
* @return list<string>
*/
private function capture(string $step, string $dockerRun, ?string $template = null, ?array $auth = null): array
{
$out = escapeshellarg($this->workDir().'/out');
$commands = [...$this->start($template === null ? ['scratch'] : ['scratch', 'tpl'])];
if ($template !== null) {
$commands[] = $this->writeFile(file_get_contents(resource_path("image-scan/{$template}")), $this->workDir()."/tpl/{$template}");
}
if ($auth !== null) {
$commands = [...$commands, ...$this->authSetup($auth)];
}
return [
...$commands,
...$this->ownWorkDir($auth !== null),
$this->shell('ulimit -f '.self::OUTPUT_FILE_LIMIT_BLOCKS."; {$dockerRun} > {$out} 2>/dev/null; rc=\$?; {$this->oomCheck($step)}; size=\$(wc -c < {$out} 2>/dev/null | tr -cd 0-9); size=\${size:-0}; if [ \"\$size\" -ge ".(self::OUTPUT_FILE_LIMIT_BLOCKS * 512).' ]; then rc=0; fi; echo "'.self::MARKER." {$step} \$rc \$size\""),
...$this->finish(['echo '.self::PAYLOAD_SEPARATOR, 'head -c '.(self::OUTPUT_LIMIT + 1)." {$out} 2>/dev/null || true"]),
];
}
/**
* Syft writes the SBOM to stdout; only a complete run is moved into place. An SBOM over the file limit fails.
*/
private function writeSbom(string $name, string $dockerRun): string
{
$tmp = escapeshellarg($this->sbomTmpPath($name));
$final = escapeshellarg($this->sbomPath($name));
return $this->shell('ulimit -f '.self::SBOM_FILE_LIMIT_BLOCKS."; {$dockerRun} > {$tmp} 2>/dev/null; rc=\$?; {$this->oomCheck('sbom')}; if [ \$rc -eq 0 ]; then chown ".self::SCANNER_USER." {$tmp} && chmod 600 {$tmp} && mv -f {$tmp} {$final}; rc=\$?; fi; echo \"".self::MARKER.' sbom $rc"');
}
private function usageMarker(string $step, string $path): string
{
return 'echo "'.self::MARKER." {$step} 0 \$(du -sb {$path} 2>/dev/null | cut -f1 | grep -E '^[0-9]+\$' || echo 0)\"";
}
/**
* One `sh -c` line: the sudo parser prefixes it with `sudo` and leaves the script as it is.
*/
private function shell(string $script): string
{
return 'sh -c '.escapeshellarg($script);
}
/**
* @param list<string> $subdirectories
* @return list<string>
*/
private function start(array $subdirectories): array
{
return [
$this->removeContainer(),
'rm -rf '.escapeshellarg($this->workDir()).' || true',
'mkdir -p -m 0700 '.implode(' ', array_map(fn (string $dir) => escapeshellarg($this->workDir()."/{$dir}"), $subdirectories)),
];
}
/**
* @param list<string> $before
* @return list<string>
*/
private function finish(array $before = []): array
{
return [
$this->removeContainer(),
...$before,
'rm -rf '.escapeshellarg($this->workDir()).' || true',
];
}
/**
* Runs before any container used the directory, so nothing in it can be a planted link.
*
* @return list<string>
*/
private function ownWorkDir(bool $hasAuth = false): array
{
$commands = [
'chown -R '.self::SCANNER_USER.' '.escapeshellarg($this->workDir()),
'chmod 700 '.escapeshellarg($this->workDir()),
];
if ($hasAuth) {
$commands[] = 'chmod 600 '.escapeshellarg($this->workDir().'/auth/config.json');
}
return $commands;
}
/**
* @param array<string, mixed>|null $auth
* @return list<string>
*/
private function workDirSetup(?array $auth): array
{
return [
'mkdir -p -m 0700 '.escapeshellarg($this->workDir().'/scratch'),
...($auth !== null ? $this->authSetup($auth) : []),
...$this->ownWorkDir($auth !== null),
];
}
/**
* @return list<string>
*/
private function sbomDirectory(): array
{
$dir = escapeshellarg(self::SBOM_DIR);
return [
"mkdir -p -m 0700 {$dir}",
'chown '.self::SCANNER_USER." {$dir}",
"chmod 700 {$dir}",
];
}
/**
* Creates the database volume and gives it to the scanner user from inside a container, so the shell
* never needs the host path of the volume (`chown -h` does not follow a link that a database update could have left).
* Prints `coolify-scan-exit db-volume {rc}`.
*/
private function databaseVolume(): string
{
$script = 'mkdir -p /db/tmp && chown 65534:65534 /db && chown -h 65534:65534 /db/tmp && chmod 700 /db/tmp';
$run = implode(' ', [
'timeout -k 10 '.self::TIMEOUT_VOLUME_SETUP.' docker run',
'--rm --name '.$this->containerName(),
'--network none --cap-drop ALL --cap-add CHOWN --cap-add DAC_OVERRIDE --cap-add FOWNER',
'--security-opt no-new-privileges --read-only --pids-limit 64',
'--user 0:0 --cpus 0.5 --memory 64m --memory-swap 64m --log-driver none',
'--mount type=volume,src='.self::DB_VOLUME.',dst=/db',
escapeshellarg($this->image('init')),
'sh -c '.escapeshellarg($script),
]);
return $this->shell($this->step('db-volume', 'docker volume create '.self::DB_VOLUME." >/dev/null && {$run} >/dev/null 2>&1"));
}
/**
* @return array<string, string>
*/
private function grypeDatabaseEnv(): array
{
return ['GRYPE_DB_CACHE_DIR' => '/db', 'GRYPE_DB_AUTO_UPDATE' => 'false'];
}
private function databaseMount(): string
{
return 'type=volume,src='.self::DB_VOLUME.',dst=/db,readonly';
}
private function sbomMount(string $name): string
{
return 'type=bind,src='.$this->sbomPath($name).',dst=/sbom/sbom.json,readonly';
}
/**
* `echo` is a shell builtin and a sudo parser keyword: the content never shows up in a process
* argument list or in the sudo log; only `base64 -d` and `tee` run with sudo.
*/
private function writeFile(string $content, string $path): string
{
return 'echo '.escapeshellarg(base64_encode($content)).' | base64 -d | tee '.escapeshellarg($path).' >/dev/null';
}
/**
* Accepts a Docker config with one registry in `auths` and nothing else (no credential helpers).
* The registry must be the registry of the image.
*
* @return array<string, mixed>|null
*/
private function authConfig(?string $authConfigB64, ImageRef $image): ?array
{
if ($authConfigB64 === null) {
return null;
}
$json = preg_match('/\A[A-Za-z0-9+\/]+={0,2}\z/', $authConfigB64) === 1 ? base64_decode($authConfigB64, true) : false;
$config = is_string($json) ? json_decode($json, true) : null;
if (! is_array($config) || array_keys($config) !== ['auths'] || ! is_array($config['auths']) || count($config['auths']) !== 1 || ! is_array(reset($config['auths']))) {
throw new InvalidArgumentException('The registry credentials must be a Docker config for one registry.');
}
if (! $this->credentialsMatchRegistry((string) array_key_first($config['auths']), $image->registry)) {
throw new InvalidArgumentException('The registry credentials are not for the registry of the image.');
}
return $config;
}
private function credentialsMatchRegistry(string $key, string $registry): bool
{
$host = rtrim(preg_replace('#\Ahttps?://#', '', $key) ?? '', '/');
if ($registry === 'docker.io') {
return in_array($host, ['index.docker.io/v1', 'index.docker.io', 'docker.io', 'registry-1.docker.io'], true);
}
return $host === $registry;
}
/**
* @param array<string, mixed> $auth
* @return list<string>
*/
private function authSetup(array $auth): array
{
return [
'mkdir -p -m 0700 '.escapeshellarg($this->workDir().'/auth'),
$this->writeFile(json_encode($auth, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), $this->workDir().'/auth/config.json'),
];
}
/**
* @param array<string, mixed>|null $auth
* @return array<string, string>
*/
private function authEnv(?array $auth): array
{
return $auth === null ? [] : ['DOCKER_CONFIG' => '/auth'];
}
/**
* @param array<string, mixed>|null $auth
* @return list<string>
*/
private function authMounts(?array $auth): array
{
return $auth === null ? [] : ['type=bind,src='.$this->workDir().'/auth,dst=/auth,readonly'];
}
private function validPlatform(string $platform): string
{
if (preg_match('#\A[a-z0-9]+/[a-z0-9_]+(/[a-z0-9]+)?\z#', $platform) !== 1) {
throw new InvalidArgumentException('Invalid platform.');
}
return $platform;
}
private function validSbomName(string $name): void
{
if (preg_match('/\A[a-z0-9][a-z0-9-]{0,99}\z/', $name) !== 1) {
throw new InvalidArgumentException('Invalid SBOM name.');
}
}
/**
* No `&`, `|`, `$`, or quotes: the sudo parser rewrites `&&`, `||`, and `$(` even inside quotes.
*/
private function validDatabaseUrl(string $url): string
{
if (preg_match('#\Ahttps?://[A-Za-z0-9.-]+(:[0-9]{1,5})?(/[A-Za-z0-9._~%/+=-]*)?\z#', $url) !== 1) {
throw new InvalidArgumentException('Invalid vulnerability database URL.');
}
return $url;
}
}