Туториали

Native PHP 8.3: Automate Post-Deployment Website Security Audits

Native PHP 8.3: Автоматизирајте безбедносни ревизии на веб-страници по распоредувањето

Продукциското распоредување не е завршено само затоа што новото издание одговара на проверките на здравјето. Промените во пренасочувањата, правилата за прокси, сертификатите, заглавијата на одговорот или политиката за безбедност на содржината можат тивко да ја ослабат јавната страница додека самата апликација останува здрава.

Ова упатство создава Native PHP 8.3 команда што се извршува по секое продукциско распоредување, ја испраќа јавната HTTPS URL-адреса до Website Security Analyzer, го мапира резултатот во мал доменски објект и запишува атомски JSON артефакт за ревизија. Интеграцијата користи вграден cURL, ограничени повторни обиди, детерминистички тестови и експлицитни состојби на неуспех.

Анализаторот врши ограничена, неинвазивна анализа на јавниот HTTPS и безбедносната поставеност на прелистувачот. Третирајте го неговиот излез како повратна информација за распоредувањето, а не како пенетрациски тест, процена на ранливости или доказ дека страницата е безбедна.

Добијте пристап и копирајте го сервисниот токен

  1. Регистрирајте се на https://ai.mihajlo.mk/register, или користете https://ai.mihajlo.mk/login ако веќе имате сметка.
  2. Отворете ја страницата на услугата Website Security Analyzer.
  3. Изберете достапен Free, Plus или Pro план и завршете ја неговата активација.
  4. Отворете ја официјалната документација за услугата.
  5. Најдете го панелот Service token и копирајте го токенот со опсег на услугата.
  6. Зачувајте го во конфигурацијата на околината за распоредување на вашиот проект. Повторното генерирање на токенот го поништува претходно активниот токен, па распоредувањата и складиштата за тајни мора да се ажурираат заедно.

Оваа услуга бара автентикација; за оваа интеграција не постои режим без токен. Прифаќа Bearer токен, заглавие X-API-Token или параметар за пребарување token. Ќе ја користиме Bearer формата бидејќи параметрите за пребарување можат да бидат зачувани во дневниците за пристап, проксите и системите за надзор.

Проверете ја крајната точка пред да пишувате PHP

Точниот повик е POST https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website. Неговото JSON тело содржи url:

export WEBSITE_SECURITY_ANALYZER_TOKEN='YOUR_SERVICE_TOKEN'

curl --fail-with-body \
  --request POST \
  --header "Authorization: Bearer ${WEBSITE_SECURITY_ANALYZER_TOKEN}" \
  --header 'Accept: application/json' \
  --header 'Content-Type: application/json' \
  --data '{"url":"https://www.example.com"}' \
  https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website

Користете еднократна школка или вметнување тајни од CI наместо да ставате вистински токен во историјата на терминалот. Договорот за одговор вклучува резултат, наоди групирани по сериозност, TLS детали и препораки. Нема да претпоставуваме недокументирани полиња во тие колекции.

Архитектура и облик на проектот

Дизајнот намерно останува мал: куката за распоредување повикува команда, командата повикува посветен API клиент, а клиентот го делегира мрежниот В/И на транспортен интерфејс. Тој интерфејс ги прави PHPUnit тестовите детерминистички без контактирање на услугата.

security-audit/
├── bin/audit-production.php
├── src/AnalyzerClient.php
├── src/AnalyzerResult.php
├── src/Http/CurlTransport.php
├── src/Http/HttpResponse.php
├── src/Http/HttpTransport.php
├── tests/AnalyzerClientTest.php
├── composer.json
└── phpunit.xml

Командата се извршува синхроно по распоредувањето. Тоа го прави завршувањето видливо за CI и избегнува додавање редица само за една кратка интеграција. Бидејќи изданието можеби веќе е активно, неуспешната ревизија го означува работниот тек на распоредувањето како неуспешен, но не се преправа дека автоматски ја враќа инфраструктурата назад.

Предуслови и конфигурација на околината

  • PHP 8.3 или понов со екстензиите cURL и JSON.
  • Composer.
  • Јавна HTTPS продукциска URL-адреса достапна за анализаторот.
  • Токен со опсег на услугата зачуван надвор од контрола на верзии.

