Skip to content

Commit

Permalink
Add trust feature to allow - or not - to import remote functions
Browse files Browse the repository at this point in the history
  • Loading branch information
pyrech committed Jun 25, 2023
1 parent 0b53437 commit 0bcf1c5
Show file tree
Hide file tree
Showing 49 changed files with 296 additions and 62 deletions.
16 changes: 12 additions & 4 deletions bin/generate-tests.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
$application = ApplicationFactory::create();
$application->setAutoExit(false);
$application
->run(new ArrayInput(['command' => 'list', '--format' => 'json']), $o = new BufferedOutput())
->run(new ArrayInput(['command' => 'list', '--format' => 'json', '--no-trust']), $o = new BufferedOutput())
;
$applicationDescription = json_decode($o->fetch(), true);

Expand Down Expand Up @@ -45,11 +45,12 @@
'parallel:sleep',
'run:run-parallel',
'run:ls',
'import:hello',
// Imported tasks
'pyrech:hello',
'pyrech:hello-world',
];
$optionFilterList = array_flip(['help', 'quiet', 'verbose', 'version', 'ansi', 'no-ansi', 'no-interaction', 'context']);
$optionFilterList = array_flip(['help', 'quiet', 'verbose', 'version', 'ansi', 'no-ansi', 'no-interaction', 'context', 'trust', 'no-trust']);
foreach ($applicationDescription['commands'] as $command) {
if (in_array($command['name'], $commandFilterList, true)) {
continue;
Expand Down Expand Up @@ -85,18 +86,25 @@
}

add_test(['context:context', '--context', 'run'], 'ContextContextRunTest');
add_test(['context:context', '--context', 'my_default', '-vvv'], 'ContextContextMyDefaultTest');
add_test(['context:context', '--context', 'my_default', '-v'], 'ContextContextMyDefaultTest');
add_test(['context:context', '--context', 'no_no_exist'], 'ContextContextDoNotExistTest');
add_test(['context:context', '--context', 'production'], 'ContextContextProductionTest');
add_test([], 'NewProjectTest', '/tmp');
add_test(['unknown:command', 'toto', '--foo', 1], 'NoConfigTest', '/tmp');
add_test(['import:hello'], 'ImportHelloAskingTrust', noTrust: false);
add_test(['import:hello', '--no-trust'], 'ImportHelloNoTrustForced', noTrust: false);
add_test(['import:hello', '--trust'], 'ImportHelloTrustForce', noTrust: false);

function add_test(array $args, string $class, string $cwd = null)
function add_test(array $args, string $class, string $cwd = null, bool $noTrust = true)
{
$fp = fopen(__FILE__, 'r');
fseek($fp, __COMPILER_HALT_OFFSET__ + 1);
$template = stream_get_contents($fp);

if ($noTrust) {
$args[] = '--no-trust';
}

$process = new Process(
[\PHP_BINARY, __DIR__ . '/castor', ...$args],
cwd: $cwd ?: __DIR__ . '/../',
Expand Down
23 changes: 23 additions & 0 deletions doc/13-remote.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,26 @@ function hello(): void
> **Note**
> If path of the file is empty, it will import the `castor.php` file at the root
> of the repository.
## Trusting remote resource

For security reasons, each time your Castor project tries to import a remote
resource, Castor will warn you to ask if you trust it.

For each remote resource, Castor will ask you what to do. You can either:
- `not now`: Castor will **not import** the function but will **ask you again**
next time ;
- `never`: Castor will **not import** the function and will persist your choice
to **not ask you again** in the future ;
- `only this time`: Castor will **import** the function but will ask you again the
next time ;
- `always`: Castor will **import** the function and will persist your choice
to **not ask you again** in the future.

You can also pass the `--trust` (or `--no-trust`) options to automatically trust
(or not) **all** remote resources without being asked for.

> **Warning**
> The `--trust` option should be used with caution as it could lead to malicious
> code execution. The main use case of this option is to be used in a Continuous
> Integration environment.
2 changes: 1 addition & 1 deletion examples/import.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

use function Castor\import;

import('github://pyrech/castor-setup-php/main/castor.php');
import('github://pyrech/castor-setup-php/main');

#[AsTask(description: 'Use a function imported from a remote repository')]
function hello(): void
Expand Down
20 changes: 16 additions & 4 deletions src/Console/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use Symfony\Bridge\Monolog\Handler\ConsoleHandler;
use Symfony\Component\Console\Application as SymfonyApplication;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Command\CompleteCommand;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
Expand Down Expand Up @@ -81,10 +82,12 @@ protected function doRunCommand(Command $command, InputInterface $input, OutputI
{
GlobalHelper::setCommand($command);

$context = $this->createContext($input, $output);
GlobalHelper::setInitialContext($context);
if ($command instanceof TaskCommand) {
$context = $this->createContext($input, $output);
GlobalHelper::setInitialContext($context);
}

if ('_complete' !== $command->getName()) {
if (!$command instanceof CompleteCommand) {
$this->stubsGenerator->generateStubsIfNeeded($this->rootDir . '/.castor.stub.php');
$this->displayUpdateWarningIfNeeded(new SymfonyStyle($input, $output));
}
Expand All @@ -94,6 +97,15 @@ protected function doRunCommand(Command $command, InputInterface $input, OutputI

private function initializeApplication(): void
{
$this->getDefinition()->addOption(
new InputOption(
'trust',
null,
InputOption::VALUE_NEGATABLE,
'Trust all the imported functions from remote resources'
)
);

// Find all potential commands / context
$functions = $this->functionFinder->findFunctions($this->rootDir);

Expand Down Expand Up @@ -122,7 +134,7 @@ private function initializeApplication(): void

private function createContext(InputInterface $input, OutputInterface $output): Context
{
// occurs when running `castor -h`, or if no context is defined
// Occurs when running a native command (like `castor -h`, `castor list`, etc), or if no context is defined
if (!$input->hasOption('context')) {
return new Context();
}
Expand Down
13 changes: 13 additions & 0 deletions src/Remote/Exception/NotTrusted.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

namespace Castor\Remote\Exception;

class NotTrusted extends \RuntimeException
{
public function __construct(
public readonly string $url,
public readonly bool $wasAsked = true,
) {
parent::__construct("The remote resource {$url} is not trusted.");
}
}
81 changes: 81 additions & 0 deletions src/Remote/Import.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,13 @@
namespace Castor\Remote;

use Castor\GlobalHelper;
use Castor\Remote\Exception\NotTrusted;
use Symfony\Contracts\Cache\ItemInterface;

use function Castor\cache;
use function Castor\fs;
use function Castor\get_input;
use function Castor\io;
use function Castor\log;
use function Castor\run;

Expand All @@ -13,6 +18,8 @@ class Import
{
public static function importFunctionsFromGitRepository(string $domain, string $repository, string $version, string $functionPath): string
{
self::ensureTrustedResource($domain . '/' . $repository);

$dir = GlobalHelper::getGlobalDirectory() . '/remote/' . $domain . '/' . $repository . '/' . $version;

if (!is_dir($dir)) {
Expand All @@ -25,4 +32,78 @@ public static function importFunctionsFromGitRepository(string $domain, string $

return $dir . $functionPath;
}

private static function ensureTrustedResource(string $url): void
{
$input = get_input();
$io = io();

// Need to look for the raw options as the input is not yet parsed
$trust = $input->getParameterOption('--trust', false);
$noTrust = $input->getParameterOption('--no-trust', false);

if (false !== $trust) {
return;
}

if (false !== $noTrust) {
throw new NotTrusted($url, false);
}

$trustKey = sprintf('remote.trust.%s', str_replace('/', '.', $url));

$trustChoice = cache(
$trustKey,
function (ItemInterface $item) {
if ($item->isHit()) {
return $item->get();
}

$item->expiresAfter(-1);

return null;
},
);

if (null !== $trustChoice) {
$trustChoice = TrustEnum::tryFrom($trustChoice);

if (TrustEnum::NEVER === $trustChoice) {
throw new NotTrusted($url, false);
}

if (TrustEnum::ALWAYS === $trustChoice) {
return;
}
}

static $displayTrustWarning = true;

if ($displayTrustWarning) {
$io->warning('Your Castor project tries to import functions from external resources');

$displayTrustWarning = false;
}

$action = TrustEnum::from(
$io->choice(
sprintf('Trust <comment>%s</comment> and import?', $url),
TrustEnum::toArray(),
TrustEnum::NOT_NOW->value,
)
);

if (TrustEnum::ALWAYS === $action || TrustEnum::NEVER === $action) {
log('Persisting trust choice for', context: [
'url' => $url,
'choice' => $action->value,
]);

cache($trustKey, fn () => $action->value);
}

if (TrustEnum::NOT_NOW === $action || TrustEnum::NEVER === $action) {
throw new NotTrusted($url, true);
}
}
}
22 changes: 22 additions & 0 deletions src/Remote/TrustEnum.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

namespace Castor\Remote;

enum TrustEnum: string
{
case NOT_NOW = 'not now';
case NEVER = 'never';
case ONLY_THIS_TIME = 'only this time';
case ALWAYS = 'always';

/**
* @return array<string>
*/
public static function toArray(): array
{
return array_map(
fn (self $item) => $item->value,
self::cases(),
);
}
}
37 changes: 23 additions & 14 deletions src/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Castor;

use Castor\Console\Application;
use Castor\Remote\Exception\NotTrusted;
use Castor\Remote\Import;
use Joli\JoliNotif\Notification;
use Joli\JoliNotif\NotifierFactory;
Expand Down Expand Up @@ -444,23 +445,31 @@ function import(string $path): void
$scheme = parse_url($path, \PHP_URL_SCHEME);

if ($scheme) {
if ('github' === $scheme) {
if (!preg_match('#^github://(?<organization>[^/]+)/(?<repository>[^/]+)/(?<version>[^/]+)(?<function_path>.*)$#', $path, $matches)) {
throw fix_exception(new \InvalidArgumentException('The import path from GitHub repository must be formatted like this: "github://<organization>/<repository>/<version>/<function_path>".'));
}
try {
if ('github' === $scheme) {
if (!preg_match('#^github://(?<organization>[^/]+)/(?<repository>[^/]+)/(?<version>[^/]+)(?<function_path>.*)$#', $path, $matches)) {
throw fix_exception(new \InvalidArgumentException('The import path from GitHub repository must be formatted like this: "github://<organization>/<repository>/<version>/<function_path>".'));
}

$path = Import::importFunctionsFromGitRepository(
'github.com',
sprintf('%s/%s', $matches['organization'], $matches['repository']),
$matches['version'],
$matches['function_path'] ?? '/castor.php',
);
$path = Import::importFunctionsFromGitRepository(
'github.com',
sprintf('%s/%s', $matches['organization'], $matches['repository']),
$matches['version'],
$matches['function_path'] ?? '/castor.php',
);

log('Using functions from remote resource.', 'info', [
'url' => $path,
log('Using functions from remote resource.', 'info', [
'url' => $path,
]);
} else {
throw fix_exception(new \InvalidArgumentException(sprintf('The scheme "%s" is not supported.', $scheme)));
}
} catch (NotTrusted $e) {
log('Ignoring functions from untrusted resource.', 'info', [
'url' => $e->url,
]);
} else {
throw fix_exception(new \InvalidArgumentException(sprintf('The scheme "%s" is not supported.', $scheme)));

return;
}
}

Expand Down
2 changes: 1 addition & 1 deletion tests/Examples/ArgsAnotherArgsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ class ArgsAnotherArgsTest extends TaskTestCase
// args:another-args
public function test(): void
{
$process = $this->runTask(['args:another-args', 'FIXME(required)', '--test2', 1]);
$process = $this->runTask(['args:another-args', 'FIXME(required)', '--test2', 1, '--no-trust']);

$this->assertSame(0, $process->getExitCode());
$this->assertStringEqualsFile(__FILE__ . '.output.txt', $process->getOutput());
Expand Down
2 changes: 1 addition & 1 deletion tests/Examples/ArgsArgsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ class ArgsArgsTest extends TaskTestCase
// args:args
public function test(): void
{
$process = $this->runTask(['args:args', 'FIXME(word)', '--option', 'default value', '--dry-run']);
$process = $this->runTask(['args:args', 'FIXME(word)', '--option', 'default value', '--dry-run', '--no-trust']);

$this->assertSame(0, $process->getExitCode());
$this->assertStringEqualsFile(__FILE__ . '.output.txt', $process->getOutput());
Expand Down
2 changes: 1 addition & 1 deletion tests/Examples/BarBarTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ class BarBarTest extends TaskTestCase
// bar:bar
public function test(): void
{
$process = $this->runTask(['bar:bar']);
$process = $this->runTask(['bar:bar', '--no-trust']);

$this->assertSame(0, $process->getExitCode());
$this->assertStringEqualsFile(__FILE__ . '.output.txt', $process->getOutput());
Expand Down
2 changes: 1 addition & 1 deletion tests/Examples/CacheComplexTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ class CacheComplexTest extends TaskTestCase
// cache:complex
public function test(): void
{
$process = $this->runTask(['cache:complex']);
$process = $this->runTask(['cache:complex', '--no-trust']);

$this->assertSame(0, $process->getExitCode());
$this->assertStringEqualsFile(__FILE__ . '.output.txt', $process->getOutput());
Expand Down
2 changes: 1 addition & 1 deletion tests/Examples/CacheSimpleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ class CacheSimpleTest extends TaskTestCase
// cache:simple
public function test(): void
{
$process = $this->runTask(['cache:simple']);
$process = $this->runTask(['cache:simple', '--no-trust']);

$this->assertSame(0, $process->getExitCode());
$this->assertStringEqualsFile(__FILE__ . '.output.txt', $process->getOutput());
Expand Down
2 changes: 1 addition & 1 deletion tests/Examples/CdDirectoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ class CdDirectoryTest extends TaskTestCase
// cd:directory
public function testCdDirectory(): void
{
$process = $this->runTask(['cd:directory']);
$process = $this->runTask(['cd:directory', '--no-trust']);
$this->assertSame(0, $process->getExitCode());
$output = OutputCleaner::cleanOutput($process->getOutput());
$this->assertStringEqualsFile(__FILE__ . '.output.txt', $output);
Expand Down
2 changes: 1 addition & 1 deletion tests/Examples/ContextContextDoNotExistTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ class ContextContextDoNotExistTest extends TaskTestCase
// context:context
public function test(): void
{
$process = $this->runTask(['context:context', '--context', 'no_no_exist']);
$process = $this->runTask(['context:context', '--context', 'no_no_exist', '--no-trust']);

$this->assertSame(1, $process->getExitCode());
$this->assertStringEqualsFile(__FILE__ . '.output.txt', $process->getOutput());
Expand Down

0 comments on commit 0bcf1c5

Please sign in to comment.