175 lines
7.8 KiB
PHP
175 lines
7.8 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\ExchangePowerShellProcessResult;
|
|
use App\Services\TenantConfiguration\ExchangePowerShellRuntimePolicy;
|
|
use App\Services\TenantConfiguration\ExchangePowerShellStructuredOutputEnvelope;
|
|
|
|
it('Spec435 accepts structured Exchange collections and structured empty collections without raw process output', function (): void {
|
|
$contract = spec435VerifiedContract('transportRule');
|
|
|
|
$collection = (new ExchangePowerShellOutputGuard)->guard(
|
|
ExchangePowerShellProcessResult::completed(0, '[{"Guid":"rule-1","client_secret":"unsafe"}]', durationMs: 10),
|
|
$contract,
|
|
spec435RuntimePolicy(),
|
|
);
|
|
$empty = (new ExchangePowerShellOutputGuard)->guard(
|
|
ExchangePowerShellProcessResult::completed(0, '', durationMs: 11),
|
|
$contract,
|
|
spec435RuntimePolicy(),
|
|
);
|
|
|
|
$collectionEnvelope = ExchangePowerShellStructuredOutputEnvelope::fromInvocationResult(
|
|
resourceType: 'transportRule',
|
|
contract: $contract,
|
|
runnerMode: 'fake',
|
|
result: $collection,
|
|
);
|
|
$emptyEnvelope = ExchangePowerShellStructuredOutputEnvelope::fromInvocationResult(
|
|
resourceType: 'transportRule',
|
|
contract: $contract,
|
|
runnerMode: 'fake',
|
|
result: $empty,
|
|
);
|
|
|
|
$collectionItemsJson = json_encode($collectionEnvelope->items, JSON_THROW_ON_ERROR);
|
|
|
|
expect($collectionEnvelope->accepted())->toBeTrue()
|
|
->and($collectionEnvelope->shapeState)->toBe(ExchangePowerShellStructuredOutputEnvelope::SHAPE_STRUCTURED_COLLECTION)
|
|
->and($collectionEnvelope->itemCount)->toBe(1)
|
|
->and($collectionEnvelope->items[0]['Guid'] ?? null)->toBe('rule-1')
|
|
->and($collectionItemsJson)
|
|
->not->toContain('client_secret')
|
|
->not->toContain('unsafe')
|
|
->and($collectionEnvelope->safeSummary())
|
|
->not->toHaveKey('stdout')
|
|
->not->toHaveKey('stderr')
|
|
->and(json_encode($collectionEnvelope->safeSummary(), JSON_THROW_ON_ERROR))
|
|
->not->toContain('client_secret')
|
|
->and($emptyEnvelope->accepted())->toBeTrue()
|
|
->and($emptyEnvelope->shapeState)->toBe(ExchangePowerShellStructuredOutputEnvelope::SHAPE_STRUCTURED_EMPTY_COLLECTION)
|
|
->and($emptyEnvelope->emptyCollection)->toBeTrue()
|
|
->and($emptyEnvelope->itemCount)->toBe(0);
|
|
});
|
|
|
|
it('Spec435 strips forbidden secret and raw-output key families from public envelope items recursively', function (): void {
|
|
$contract = spec435VerifiedContract('transportRule');
|
|
$collection = (new ExchangePowerShellOutputGuard)->guard(
|
|
ExchangePowerShellProcessResult::completed(0, json_encode([[
|
|
'Guid' => 'rule-1',
|
|
'Actions' => [
|
|
'SetHeaderValue' => 'business-value-redacted-later',
|
|
'authorization_header' => 'Bearer unsafe',
|
|
],
|
|
'Nested' => [
|
|
'stdout' => 'unsafe-direct-stdout',
|
|
'stderr' => 'unsafe-direct-stderr',
|
|
'tokens' => ['unsafe-token'],
|
|
'Cookies' => ['unsafe-cookie'],
|
|
'CredentialMaterial' => 'unsafe-credential',
|
|
'PowerShellTranscript' => 'unsafe-transcript',
|
|
'RawShellStdout' => 'unsafe-stdout',
|
|
],
|
|
]], JSON_THROW_ON_ERROR), durationMs: 10),
|
|
$contract,
|
|
spec435RuntimePolicy(),
|
|
);
|
|
|
|
$envelope = ExchangePowerShellStructuredOutputEnvelope::fromInvocationResult(
|
|
resourceType: 'transportRule',
|
|
contract: $contract,
|
|
runnerMode: 'fake',
|
|
result: $collection,
|
|
);
|
|
|
|
$itemsJson = json_encode($envelope->items, JSON_THROW_ON_ERROR);
|
|
|
|
expect($envelope->accepted())->toBeTrue()
|
|
->and(data_get($envelope->items, '0.Guid'))->toBe('rule-1')
|
|
->and(data_get($envelope->items, '0.Actions.SetHeaderValue'))->toBe('business-value-redacted-later')
|
|
->and($itemsJson)
|
|
->not->toContain('authorization_header')
|
|
->not->toContain('unsafe-token')
|
|
->not->toContain('unsafe-cookie')
|
|
->not->toContain('unsafe-credential')
|
|
->not->toContain('unsafe-transcript')
|
|
->not->toContain('unsafe-stdout')
|
|
->not->toContain('unsafe-direct-stdout')
|
|
->not->toContain('unsafe-direct-stderr');
|
|
});
|
|
|
|
it('Spec435 maps unsafe Exchange process output shapes to explicit readiness blockers', function (
|
|
ExchangePowerShellProcessResult $processResult,
|
|
string $expectedShapeState,
|
|
string $expectedBlocker,
|
|
): void {
|
|
$contract = spec435VerifiedContract('transportRule');
|
|
$result = (new ExchangePowerShellOutputGuard)->guard(
|
|
$processResult,
|
|
$contract,
|
|
spec435RuntimePolicy(stdoutMaxBytes: 64, stderrMaxBytes: 64, itemLimit: 1),
|
|
);
|
|
|
|
$envelope = ExchangePowerShellStructuredOutputEnvelope::fromInvocationResult(
|
|
resourceType: 'transportRule',
|
|
contract: $contract,
|
|
runnerMode: 'fake',
|
|
result: $result,
|
|
);
|
|
|
|
expect($envelope->accepted())->toBeFalse()
|
|
->and($envelope->shapeState)->toBe($expectedShapeState)
|
|
->and($envelope->readinessBlocker)->toBe($expectedBlocker)
|
|
->and($envelope->normalizerReadinessState)->toBe(ExchangePowerShellStructuredOutputEnvelope::READINESS_BLOCKED)
|
|
->and(json_encode($envelope->safeSummary(), JSON_THROW_ON_ERROR))
|
|
->not->toContain('secret-output');
|
|
})->with([
|
|
'malformed text' => [ExchangePowerShellProcessResult::completed(0, 'raw secret-output transcript'), 'malformed_json_collection', 'blocked_malformed_output'],
|
|
'scalar output' => [ExchangePowerShellProcessResult::completed(0, '42'), 'malformed_json_collection', 'blocked_malformed_output'],
|
|
'warning-prefixed output' => [ExchangePowerShellProcessResult::completed(0, "WARNING: secret-output\n[]"), 'warning_prefixed', 'blocked_warning_prefixed_output'],
|
|
'binary output' => [ExchangePowerShellProcessResult::completed(0, "\x00\x01"), 'non_utf8_or_binary', 'blocked_binary_output'],
|
|
'oversized stdout' => [ExchangePowerShellProcessResult::completed(0, str_repeat('A', 128)), 'stdout_oversized', 'blocked_oversized_output'],
|
|
'oversized stderr' => [ExchangePowerShellProcessResult::completed(0, '[]', str_repeat('B', 128)), 'stderr_oversized', 'blocked_oversized_output'],
|
|
'non-zero exit' => [ExchangePowerShellProcessResult::completed(1, '[{"Guid":"rule-1"}]'), 'nonzero_exit', 'blocked_nonzero_exit'],
|
|
'timeout' => [ExchangePowerShellProcessResult::timedOut(60000), 'timeout', 'blocked_timeout'],
|
|
]);
|
|
|
|
function spec435RuntimePolicy(
|
|
int $stdoutMaxBytes = 524288,
|
|
int $stderrMaxBytes = 65536,
|
|
int $itemLimit = 1000,
|
|
): ExchangePowerShellRuntimePolicy {
|
|
return new ExchangePowerShellRuntimePolicy(
|
|
invocationEnabled: true,
|
|
productionRunnerEnabled: true,
|
|
allowedEnvironments: [app()->environment()],
|
|
currentEnvironment: app()->environment(),
|
|
powershellBinary: 'pwsh',
|
|
moduleCheckEnabled: true,
|
|
checkTimeoutSeconds: 5,
|
|
invocationTimeoutSeconds: 60,
|
|
stdoutMaxBytes: $stdoutMaxBytes,
|
|
stderrMaxBytes: $stderrMaxBytes,
|
|
itemLimit: $itemLimit,
|
|
lockTtlSeconds: 300,
|
|
tempFilesEnabled: false,
|
|
tempDirectory: null,
|
|
);
|
|
}
|
|
|
|
function spec435VerifiedContract(string $canonicalType): ExchangePowerShellCommandContract
|
|
{
|
|
$contracts = new ExchangePowerShellCommandContracts;
|
|
$contract = $contracts->contractForCanonicalType($canonicalType);
|
|
|
|
if (! is_array($contract)) {
|
|
throw new RuntimeException('Missing Spec435 command contract.');
|
|
}
|
|
|
|
return ExchangePowerShellCommandContract::fromVerifiedArray($contract, $contracts, []);
|
|
}
|