Инсталирајте PHPUnit 11, кој поддржува PHP 8.3, и конфигурирајте PSR-4 автоматско вчитување:

{
  "require": {
    "php": "^8.3",
    "ext-curl": "*",
    "ext-json": "*"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.0"
  },
  "autoload": {
    "psr-4": {
      "App\\": "src/"
    }
  }
}
composer install
composer dump-autoload
mkdir -p var/security-audit

На продукцискиот хост, создадете датотека за околина читлива само за сметката за распоредување. Не ја додавајте во верзиската контрола:

# /etc/my-site/security-audit.env
WEBSITE_SECURITY_ANALYZER_TOKEN=YOUR_SERVICE_TOKEN
PRODUCTION_PUBLIC_URL=https://www.example.com
SECURITY_AUDIT_OUTPUT=/srv/my-site/current/var/security-audit/latest.json

Применете рестриктивни дозволи за датотеки преку вашиот систем за обезбедување. Избегнувајте печатење на оваа датотека или овозможување следење на школката додека е вчитана.

Изградете ја границата со вграден cURL

Транспортот враќа само статус, заглавија на одговор и тело. Овозможува проверка на сертификати, го ограничува времетраењето на поврзувањето и вкупното времетраење и намерно избегнува евидентирање на заглавијата на барањето.

<?php
// src/Http/HttpResponse.php
namespace App\Http;

final readonly class HttpResponse
{
    public function __construct(
        public int $status,
        public array $headers,
        public string $body,
    ) {}
}

// src/Http/HttpTransport.php
namespace App\Http;

interface HttpTransport
{
    public function postJson(
        string $url,
        array $headers,
        array $body,
        int $connectTimeoutMs,
        int $timeoutMs,
    ): HttpResponse;
}
<?php
// src/Http/CurlTransport.php
namespace App\Http;

use JsonException;
use RuntimeException;

final class CurlTransport implements HttpTransport
{
    public function postJson(
        string $url,
        array $headers,
        array $body,
        int $connectTimeoutMs,
        int $timeoutMs,
    ): HttpResponse {
        try {
            $payload = json_encode($body, JSON_THROW_ON_ERROR);
        } catch (JsonException $e) {
            throw new RuntimeException('Could not encode request JSON.', 0, $e);
        }

        $handle = curl_init($url);
        if ($handle === false) {
            throw new RuntimeException('Could not initialize cURL.');
        }

        $responseHeaders = [];
        curl_setopt_array($handle, [
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => $payload,
            CURLOPT_HTTPHEADER => $headers,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT_MS => $connectTimeoutMs,
            CURLOPT_TIMEOUT_MS => $timeoutMs,
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
            CURLOPT_HEADERFUNCTION => static function ($curl, string $line) use (&$responseHeaders): int {
                $length = strlen($line);
                $parts = explode(':', $line, 2);
                if (count($parts) === 2) {
                    $responseHeaders[strtolower(trim($parts[0]))] = trim($parts[1]);
                }
                return $length;
            },
        ]);

        $bodyText = curl_exec($handle);
        if ($bodyText === false) {
            $message = curl_error($handle);
            curl_close($handle);
            throw new RuntimeException('Analyzer transport failed: ' . $message);
        }

        $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
        curl_close($handle);

        return new HttpResponse($status, $responseHeaders, $bodyText);
    }
}

Дефанзивно мапирајте го одговорот на услугата

Доменскиот објект го препознава само доставениот договор. Ги бара четирите области на резултатот од највисоко ниво, но ги зачувува нивните недокументирани внатрешни податоци наместо да измислува шема.

<?php
// src/AnalyzerResult.php
namespace App;

use UnexpectedValueException;

