287 lines
14 KiB
PHP
287 lines
14 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Services\TenantConfiguration\ExchangePowerShellCommandContract;
|
|
use App\Services\TenantConfiguration\ExchangePowerShellCommandContracts;
|
|
use App\Services\TenantConfiguration\ExchangePowerShellOutputGuard;
|
|
use App\Services\TenantConfiguration\ExchangePowerShellProcessCommand;
|
|
use App\Services\TenantConfiguration\ExchangePowerShellProcessCommandBuilder;
|
|
use App\Services\TenantConfiguration\ExchangePowerShellProcessExecutor;
|
|
use App\Services\TenantConfiguration\ExchangePowerShellProcessResult;
|
|
use App\Services\TenantConfiguration\ExchangePowerShellRuntimePolicy;
|
|
use App\Services\TenantConfiguration\ExchangePowerShellRuntimeReadinessChecker;
|
|
use App\Services\TenantConfiguration\FakeExchangePowerShellProcessExecutor;
|
|
|
|
it('Spec432 runtime readiness returns sanitized blockers without real PowerShell requirements', function (
|
|
ExchangePowerShellRuntimePolicy $policy,
|
|
FakeExchangePowerShellProcessExecutor $executor,
|
|
string $expectedFailureCode,
|
|
): void {
|
|
$result = (new ExchangePowerShellRuntimeReadinessChecker($executor))->check($policy);
|
|
|
|
expect($result->allowed)->toBeFalse()
|
|
->and($result->failureCode)->toBe($expectedFailureCode);
|
|
})->with([
|
|
'feature disabled' => fn (): array => [
|
|
spec432RuntimePolicy(invocationEnabled: false),
|
|
new FakeExchangePowerShellProcessExecutor,
|
|
'runtime_blocked_feature_disabled',
|
|
],
|
|
'runner disabled' => fn (): array => [
|
|
spec432RuntimePolicy(productionRunnerEnabled: false),
|
|
new FakeExchangePowerShellProcessExecutor,
|
|
'runtime_blocked_runner_disabled',
|
|
],
|
|
'environment not allowed' => fn (): array => [
|
|
spec432RuntimePolicy(allowedEnvironments: ['production']),
|
|
new FakeExchangePowerShellProcessExecutor,
|
|
'runtime_blocked_environment_not_allowed',
|
|
],
|
|
'timeout policy missing' => fn (): array => [
|
|
spec432RuntimePolicy(checkTimeoutSeconds: 0),
|
|
new FakeExchangePowerShellProcessExecutor,
|
|
'runtime_blocked_timeout_policy_missing',
|
|
],
|
|
'temp policy unsafe' => fn (): array => [
|
|
spec432RuntimePolicy(tempFilesEnabled: true, tempDirectory: '/tmp/tenantpilot-unsafe'),
|
|
new FakeExchangePowerShellProcessExecutor,
|
|
'runtime_blocked_temp_policy_unsafe',
|
|
],
|
|
'process executor missing' => fn (): array => [
|
|
spec432RuntimePolicy(),
|
|
new FakeExchangePowerShellProcessExecutor(available: false),
|
|
'runtime_blocked_process_executor_missing',
|
|
],
|
|
'powershell missing' => fn (): array => [
|
|
spec432RuntimePolicy(),
|
|
new FakeExchangePowerShellProcessExecutor(results: [
|
|
ExchangePowerShellProcessResult::completed(1, '', 'not found'),
|
|
]),
|
|
'runtime_blocked_powershell_missing',
|
|
],
|
|
'module missing' => fn (): array => [
|
|
spec432RuntimePolicy(),
|
|
new FakeExchangePowerShellProcessExecutor(results: [
|
|
ExchangePowerShellProcessResult::completed(0, "7.4.0\n"),
|
|
ExchangePowerShellProcessResult::completed(0, ''),
|
|
]),
|
|
'runtime_blocked_exchange_module_missing',
|
|
],
|
|
'module unknown' => fn (): array => [
|
|
spec432RuntimePolicy(),
|
|
new FakeExchangePowerShellProcessExecutor(results: [
|
|
ExchangePowerShellProcessResult::completed(0, "7.4.0\n"),
|
|
ExchangePowerShellProcessResult::completed(1, '', 'module error'),
|
|
]),
|
|
'runtime_blocked_exchange_module_unknown',
|
|
],
|
|
]);
|
|
|
|
it('Spec432 runtime readiness uses fixed non-invasive argument vectors', function (): void {
|
|
$executor = new FakeExchangePowerShellProcessExecutor(results: [
|
|
ExchangePowerShellProcessResult::completed(0, "7.4.0\n"),
|
|
ExchangePowerShellProcessResult::completed(0, "ExchangeOnlineManagement\n"),
|
|
]);
|
|
|
|
$result = (new ExchangePowerShellRuntimeReadinessChecker($executor))->check(spec432RuntimePolicy());
|
|
|
|
$arguments = json_encode($executor->calls, JSON_THROW_ON_ERROR);
|
|
|
|
expect($result->allowed)->toBeTrue()
|
|
->and($executor->calls)->toHaveCount(2)
|
|
->and($arguments)
|
|
->toContain('-NoLogo')
|
|
->toContain('-NoProfile')
|
|
->toContain('-NonInteractive')
|
|
->toContain('$PSVersionTable.PSVersion.ToString()')
|
|
->toContain('Get-Module -ListAvailable -Name ExchangeOnlineManagement')
|
|
->not->toContain('Select-Object')
|
|
->not->toContain('| Select-Object')
|
|
->not->toContain('Connect-ExchangeOnline')
|
|
->not->toContain('Install-Module')
|
|
->not->toContain('Invoke-Expression')
|
|
->not->toContain('Start-Process')
|
|
->not->toContain('Get-ChildItem Env:')
|
|
->not->toContain('Read-Host');
|
|
});
|
|
|
|
it('Spec432 runtime readiness catches unexpected executor failures safely', function (): void {
|
|
$executor = new class implements ExchangePowerShellProcessExecutor
|
|
{
|
|
public function available(): bool
|
|
{
|
|
return true;
|
|
}
|
|
|
|
public function run(ExchangePowerShellProcessCommand $command): ExchangePowerShellProcessResult
|
|
{
|
|
throw new RuntimeException('executor boom with client_secret=unsafe');
|
|
}
|
|
};
|
|
|
|
$result = (new ExchangePowerShellRuntimeReadinessChecker($executor))->check(spec432RuntimePolicy());
|
|
|
|
expect($result->allowed)->toBeFalse()
|
|
->and($result->failureCode)->toBe('runtime_failed_unexpected')
|
|
->and(json_encode($result->context, JSON_THROW_ON_ERROR))->not->toContain('client_secret');
|
|
});
|
|
|
|
it('Spec432 command builder creates only fixed argument-vector commands from verified contracts', function (string $canonicalType, string $commandName): void {
|
|
$contracts = new ExchangePowerShellCommandContracts;
|
|
$verifiedContract = spec432VerifiedContract($canonicalType);
|
|
|
|
$result = (new ExchangePowerShellProcessCommandBuilder($contracts))->build($verifiedContract, spec432RuntimePolicy());
|
|
|
|
expect($result->successful)->toBeTrue()
|
|
->and($result->command)->toBeInstanceOf(ExchangePowerShellProcessCommand::class)
|
|
->and($result->command?->arguments)->toBeArray()
|
|
->and($result->command?->arguments[0])->toBe('pwsh')
|
|
->and($result->command?->arguments)->toContain('-NoLogo')
|
|
->and($result->command?->arguments)->toContain('-NoProfile')
|
|
->and($result->command?->arguments)->toContain('-NonInteractive')
|
|
->and($result->command?->arguments[5])->toBe($commandName)
|
|
->and($result->command?->arguments[5])->not->toContain('Set-')
|
|
->and($result->command?->arguments[5])->not->toContain('New-')
|
|
->and($result->command?->arguments[5])->not->toContain('Remove-')
|
|
->and($result->command?->arguments[5])->not->toContain('|')
|
|
->and($result->command?->arguments[5])->not->toContain(';')
|
|
->and($result->command?->arguments[5])->not->toContain('$')
|
|
->and($result->command?->arguments[5])->not->toContain('>')
|
|
->and($result->command?->arguments[5])->not->toContain('ConvertTo-Json')
|
|
->and($result->context['command_builder_state'])->toBe('fixed_allowlist_argument_vector');
|
|
})->with([
|
|
'transport rule' => ['transportRule', 'Get-TransportRule'],
|
|
'remote domain' => ['remoteDomain', 'Get-RemoteDomain'],
|
|
'inbound connector' => ['inboundConnector', 'Get-InboundConnector'],
|
|
]);
|
|
|
|
it('Spec432 command contracts reject mutation commands shell fragments and unknown parameters before command building', function (mixed $commandName, string $expectedReason): void {
|
|
$contracts = new ExchangePowerShellCommandContracts;
|
|
|
|
expect($contracts->validateCommandName($commandName)['reason_code'])->toBe($expectedReason);
|
|
})->with([
|
|
'set command' => ['Set-TransportRule', 'mutation_command_rejected'],
|
|
'new command' => ['New-TransportRule', 'mutation_command_rejected'],
|
|
'remove command' => ['Remove-TransportRule', 'mutation_command_rejected'],
|
|
'enable command' => ['Enable-TransportRule', 'mutation_command_rejected'],
|
|
'disable command' => ['Disable-TransportRule', 'mutation_command_rejected'],
|
|
'update command' => ['Update-TransportRule', 'mutation_command_rejected'],
|
|
'start command' => ['Start-TransportRule', 'mutation_command_rejected'],
|
|
'stop command' => ['Stop-TransportRule', 'mutation_command_rejected'],
|
|
'invoke command' => ['Invoke-Expression', 'unsafe_command_text_rejected'],
|
|
'search command' => ['Search-TransportRule', 'mutation_command_rejected'],
|
|
'export command' => ['Export-TransportRule', 'mutation_command_rejected'],
|
|
'import command' => ['Import-TransportRule', 'mutation_command_rejected'],
|
|
'semicolon chain' => ['Get-TransportRule; Get-RemoteDomain', 'unsafe_command_text_rejected'],
|
|
'pipe chain' => ['Get-TransportRule | Out-File x', 'unsafe_command_text_rejected'],
|
|
'redirection' => ['Get-TransportRule > x', 'unsafe_command_text_rejected'],
|
|
'script fragment' => ['Get-TransportRule $(Get-Process)', 'unsafe_command_text_rejected'],
|
|
'alias unknown' => ['gtr', 'command_not_allowlisted'],
|
|
]);
|
|
|
|
it('Spec432 verified command contract rejects unknown parameters without exposing parameter values', function (): void {
|
|
$contracts = new ExchangePowerShellCommandContracts;
|
|
$contract = $contracts->contractForCanonicalType('transportRule');
|
|
|
|
expect($contract)->toBeArray();
|
|
|
|
$validation = $contracts->validateInvocation($contract, ['client_secret_super_token' => 'unsafe']);
|
|
|
|
expect($validation['accepted'])->toBeFalse()
|
|
->and($validation['reason_code'])->toBe('unknown_parameter_rejected')
|
|
->and(json_encode($validation, JSON_THROW_ON_ERROR))
|
|
->not->toContain('unsafe');
|
|
});
|
|
|
|
it('Spec432 output guard accepts structured JSON collections and summarizes without raw payload persistence', function (): void {
|
|
$result = (new ExchangePowerShellOutputGuard)->guard(
|
|
ExchangePowerShellProcessResult::completed(0, '[{"id":"rule-1","secret":"raw-output-secret"}]', durationMs: 12),
|
|
spec432VerifiedContract('transportRule'),
|
|
spec432RuntimePolicy(),
|
|
);
|
|
|
|
expect($result->successful)->toBeTrue()
|
|
->and($result->collection)->toHaveCount(1)
|
|
->and($result->context['output_state'])->toBe('structured_collection')
|
|
->and($result->context['item_count'])->toBe(1);
|
|
});
|
|
|
|
it('Spec432 output guard fails safely for unsafe process outputs', function (
|
|
ExchangePowerShellProcessResult $processResult,
|
|
string $expectedFailureCode,
|
|
): void {
|
|
$result = (new ExchangePowerShellOutputGuard)->guard(
|
|
$processResult,
|
|
spec432VerifiedContract('transportRule'),
|
|
spec432RuntimePolicy(stdoutMaxBytes: 64, stderrMaxBytes: 64, itemLimit: 1),
|
|
);
|
|
|
|
expect($result->successful)->toBeFalse()
|
|
->and($result->failureCode)->toBe($expectedFailureCode)
|
|
->and($result->summaryCounts)->toMatchArray([
|
|
'total' => 1,
|
|
'processed' => 1,
|
|
'succeeded' => 0,
|
|
'failed' => 1,
|
|
'skipped' => 0,
|
|
'items' => 0,
|
|
])
|
|
->and(json_encode($result->context, JSON_THROW_ON_ERROR))
|
|
->not->toContain('secret-output');
|
|
})->with([
|
|
'warning prefix' => [ExchangePowerShellProcessResult::completed(0, "WARNING: secret-output\n[]"), 'execution_failed_output_shape_unsafe'],
|
|
'nonzero with stdout' => [ExchangePowerShellProcessResult::completed(1, '[{"id":"rule-1"}]'), 'execution_failed_nonzero_exit'],
|
|
'timeout' => [ExchangePowerShellProcessResult::timedOut(60000), 'execution_failed_timeout'],
|
|
'oversized stdout' => [ExchangePowerShellProcessResult::completed(0, str_repeat('A', 128)), 'execution_failed_stdout_oversized'],
|
|
'oversized stderr' => [ExchangePowerShellProcessResult::completed(0, '[]', str_repeat('B', 128)), 'execution_failed_stderr_oversized'],
|
|
'binary output' => [ExchangePowerShellProcessResult::completed(0, "\x00\x01"), 'execution_failed_output_encoding'],
|
|
'scalar output' => [ExchangePowerShellProcessResult::completed(0, '42'), 'execution_failed_shape_unsafe'],
|
|
'malformed text' => [ExchangePowerShellProcessResult::completed(0, 'raw secret-output transcript'), 'execution_failed_shape_unsafe'],
|
|
'too many items' => [ExchangePowerShellProcessResult::completed(0, '[{"id":"one"},{"id":"two"}]'), 'execution_failed_item_limit_exceeded'],
|
|
'executor exception' => [ExchangePowerShellProcessResult::exception('RuntimeException'), 'execution_failed_unexpected_exception'],
|
|
]);
|
|
|
|
function spec432RuntimePolicy(
|
|
bool $invocationEnabled = true,
|
|
bool $productionRunnerEnabled = true,
|
|
array $allowedEnvironments = [],
|
|
int $checkTimeoutSeconds = 5,
|
|
int $invocationTimeoutSeconds = 60,
|
|
int $stdoutMaxBytes = 524288,
|
|
int $stderrMaxBytes = 65536,
|
|
int $itemLimit = 1000,
|
|
int $lockTtlSeconds = 300,
|
|
bool $tempFilesEnabled = false,
|
|
?string $tempDirectory = null,
|
|
): ExchangePowerShellRuntimePolicy {
|
|
return new ExchangePowerShellRuntimePolicy(
|
|
invocationEnabled: $invocationEnabled,
|
|
productionRunnerEnabled: $productionRunnerEnabled,
|
|
allowedEnvironments: $allowedEnvironments !== [] ? $allowedEnvironments : [app()->environment()],
|
|
currentEnvironment: app()->environment(),
|
|
powershellBinary: 'pwsh',
|
|
moduleCheckEnabled: true,
|
|
checkTimeoutSeconds: $checkTimeoutSeconds,
|
|
invocationTimeoutSeconds: $invocationTimeoutSeconds,
|
|
stdoutMaxBytes: $stdoutMaxBytes,
|
|
stderrMaxBytes: $stderrMaxBytes,
|
|
itemLimit: $itemLimit,
|
|
lockTtlSeconds: $lockTtlSeconds,
|
|
tempFilesEnabled: $tempFilesEnabled,
|
|
tempDirectory: $tempDirectory,
|
|
);
|
|
}
|
|
|
|
function spec432VerifiedContract(string $canonicalType): ExchangePowerShellCommandContract
|
|
{
|
|
$contracts = new ExchangePowerShellCommandContracts;
|
|
$contract = $contracts->contractForCanonicalType($canonicalType);
|
|
|
|
if (! is_array($contract)) {
|
|
throw new RuntimeException('Missing Spec432 command contract.');
|
|
}
|
|
|
|
return ExchangePowerShellCommandContract::fromVerifiedArray($contract, $contracts, []);
|
|
}
|