diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..fc134fc --- /dev/null +++ b/.editorconfig @@ -0,0 +1,20 @@ +# https://EditorConfig.org + +root = true + +[*] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +block_comment_start = /* +block_comment = * +block_comment_end = */ + +[*.yml] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..cdcc774 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +.editorconfig export-ignore +.gitattributes export-ignore +.github/ export-ignore +.gitignore export-ignore +CHANGELOG.md export-ignore +ecs.php export-ignore +phpstan.neon export-ignore +phpunit.xml.dist export-ignore +docs/ export-ignore +tests/ export-ignore diff --git a/.github/workflows/integrate.yml b/.github/workflows/integrate.yml new file mode 100644 index 0000000..cdfe85e --- /dev/null +++ b/.github/workflows/integrate.yml @@ -0,0 +1,130 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow + +name: "Integrate" + +on: + push: + branches: + - "develop" + pull_request: null + +env: + PHP_EXTENSIONS: "intl" + +jobs: + file_consistency: + name: "1️⃣ File consistency" + runs-on: "ubuntu-latest" + steps: + - name: "Set up PHP" + uses: "shivammathur/setup-php@v2" + with: + php-version: "8.1" + extensions: "${{ env.PHP_EXTENSIONS }}" + ini-values: "post_max_size=256M" + + - name: "Checkout code" + uses: "actions/checkout@v4" + + - name: Install Effigy + run: | + composer global config --no-plugins allow-plugins.phpstan/extension-installer true + composer global require decodelabs/effigy + + - name: "Install dependencies" + uses: "ramsey/composer-install@v3" + with: + dependency-versions: "highest" + + - name: "Check file permissions" + run: | + composer global exec effigy check-executable-permissions + + - name: "Check exported files" + run: | + composer global exec effigy check-git-exports + + - name: "Find non-printable ASCII characters" + run: | + composer global exec effigy check-non-ascii + + - name: "Check source code for syntax errors" + run: | + composer global exec effigy lint + + static_analysis: + name: "3️⃣ Static Analysis" + needs: + - "file_consistency" + runs-on: "ubuntu-latest" + strategy: + matrix: + php-version: + - "8.1" + - "8.2" + - "8.3" + steps: + - name: "Set up PHP" + uses: "shivammathur/setup-php@v2" + with: + php-version: "${{ matrix.php-version }}" + extensions: "${{ env.PHP_EXTENSIONS }}" + ini-values: "post_max_size=256M" + + - name: "Checkout code" + uses: "actions/checkout@v4" + + - name: Install Effigy + run: | + composer global config --no-plugins allow-plugins.phpstan/extension-installer true + composer global require decodelabs/effigy + + - name: "Validate Composer configuration" + run: "composer validate --strict" + + - name: "Install dependencies" + uses: "ramsey/composer-install@v3" + with: + dependency-versions: "highest" + + - name: "Execute static analysis" + run: | + composer global exec effigy analyze -- --headless + + + coding_standards: + name: "4️⃣ Coding Standards" + needs: + - "file_consistency" + runs-on: "ubuntu-latest" + steps: + - name: "Set up PHP" + uses: "shivammathur/setup-php@v2" + with: + php-version: "8.1" + extensions: "${{ env.PHP_EXTENSIONS }}" + ini-values: "post_max_size=256M" + + - name: "Checkout code" + uses: "actions/checkout@v4" + + - name: "Check EditorConfig configuration" + run: "test -f .editorconfig" + + - name: "Check adherence to EditorConfig" + uses: "greut/eclint-action@v0" + + - name: Install Effigy + run: | + composer global config --no-plugins allow-plugins.phpstan/extension-installer true + composer global require decodelabs/effigy + + - name: "Install dependencies" + uses: "ramsey/composer-install@v3" + with: + dependency-versions: "highest" + + - name: "Check coding style" + run: | + composer global exec effigy format -- --headless + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6b0d60a --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +/vendor +composer.phar +composer.lock +.DS_Store +Thumbs.db +/phpunit.xml +/.idea +/.env diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f7d8d7b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,2 @@ +## v0.1.0 (2024-06-19) +* Built initial implementation diff --git a/README.md b/README.md new file mode 100644 index 0000000..550b233 --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# Scrutiny + +[![PHP from Packagist](https://img.shields.io/packagist/php-v/decodelabs/scrutiny?style=flat)](https://packagist.org/packages/decodelabs/scrutiny) +[![Latest Version](https://img.shields.io/packagist/v/decodelabs/scrutiny.svg?style=flat)](https://packagist.org/packages/decodelabs/scrutiny) +[![Total Downloads](https://img.shields.io/packagist/dt/decodelabs/scrutiny.svg?style=flat)](https://packagist.org/packages/decodelabs/scrutiny) +[![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/decodelabs/scrutiny/integrate.yml?branch=develop)](https://github.com/decodelabs/scrutiny/actions/workflows/integrate.yml) +[![PHPStan](https://img.shields.io/badge/PHPStan-enabled-44CC11.svg?longCache=true&style=flat)](https://github.com/phpstan/phpstan) +[![License](https://img.shields.io/packagist/l/decodelabs/scrutiny?style=flat)](https://packagist.org/packages/decodelabs/scrutiny) + +### Unified human check controls + +Scrutiny provides ... + +_Get news and updates on the [DecodeLabs blog](https://blog.decodelabs.com)._ + +--- + +## Installation + +Install via Composer: + +```bash +composer require decodelabs/scrutiny +``` + +## Usage + +Coming soon... + +## Licensing + +Scrutiny is licensed under the MIT License. See [LICENSE](./LICENSE) for the full license text. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..4dada07 --- /dev/null +++ b/composer.json @@ -0,0 +1,45 @@ +{ + "name": "decodelabs/scrutiny", + "description": "Unified human check controls", + "type": "library", + "keywords": [ ], + "license": "MIT", + "authors": [ { + "name": "Tom Wright", + "email": "tom@inflatablecookie.com" + } ], + "require": { + "php": "^8.1", + + "decodelabs/archetype": "^0.3.5", + "decodelabs/coercion": "^0.2.8", + "decodelabs/compass": "^0.1.7", + "decodelabs/exceptional": "^0.4.5", + "decodelabs/glitch-support": "^0.4.6", + "decodelabs/hydro": "^0.1.3", + "decodelabs/slingshot": "^0.1.9", + "decodelabs/veneer": "^0.10.25", + + "symfony/polyfill-php82": "^1.29" + }, + "require-dev": { + "decodelabs/phpstan-decodelabs": "^0.6.8", + "decodelabs/dovetail": "^0.2.5" + }, + "autoload": { + "psr-4": { + "DecodeLabs\\Scrutiny\\": "src/Scrutiny" + }, + "classmap": [ + "src/Dovetail/" + ], + "files": [ + "src/Scrutiny/Context.php" + ] + }, + "extra": { + "branch-alias": { + "dev-develop": "0.1.x-dev" + } + } +} diff --git a/ecs.php b/ecs.php new file mode 100644 index 0000000..3f198e9 --- /dev/null +++ b/ecs.php @@ -0,0 +1,14 @@ +withPaths([__DIR__.'/src']) + ->withPreparedSets( + cleanCode: true, + psr12: true + ); diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..16f0d34 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,4 @@ +parameters: + paths: + - src + level: max diff --git a/src/Dovetail/Config/Scrutiny.php b/src/Dovetail/Config/Scrutiny.php new file mode 100644 index 0000000..df50ed7 --- /dev/null +++ b/src/Dovetail/Config/Scrutiny.php @@ -0,0 +1,52 @@ + [ + 'enabled' => false, + 'siteKey' => '--siteKey--', + 'secret' => "{{envString('VERIFIER_SECRET')}}", + ] + ]; + } + + public function getFirstEnabledVerifier(): ?string + { + foreach ($this->data as $name => $settings) { + if ($settings->enabled->as('bool', [ + 'default' => true + ])) { + return (string)$name; + } + } + + return null; + } + + /** + * @return array + */ + public function getSettingsFor( + string $verifierName + ): array { + return $this->data->{$verifierName}->toArray(); + } +} diff --git a/src/Scrutiny/Config.php b/src/Scrutiny/Config.php new file mode 100644 index 0000000..607e1b0 --- /dev/null +++ b/src/Scrutiny/Config.php @@ -0,0 +1,22 @@ + + */ + public function getSettingsFor( + string $verifierName + ): array; +} diff --git a/src/Scrutiny/Context.php b/src/Scrutiny/Context.php new file mode 100644 index 0000000..56a6c93 --- /dev/null +++ b/src/Scrutiny/Context.php @@ -0,0 +1,226 @@ + + */ + protected array $hostNames = []; + + protected ?Config $config = null; + + /** + * Set config + */ + public function setConfig( + ?Config $config + ): void { + $this->config = $config; + } + + /** + * Get config + */ + public function getConfig(): ?Config + { + if ( + $this->config === null && + class_exists(Dovetail::class) + ) { + $this->config = ScrutinyConfig::load(); + } + + return $this->config; + } + + /** + * Load verifier + * + * @param array $config + */ + public function loadVerifier( + string $name, + ?array $config = null + ): Verifier { + if ($config === null) { + $config = $this->getConfig()?->getSettingsFor($name) ?? []; + } + + if ( + isset($config['enabled']) && + $config['enabled'] === false + ) { + throw Exceptional::NotFound( + 'Verifier ' . $name . ' is not enabled' + ); + } + + return (new Slingshot()) + ->addType($this) + ->resolveNamedInstance(Verifier::class, $name, $config); + } + + /** + * Load default verifier + */ + public function loadDefaultVerifier(): Verifier + { + $config = $this->getConfig(); + $name = $config?->getFirstEnabledVerifier(); + + if ($name === null) { + throw Exceptional::NotFound( + 'No verifiers are enabled' + ); + } + + return $this->loadVerifier( + $name, + $config?->getSettingsFor($name) ?? [] + ); + } + + /** + * Create payload and verify + * + * @param array $values + */ + public function verify( + ?string $verifierName = null, + array $values = [], + ?string $action = null, + ?float $scoreThreshold = null, + ?int $timeout = null, + ): Result { + return $this->verifyPayload( + $this->createPayload( + verifierName: $verifierName, + values: $values, + action: $action, + scoreThreshold: $scoreThreshold, + timeout: $timeout, + ) + ); + } + + /** + * Verify prepared payload + */ + public function verifyPayload( + Payload $payload + ): Result { + $name = $payload->getVerifierName(); + + try { + if ($name === null) { + $verifier = $this->loadDefaultVerifier(); + } else { + $verifier = $this->loadVerifier($name); + } + } catch (NotFoundException $e) { + return new Result( + payload: $payload, + errors: [ + Error::VerifierNotFound + ] + ); + } + + return $verifier->verify($payload); + } + + + /** + * Add host name + */ + public function addHostNames( + string ...$hostNames + ): void { + foreach ($hostNames as $hostName) { + $hostName = $this->prepareHostName($hostName); + + if (!in_array($hostName, $this->hostNames)) { + $this->hostNames[] = $hostName; + } + } + } + + /** + * Remove host name + */ + public function removeHostNames( + string ...$hostNames + ): void { + foreach ($hostNames as $hostName) { + $hostName = $this->prepareHostName($hostName); + $key = array_search($hostName, $this->hostNames); + + if ($key !== false) { + unset($this->hostNames[$key]); + } + } + } + + /** + * Get host names + * + * @return array + */ + public function getHostNames(): array + { + return $this->hostNames; + } + + /** + * Prepare host name + */ + public static function prepareHostName( + string $hostName + ): string { + $hostName = (string)preg_replace('|^https?://|', '', $hostName); + $hostName = trim($hostName, '/'); + return $hostName; + } + + + /** + * Create payload + * + * @param array $values + */ + public function createPayload( + ?string $verifierName = null, + array $values = [], + ?string $action = null, + ?float $scoreThreshold = null, + ?int $timeout = null, + ): Payload { + return new Payload( + verifierName: $verifierName, + values: $values, + hostNames: $this->hostNames, + action: $action, + scoreThreshold: $scoreThreshold, + timeout: $timeout, + ); + } +} + +// Register the Veneer facade +Veneer::register(Context::class, Scrutiny::class); diff --git a/src/Scrutiny/Error.php b/src/Scrutiny/Error.php new file mode 100644 index 0000000..e5ffe0d --- /dev/null +++ b/src/Scrutiny/Error.php @@ -0,0 +1,38 @@ + true, + default => false, + }; + } +} diff --git a/src/Scrutiny/Payload.php b/src/Scrutiny/Payload.php new file mode 100644 index 0000000..2f67bb7 --- /dev/null +++ b/src/Scrutiny/Payload.php @@ -0,0 +1,262 @@ + + */ + protected array $values = []; + + /** + * @var array + */ + protected array $hostNames = []; + protected ?string $action; + protected ?float $scoreThreshold = null; + protected ?int $timeout = null; + + /** + * Init with values + * + * @param array $values + * @param array $hostNames + */ + public function __construct( + ?string $verifierName, + ?Ip $ip = null, + array $values = [], + array $hostNames = [], + ?string $action = null, + ?int $timeout = null, + ?float $scoreThreshold = null, + ) { + if ($timeout <= 0) { + $timeout = null; + } + + $this->verifierName = $verifierName; + $this->ip = $ip; + $this->values = $values; + + $this->hostNames = $hostNames; + $this->action = $action; + $this->scoreThreshold = $scoreThreshold; + $this->timeout = $timeout; + } + + /** + * Get verifier name + */ + public function getVerifierName(): ?string + { + return $this->verifierName; + } + + /** + * Get IP + */ + public function getIp(): Ip + { + if ($this->ip === null) { + $this->ip = $this->extrapolateIp(); + } + + return $this->ip; + } + + protected function extrapolateIp(): Ip + { + $ips = ''; + + if (isset($_SERVER['HTTP_X_FORWARDED_FOR'])) { + $ips .= $_SERVER['HTTP_X_FORWARDED_FOR'] . ','; + } + + if (isset($_SERVER['REMOTE_ADDR'])) { + $ips .= $_SERVER['REMOTE_ADDR'] . ','; + } + + if (isset($_SERVER['HTTP_CLIENT_IP'])) { + $ips .= $_SERVER['HTTP_CLIENT_IP'] . ','; + } + + $parts = explode(',', rtrim($ips, ',')); + + while (!empty($parts)) { + $ip = trim(array_shift($parts)); + + try { + return Ip::parse($ip); + } catch (Throwable $e) { + } + } + + return new Ip('0.0.0.0'); + } + + + /** + * Get value + */ + public function getValue( + string $name + ): mixed { + return $this->values[$name] ?? null; + } + + /** + * Get all values + * + * @return array + */ + public function getValues(): array + { + return $this->values; + } + + + + /** + * Get host names + * + * @return array + */ + public function getHostNames(): array + { + return $this->hostNames; + } + + /** + * Has host names + */ + public function hasHostNames(): bool + { + return !empty($this->hostNames); + } + + /** + * Has host name + */ + public function validateHostName( + ?string $hostName + ): ?bool { + if ( + $hostName === null || + empty($this->hostNames) + ) { + return null; + } + + return in_array(Context::prepareHostName($hostName), $this->hostNames); + } + + /** + * Get action + */ + public function getAction(): string + { + return $this->action ?? 'default'; + } + + /** + * Validate action + */ + public function validateAction( + ?string $action + ): ?bool { + if ( + $action === null || + $this->action === null + ) { + return null; + } + + return $action === $this->action; + } + + /** + * Set timeout + */ + public function setTimeout( + ?int $timeout + ): void { + if ($timeout <= 0) { + $timeout = null; + } + + $this->timeout = $timeout; + } + + /** + * Get timeout + */ + public function getTimeout(): ?int + { + return $this->timeout; + } + + /** + * Validate timeout + */ + public function validateTimeout( + ?int $timestamp + ): ?bool { + if ( + $timestamp === null || + $this->timeout === null + ) { + return null; + } + + return time() - $timestamp <= $this->timeout; + } + + + /** + * Set score threshold + */ + public function setScoreThreshold( + ?float $scoreThreshold + ): void { + $this->scoreThreshold = min(1, max(0, $scoreThreshold)); + } + + /** + * Get score threshold + */ + public function getScoreThreshold(): ?float + { + return $this->scoreThreshold; + } + + /** + * Validate score threshold + */ + public function validateScoreThreshold( + ?float $score + ): ?bool { + if ( + $score === null || + $this->scoreThreshold === null + ) { + return null; + } + + return $score < $this->scoreThreshold; + } +} diff --git a/src/Scrutiny/Response.php b/src/Scrutiny/Response.php new file mode 100644 index 0000000..49a8345 --- /dev/null +++ b/src/Scrutiny/Response.php @@ -0,0 +1,79 @@ +getTimestamp(); + } + + $this->hostName = $hostName; + $this->action = $action; + $this->timestamp = $timestamp; + $this->score = $score; + $this->rawScore = $rawScore; + } + + /** + * Get host name + */ + public function getHostName(): ?string + { + return $this->hostName; + } + + /** + * Get action + */ + public function getAction(): ?string + { + return $this->action; + } + + /** + * Get timestamp + */ + public function getTimestamp(): ?int + { + return $this->timestamp; + } + + /** + * Get score + */ + public function getScore(): ?float + { + return $this->score; + } + + /** + * Get raw score + */ + public function getRawScore(): int|float|string|null + { + return $this->rawScore; + } +} diff --git a/src/Scrutiny/Result.php b/src/Scrutiny/Result.php new file mode 100644 index 0000000..62dc956 --- /dev/null +++ b/src/Scrutiny/Result.php @@ -0,0 +1,100 @@ + + */ + protected array $errors = []; + + /** + * Init with payload + * + * @param array $errors + */ + public function __construct( + Payload $payload, + ?Response $response = null, + array $errors = [] + ) { + $this->payload = $payload; + $this->response = $response; + $this->errors = $errors; + + // Host name + if (false === $this->payload->validateHostName( + $this->response?->getHostName() + )) { + $this->errors[] = Error::HostNameMismatch; + } + + // Action + if (false === $this->payload->validateAction( + $this->response?->getAction() + )) { + $this->errors[] = Error::ActionMismatch; + } + + // Threshold + if (false === $this->payload->validateScoreThreshold( + $this->response?->getScore() + )) { + $this->errors[] = Error::RiskThresholdExceeded; + } + + // Timeout + if (false === $this->payload->validateTimeout( + $this->response?->getTimestamp() + )) { + $this->errors[] = Error::Timeout; + } + + $this->errors = array_unique($this->errors); + } + + /** + * Get payload + */ + public function getPayload(): Payload + { + return $this->payload; + } + + /** + * Get response + */ + public function getResponse(): ?Response + { + return $this->response; + } + + /** + * Is valid + */ + public function isValid(): bool + { + return empty($this->errors); + } + + /** + * Get errors + * + * @return array + */ + public function getErrors(): array + { + return $this->errors; + } +} diff --git a/src/Scrutiny/Verifier.php b/src/Scrutiny/Verifier.php new file mode 100644 index 0000000..fa8d32d --- /dev/null +++ b/src/Scrutiny/Verifier.php @@ -0,0 +1,17 @@ +siteKey = $siteKey; + $this->secret = $secret; + } + + /** + * Verify payload + */ + public function verify( + Payload $payload + ): Result { + $ip = $payload->getIp(); + $key = static::RESPONSE_FIELD_NAME; + $value = $payload->getValue($key); + + if ($value === null) { + return new Result( + payload: $payload, + errors: [ + Error::InvalidPayload + ] + ); + } + + $httpResponse = Hydro::request('POST', [ + 'url' => static::VERIFY_URL, + 'form_params' => [ + 'secret' => $this->secret, + 'response' => $value, + 'remoteIp' => (string)$ip + ] + ]); + + if ($httpResponse->getStatusCode() !== 200) { + return new Result( + payload: $payload, + errors: [ + match ($httpResponse->getStatusCode()) { + 404, + 500 => Error::VerifierFailed, + default => Error::InvalidInput + } + ] + ); + } + + $data = (array)json_decode((string)$httpResponse->getBody(), true); + + if (!($data['success'] ?? false)) { + $errors = []; + + foreach ($data['error-codes'] ?? [] as $code) { + $errors[] = match ($code) { + 'missing-input-response' => Error::InvalidPayload, + 'invalid-input-response' => Error::InvalidInput, + 'invalid-input-secret', + 'missing-input-secret' => Error::InvalidSecret, + 'timeout-or-duplicate' => Error::Timeout, + default => Error::VerifierFailed + }; + } + + return new Result( + payload: $payload, + errors: $errors + ); + } + + return new Result( + $payload, + $this->createResponse($data) + ); + } + + /** + * @param array $data + */ + abstract protected function createResponse( + array $data + ): Response; +} diff --git a/stubs/DecodeLabs/Scrutiny.php b/stubs/DecodeLabs/Scrutiny.php new file mode 100644 index 0000000..59c0dde --- /dev/null +++ b/stubs/DecodeLabs/Scrutiny.php @@ -0,0 +1,52 @@ +getConfig(); + } + public static function loadVerifier(string $name, ?array $config = NULL): Ref1 { + return static::$instance->loadVerifier(...func_get_args()); + } + public static function loadDefaultVerifier(): Ref1 { + return static::$instance->loadDefaultVerifier(); + } + public static function verify(?string $verifierName = NULL, array $values = [], ?string $action = NULL, ?float $scoreThreshold = NULL, ?int $timeout = NULL): Ref2 { + return static::$instance->verify(...func_get_args()); + } + public static function verifyPayload(Ref3 $payload): Ref2 { + return static::$instance->verifyPayload(...func_get_args()); + } + public static function addHostNames(string ...$hostNames): void {} + public static function removeHostNames(string ...$hostNames): void {} + public static function getHostNames(): array { + return static::$instance->getHostNames(); + } + public static function prepareHostName(string $hostName): string { + return static::$instance->prepareHostName(...func_get_args()); + } + public static function createPayload(?string $verifierName = NULL, array $values = [], ?string $action = NULL, ?float $scoreThreshold = NULL, ?int $timeout = NULL): Ref3 { + return static::$instance->createPayload(...func_get_args()); + } +};