final readonly class AnalyzerResult
{
    public function __construct(
        public int|float $score,
        public array $findingsBySeverity,
        public array $tls,
        public array $recommendations,
    ) {}

    public static function fromArray(array $data): self
    {
        if (!isset($data['score']) || !is_numeric($data['score'])) {
            throw new UnexpectedValueException('Response score is missing or invalid.');
        }
        if (!isset($data['findings']) || !is_array($data['findings'])) {
            throw new UnexpectedValueException('Response findings are missing or invalid.');
        }
        foreach ($data['findings'] as $severity => $items) {
            if (!is_string($severity) || !is_array($items)) {
                throw new UnexpectedValueException('Findings are not grouped by severity.');
            }
        }
        if (!isset($data['tls']) || !is_array($data['tls'])) {
            throw new UnexpectedValueException('Response TLS details are missing or invalid.');
        }
        if (!isset($data['recommendations']) || !is_array($data['recommendations'])) {
            throw new UnexpectedValueException('Response recommendations are missing or invalid.');
        }

        return new self(
            $data['score'] + 0,
            $data['findings'],
            $data['tls'],
            $data['recommendations'],
        );
    }

    public function toArray(): array
    {
        return [
            'score' => $this->score,
            'findings' => $this->findingsBySeverity,
            'tls' => $this->tls,
            'recommendations' => $this->recommendations,
        ];
    }
}

Клиентот повторно се обидува при транспортни неуспеси, HTTP 429 и серверски грешки. Не се обидува повторно при автентикација или други клиентски грешки: повторувањето неважечки токен или погрешно формулирано барање само троши време и квота. И експоненцијалното повлекување и доцнењата побарани од серверот се ограничени.

<?php
// src/AnalyzerClient.php
namespace App;

use App\Http\HttpTransport;
use Closure;
use JsonException;
use RuntimeException;

final class AnalyzerClient
{
    private const ENDPOINT =
        'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website';

    private Closure $sleep;

    public function __construct(
        private readonly HttpTransport $transport,
        private readonly string $token,
        ?Closure $sleep = null,
    ) {
        if ($token === '') {
            throw new RuntimeException('Analyzer token is empty.');
        }
        $this->sleep = $sleep ?? static fn(int $microseconds) => usleep($microseconds);
    }

    public function analyze(string $url): AnalyzerResult
    {
        if (filter_var($url, FILTER_VALIDATE_URL) === false
            || parse_url($url, PHP_URL_SCHEME) !== 'https') {
            throw new RuntimeException('Production URL must be valid HTTPS.');
        }

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->transport->postJson(
                    self::ENDPOINT,
                    [
                        'Authorization: Bearer ' . $this->token,
                        'Accept: application/json',
                        'Content-Type: application/json',
                    ],
                    ['url' => $url],
                    3000,
                    20000,
                );
            } catch (RuntimeException $e) {
                if ($attempt === 3) {
                    throw $e;
                }
                ($this->sleep)(250000 * (2 ** ($attempt - 1)));
                continue;
            }

            if ($response->status === 429 || $response->status >= 500) {
                if ($attempt === 3) {
                    throw new RuntimeException(
                        'Analyzer temporarily unavailable; HTTP ' . $response->status
                    );
                }
                $seconds = ctype_digit($response->headers['retry-after'] ?? '')
                    ? min(5, (int) $response->headers['retry-after'])
                    : 0;
                ($this->sleep)($seconds > 0
                    ? $seconds * 1000000
                    : 250000 * (2 ** ($attempt - 1)));
                continue;
            }

            if ($response->status === 401 || $response->status === 403) {
                throw new RuntimeException('Analyzer authentication was rejected.');
            }
            if ($response->status < 200 || $response->status >= 300) {
                throw new RuntimeException(
                    'Analyzer rejected the request; HTTP ' . $response->status
                );
            }

            try {
                $decoded = json_decode($response->body, true, 512, JSON_THROW_ON_ERROR);
            } catch (JsonException $e) {
                throw new RuntimeException('Analyzer returned invalid JSON.', 0, $e);
            }
            if (!is_array($decoded)) {
                throw new RuntimeException('Analyzer returned an invalid document.');
            }

            return AnalyzerResult::fromArray($decoded);
        }

        throw new RuntimeException('Analyzer retry loop ended unexpectedly.');
    }
}

Создадете ја командата за распоредување

Командата бележи или completed или unavailable. Запишува преку привремена датотека и преименување, па читателите никогаш не гледаат полунапишан JSON. Дневникот за грешки ги содржи целта и причината, но никогаш токенот или телото на одговорот.

<?php
// bin/audit-production.php
declare(strict_types=1);

use App\AnalyzerClient;
use App\Http\CurlTransport;

require dirname(__DIR__) . '/vendor/autoload.php';

$output = getenv('SECURITY_AUDIT_OUTPUT')
    ?: dirname(__DIR__) . '/var/security-audit/latest.json';
$target = getenv('PRODUCTION_PUBLIC_URL') ?: null;

try {
    $token = getenv('WEBSITE_SECURITY_ANALYZER_TOKEN');
    if ($target === null || $token === false || $token === '') {
        throw new RuntimeException('Required audit environment is missing.');
    }

    $result = (new AnalyzerClient(new CurlTransport(), $token))->analyze($target);
    $record = [
        'status' => 'completed',
        'checked_at' => gmdate(DATE_ATOM),
        'target_url' => $target,
        'analysis' => $result->toArray(),
        'scope' => 'bounded public HTTPS and browser security posture analysis',
    ];
    $exitCode = 0;
} catch (Throwable $e) {
    $record = [
        'status' => 'unavailable',
        'checked_at' => gmdate(DATE_ATOM),
        'target_url' => $target,
        'reason' => $e->getMessage(),
    ];
    fwrite(STDERR, json_encode([
        'event' => 'website_security_audit_failed',
        'target_url' => $target,
        'reason' => $e->getMessage(),
    ], JSON_THROW_ON_ERROR) . PHP_EOL);
    $exitCode = 2;
}

$directory = dirname($output);
if (!is_dir($directory) && !mkdir($directory, 0750, true) && !is_dir($directory)) {
    throw new RuntimeException('Could not create audit output directory.');
}

$json = json_encode($record, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
$temporary = $output . '.tmp.' . getmypid();

if (file_put_contents($temporary, $json . PHP_EOL, LOCK_EX) === false
    || !rename($temporary, $output)) {
    throw new RuntimeException('Could not persist audit result.');
}

fwrite(STDOUT, json_encode([
    'event' => 'website_security_audit_finished',
    'status' => $record['status'],
    'target_url' => $target,
], JSON_THROW_ON_ERROR) . PHP_EOL);

exit($exitCode);

Тестирајте мапирање, повторни обиди и неуспеси на автентикација

Лажен транспорт обезбедува одговори во редица и брои повици. Вметнатиот sleeper без дејство ги одржува тестовите за повторни обиди брзи и детерминистички.

<?php
// tests/AnalyzerClientTest.php
use App\AnalyzerClient;
use App\Http\HttpResponse;
use App\Http\HttpTransport;
use PHPUnit\Framework\TestCase;

final class FakeTransport implements HttpTransport
{
    public int $calls = 0;

    public function __construct(private array $responses) {}

    public function postJson(
        string $url,
        array $headers,
        array $body,
        int $connectTimeoutMs,
        int $timeoutMs,
    ): HttpResponse {
        $this->calls++;
        return array_shift($this->responses);
    }
}

final class AnalyzerClientTest extends TestCase
{
    private function response(int $status, array $headers = []): HttpResponse
    {
        return new HttpResponse(
            $status,
            $headers,
            '{"score":91,"findings":{},"tls":{},"recommendations":[]}'
        );
    }

    public function testMapsDocument(): void
    {
        $fake = new FakeTransport([$this->response(200)]);
        $client = new AnalyzerClient($fake, 'test-token', static fn(int $us) => null);

        self::assertSame(91, $client->analyze('https://www.example.com')->score);
        self::assertSame(1, $fake->calls);
    }

    public function testRetriesRateLimitThenSucceeds(): void
    {
        $fake = new FakeTransport([
            $this->response(429, ['retry-after' => '1']),
            $this->response(200),
        ]);
        $client = new AnalyzerClient($fake, 'test-token', static fn(int $us) => null);

        $client->analyze('https://www.example.com');
        self::assertSame(2, $fake->calls);
    }

    public function testDoesNotRetryAuthenticationFailure(): void
    {
        $fake = new FakeTransport([$this->response(401)]);
        $client = new AnalyzerClient($fake, 'test-token', static fn(int $us) => null);

        $this->expectExceptionMessage('authentication was rejected');
        try {
            $client->analyze('https://www.example.com');
        } finally {
            self::assertSame(1, $fake->calls);
        }
    }
}
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php" colors="true">
  <testsuites>
    <testsuite name="security-audit">
      <directory>tests</directory>
    </testsuite>
  </testsuites>
</phpunit>
vendor/bin/phpunit

Прикачете ја на продукциското распоредување

Извршувајте ја командата само откако јавната верзија и нејзината HTTPS рута се достапни. Генеричка фаза на распоредување може да ја вчита заштитената датотека за околина и да ја повика ревизијата:

set -eu
set -a
. /etc/my-site/security-audit.env
set +a

cd /srv/my-site/current
php bin/audit-production.php

Држете го API повикот надвор од барањата на прелистувачот. Распоредувањето е правилната граница: корисниците не чекаат за него, повторните обиди се контролирани, а добиениот артефакт може да се собере од CI или надзор. Поставете предупредување за структурираниот настан website_security_audit_failed и чувајте ги ревизорските датотеки според вашата вообичаена оперативна политика.

Не ги прогласувајте изданијата автоматски за неуспешни врз основа на претпоставено значење на опсег на резултати. Прво потврдете ја документираната семантика на резултатот и дефинирајте експлицитна продуктна политика. Неуспех на транспортот, наод и прекршување на политика се различни состојби и треба да останат разликувачки.

Вообичаени продукциски неуспеси

  • HTTP 401 или 403: потврдете ја активацијата на планот и вметнувањето на токенот. Ако токенот е повторно генериран, заменете ја поништената вредност насекаде.
  • HTTP 429: клиентот почитува нумеричка вредност Retry-After до пет секунди. Постојаниот притисок врз квотата треба да се реши преку распоредување или капацитет на планот, а не со агресивни повторни обиди.
  • Истекувања на време или серверски грешки: три ограничени обиди покриваат краткотрајни прекини. Продолжениот неуспех создава артефакт unavailable и излезен код 2.
  • Неважечки облик на одговор: третирајте го отстапувањето од договорот како неуспех на интеграцијата. Зачувајте го суровиот одговор само во строго контролиран дијагностички процес; не го истурајте неселективно во дневниците.
  • Приватна URL-адреса или URL-адреса пред издание: услугата анализира јавна веб-страница. Извршете ја откако промените во DNS, TLS, прокси и автентикација ќе ја направат наменетата продукциска URL-адреса јавно достапна.

Конечна контролна листа за проверка

  • Конфигурираната цел е канонската јавна HTTPS URL-адреса, а не внатрешно име на хост.
  • Вистинскиот сервисен токен постои само во конфигурација за тајни поддржана од околината.
  • PHP има овозможени cURL и JSON, зависностите на Composer се инсталирани и PHPUnit поминува.
  • Сметката за распоредување може да запишува во директориумот за ревизија без да му доделува јавен веб-пристап.
  • Рачно продукциско извршување создава важечки JSON што содржи статус, временски печат, цел, резултат, групирани наоди, TLS детали и препораки.
  • Неуспесите на автентикација не се повторуваат, додека ограничувањата на стапката и минливите неуспеси користат ограничено повлекување.
  • Работниот тек на распоредување ја извршува командата по секое продукциско издание и го прикажува излезниот код 2.
  • Оперативната документација наведува дека резултатот е ограничена анализа на безбедносната поставеност, а не пенетрациски тест.

Корисната ревизија при распоредување не е најгласниот безбедносен механизам во системот. Таа е доверливата: тесно ограничена по опсег, безбедно автентицирана, искрена за неуспехот и присутна по секое издание. Со оваа команда, безбедносната поставеност кон јавноста станува дел од испорачувањето софтвер наместо повремена задача на која се сеќавате откако нешто веќе тргнало наопаку.

Портрет на автор на блогот

Mihajlo

Јас сум Михајло - развивач поттикнат од љубопитност, дисциплина и постојаната желба да создадам нешто значајно. Споделувам увиди, упатства и бесплатни услуги за да им помогнам на другите да ја поедностават својата работа и да растат во постојано развивачкиот свет на софтверот и вештачката интелигенција.