Туториали

Laravel Deployment Guardian: Automate Security Checks Post-Push

Laravel Deployment Guardian: Автоматизирајте ги безбедносните проверки по push

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

Овој туторијал додава чувар по распоредувањето во Laravel апликација. Откако секое продукциско издание ќе стане достапно, Artisan команда бара од Website Security Analyzer да ја испита јавната HTTPS крајна точка. Интеграцијата ги мапира вратениот резултат, наодите групирани по сериозност, TLS деталите и препораките во строг доменски објект, а потоа запишува концизен резултат за операторите.

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

Обезбедете пристап пред да пишувате код за интеграција

Најпрво, регистрирајте сметка, или најавете се ако веќе имате.

Отворете ја страницата на услугата Website Security Analyzer. Изберете достапен Free, Plus или Pro план и завршете ја неговата активација. Изборот на планот припаѓа тука, наместо во кодот на апликацијата, бидејќи квотите и комерцијалните услови може да се променат независно од распоредувањето.

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

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

Точната операција е POST https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website. Пред да ја изградите Laravel функционалноста, потврдете го пристапот со минимално барање кон јавна HTTPS URL-адреса што ја контролирате:

export SECURITY_ANALYZER_TOKEN=YOUR_SERVICE_TOKEN

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

Не го предавајте токенот во складиштето и не оставајте вистинска вредност во shell скрипти. Ставете го во шифрираното складиште за тајни на продукциската платформа и изложете го на Laravel преку конфигурација поддржана од околински променливи:

# .env — use deployment secrets in production
SECURITY_ANALYZER_TOKEN=YOUR_SERVICE_TOKEN
SECURITY_ANALYZER_URL=https://www.example.com
SECURITY_ANALYZER_FAIL_ON=critical
<?php
// config/services.php

return [
    // Existing services...

    'website_security_analyzer' => [
        'endpoint' => 'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website',
        'token' => env('SECURITY_ANALYZER_TOKEN'),
        'url' => env('SECURITY_ANALYZER_URL'),
        'fail_on' => env('SECURITY_ANALYZER_FAIL_ON'),
    ],
];

По менувањето на продукциските тајни, повторно изградете го кешот на конфигурацијата на Laravel. Кодот на апликацијата мора да чита config(), а не директно да повикува env() откако конфигурацијата е кеширана.

Изберете намерно мала архитектура

Имплементацијата бара PHP 8.3 или понов, Laravel апликација, излезен HTTPS пристап до анализаторот и распоредена јавна HTTPS URL-адреса. Таа го користи вградениот HTTP клиент на Laravel, па не е потребен дополнителен HTTP пакет.

Подвижните делови се намерно ограничени:

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

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

Релевантната структура на проектот е:

app/
  Console/Commands/AnalyzeProductionWebsite.php
  Domain/Security/AnalyzerException.php
  Domain/Security/AnalyzerFailure.php
  Domain/Security/WebsiteSecurityReport.php
  Services/WebsiteSecurityAnalyzer.php
config/
  services.php
tests/
  Feature/WebsiteSecurityAnalyzerTest.php

Валидирајте го API одговорот на границата

Оддалечениот JSON е недоверлив влез дури и кога доаѓа од услуга што сте ја избрале. Маперот на одговори го прифаќа само доставениот договор: нумерички score, објект од findings групирани по сериозност, објект со tls детали и листа на текстуални recommendations. Тој намерно избегнува да нагаѓа недокументирани вгнездени полиња.

<?php
// app/Domain/Security/WebsiteSecurityReport.php

namespace App\Domain\Security;

use UnexpectedValueException;

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

    public static function fromArray(array $payload): self
    {
        $score = $payload['score'] ?? null;
        $findings = $payload['findings'] ?? null;
        $tls = $payload['tls'] ?? null;
        $recommendations = $payload['recommendations'] ?? null;

        if (! is_int($score) && ! is_float($score)) {
            throw new UnexpectedValueException('Analyzer score is not numeric.');
        }

        if (! is_array($findings) || array_is_list($findings)) {
            throw new UnexpectedValueException('Analyzer findings are not severity-grouped.');
        }

        foreach ($findings as $severity => $items) {
            if (! is_string($severity) || ! is_array($items) || ! array_is_list($items)) {
                throw new UnexpectedValueException('A findings group is malformed.');
            }
        }

        if (! is_array($tls) || array_is_list($tls)) {
            throw new UnexpectedValueException('Analyzer TLS details are malformed.');
        }

        if (! is_array($recommendations) || ! array_is_list($recommendations)) {
            throw new UnexpectedValueException('Analyzer recommendations are malformed.');
        }

        foreach ($recommendations as $recommendation) {
            if (! is_string($recommendation)) {
                throw new UnexpectedValueException('An analyzer recommendation is malformed.');
            }
        }

        return new self($score, $findings, $tls, $recommendations);
    }

    public function findingCounts(): array
    {
        return array_map(
            static fn (array $items): int => count($items),
            $this->findingsBySeverity,
        );
    }
}

Структурираните неуспеси ѝ овозможуваат на командата да разликува лоши ингеренции од минливи инфраструктурни проблеми без да анализира пораки за исклучоци:

<?php
// app/Domain/Security/AnalyzerFailure.php

namespace App\Domain\Security;

enum AnalyzerFailure: string
{
    case Configuration = 'configuration';
    case Authentication = 'authentication';
    case Validation = 'validation';
    case RateLimited = 'rate_limited';
    case RemoteService = 'remote_service';
    case Network = 'network';
    case MalformedResponse = 'malformed_response';
}

// app/Domain/Security/AnalyzerException.php

namespace App\Domain\Security;

use RuntimeException;

final class AnalyzerException extends RuntimeException
{
    public function __construct(
        public readonly AnalyzerFailure $failure,
        string $message,
    ) {
        parent::__construct($message);
    }
}

Изградете Laravel HTTP клиент свесен за повторни обиди

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

Нумеричката вредност Retry-After се почитува при ограничување на стапката, но е ограничена на пет секунди за распоредувањето да не може да заглави неограничено. Временските ограничувања за поврзување и за вкупен одговор обезбедуваат уште една строга граница.

<?php
// app/Services/WebsiteSecurityAnalyzer.php

namespace App\Services;

use App\Domain\Security\AnalyzerException;
use App\Domain\Security\AnalyzerFailure;
use App\Domain\Security\WebsiteSecurityReport;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use UnexpectedValueException;

final class WebsiteSecurityAnalyzer
{
    public function analyze(string $url): WebsiteSecurityReport
    {
        $endpoint = config('services.website_security_analyzer.endpoint');
        $token = config('services.website_security_analyzer.token');

        if (! is_string($endpoint) || $endpoint === '' ||
            ! is_string($token) || $token === '') {
            throw new AnalyzerException(
                AnalyzerFailure::Configuration,
                'The analyzer endpoint or token is not configured.',
            );
        }

        if (filter_var($url, FILTER_VALIDATE_URL) === false ||
            parse_url($url, PHP_URL_SCHEME) !== 'https' ||
            ! is_string(parse_url($url, PHP_URL_HOST))) {
            throw new AnalyzerException(
                AnalyzerFailure::Validation,
                'The configured target must be a valid HTTPS URL.',
            );
        }

        try {
            $response = Http::acceptJson()
                ->asJson()
                ->withToken($token)
                ->connectTimeout(5)
                ->timeout(20)
                ->retry(
                    3,
                    function (int $attempt, \Exception $exception): int {
                        if ($exception instanceof RequestException &&
                            $exception->response->status() === 429) {
                            $header = $exception->response->header('Retry-After');

                            if (is_string($header) && ctype_digit($header)) {
                                return min(max((int) $header * 1000, 250), 5000);
                            }
                        }

                        return min(250 * (2 ** ($attempt - 1)), 2000);
                    },
                    function (\Exception $exception): bool {
                        return $exception instanceof ConnectionException ||
                            ($exception instanceof RequestException && (
                                $exception->response->status() === 429 ||
                                $exception->response->serverError()
                            ));
                    },
                    throw: false,
                )
                ->post($endpoint, ['url' => $url]);
        } catch (ConnectionException) {
            throw new AnalyzerException(
                AnalyzerFailure::Network,
                'The analyzer could not be reached within the configured limits.',
            );
        }

        $this->guardStatus($response);

        $payload = $response->json();

        if (! is_array($payload)) {
            throw new AnalyzerException(
                AnalyzerFailure::MalformedResponse,
                'The analyzer returned non-object JSON.',
            );
        }

        try {
            return WebsiteSecurityReport::fromArray($payload);
        } catch (UnexpectedValueException) {
            throw new AnalyzerException(
                AnalyzerFailure::MalformedResponse,
                'The analyzer response did not match the expected contract.',
            );
        }
    }

    private function guardStatus(Response $response): void
    {
        if ($response->successful()) {
            return;
        }

        $failure = match (true) {
            in_array($response->status(), [401, 403], true) =>
                AnalyzerFailure::Authentication,
            in_array($response->status(), [400, 422], true) =>
                AnalyzerFailure::Validation,
            $response->status() === 429 =>
                AnalyzerFailure::RateLimited,
            default => AnalyzerFailure::RemoteService,
        };

        throw new AnalyzerException(
            $failure,
            'The analyzer request failed with HTTP '.$response->status().'.',
        );
    }
}

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

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

<?php
// app/Console/Commands/AnalyzeProductionWebsite.php

namespace App\Console\Commands;

use App\Domain\Security\AnalyzerException;
use App\Services\WebsiteSecurityAnalyzer;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;

final class AnalyzeProductionWebsite extends Command
{
    protected $signature = 'security:analyze-production';
    protected $description = 'Analyze the deployed public HTTPS website';

    public function handle(WebsiteSecurityAnalyzer $analyzer): int
    {
        if (! app()->environment('production')) {
            $this->error('This command runs only in production.');

            return self::INVALID;
        }

        $url = config('services.website_security_analyzer.url');

        if (! is_string($url) || $url === '') {
            $this->error('SECURITY_ANALYZER_URL is not configured.');

            return self::INVALID;
        }

        try {
            $report = $analyzer->analyze($url);
        } catch (AnalyzerException $exception) {
            Log::error('Post-deployment security analysis failed.', [
                'failure' => $exception->failure->value,
            ]);

            $this->error(
                'Security analysis failed: '.$exception->failure->value
            );

            return self::FAILURE;
        }

        $counts = $report->findingCounts();

        Log::info('Post-deployment security analysis completed.', [
            'url_host' => parse_url($url, PHP_URL_HOST),
            'score' => $report->score,
            'finding_counts' => $counts,
            'recommendation_count' => count($report->recommendations),
        ]);

        $this->info('Security score: '.$report->score);

        foreach ($counts as $severity => $count) {
            $this->line($severity.': '.$count);
        }

        $blockingSeverity = config(
            'services.website_security_analyzer.fail_on'
        );

        if (is_string($blockingSeverity) &&
            $blockingSeverity !== '' &&
            ($counts[$blockingSeverity] ?? 0) > 0) {
            $this->error(
                'Blocking findings detected for severity '.$blockingSeverity.'.'
            );

            return self::FAILURE;
        }

        return self::SUCCESS;
    }
}

Вообичаеното откривање команди на Laravel прави класата под app/Console/Commands достапна за Artisan. Потврдете го тоа со php artisan list пред да го менувате продукцискиот цевковод.

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

Http::fake() спречува тестовите да трошат квота или да зависат од мрежа. Првиот тест ја потврдува точната крајна точка, Bearer заглавието, телото на барањето и доменското мапирање. Вториот докажува дека неуспех при автентикација не се повторува.

<?php
// tests/Feature/WebsiteSecurityAnalyzerTest.php

namespace Tests\Feature;

use App\Domain\Security\AnalyzerException;
use App\Domain\Security\AnalyzerFailure;
use App\Services\WebsiteSecurityAnalyzer;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

final class WebsiteSecurityAnalyzerTest extends TestCase
{
    private string $endpoint =
        'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website';

    protected function setUp(): void
    {
        parent::setUp();

        config([
            'services.website_security_analyzer.endpoint' => $this->endpoint,
            'services.website_security_analyzer.token' => 'test-token',
        ]);
    }

    public function test_it_maps_a_successful_analysis(): void
    {
        Http::fake([
            $this->endpoint => Http::response([
                'score' => 91,
                'findings' => [
                    'critical' => [],
                    'medium' => [['summary' => 'Example finding']],
                ],
                'tls' => ['enabled' => true],
                'recommendations' => ['Review the reported finding.'],
            ], 200),
        ]);

        $report = app(WebsiteSecurityAnalyzer::class)
            ->analyze('https://www.example.com');

        $this->assertSame(91, $report->score);
        $this->assertCount(1, $report->findingsBySeverity['medium']);
        $this->assertTrue($report->tls['enabled']);

        Http::assertSent(fn (Request $request): bool =>
            $request->url() === $this->endpoint &&
            $request->method() === 'POST' &&
            $request->hasHeader('Authorization', 'Bearer test-token') &&
            $request['url'] === 'https://www.example.com'
        );
    }

    public function test_it_does_not_retry_an_authentication_failure(): void
    {
        Http::fakeSequence()->pushStatus(401);

        try {
            app(WebsiteSecurityAnalyzer::class)
                ->analyze('https://www.example.com');

            $this->fail('Expected AnalyzerException was not thrown.');
        } catch (AnalyzerException $exception) {
            $this->assertSame(
                AnalyzerFailure::Authentication,
                $exception->failure,
            );
        }

        Http::assertSentCount(1);
    }
}

Извршете го фокусираниот тест со php artisan test --filter=WebsiteSecurityAnalyzerTest. Користете лажни токени во тестовите; тест-пакетот никогаш не треба да ја има потреба од продукциската тајна.

Поврзете го со продукциското распоредување

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

php artisan migrate --force
php artisan config:cache
php artisan route:cache

curl --fail --silent --show-error \
  'https://www.example.com/up' > /dev/null

php artisan security:analyze-production

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

Дневниците намерно ги вклучуваат името на домаќинот, резултатот, броењата и категоријата на неуспех, но не и токенот, телото на одговорот, TLS товарот или содржината на наодите. Испратете ги тие структурирани настани до постојната дестинација за дневници на проектот и поставете предупредување за повторени неуспеси или значајна промена на резултатот. Избегнувајте логирање на заглавија на барања при овозможување HTTP дијагностика.

Вообичаени неуспеси за кои вреди да се планира

  • 401 или 403: потврдете ја активацијата на планот и токенот ограничен на услугата. Ако некој го регенерирал, стариот токен е поништен и секоја околина што го користи мора да се ажурира.
  • 400 или 422: потврдете дека SECURITY_ANALYZER_URL е целосна, јавно достапна HTTPS URL-адреса. Клиентот не ги повторува овие одговори.
  • 429: ограничениот повторен обид може да закрепне од краткотрајно ограничување, но повторените одговори бараат намалена зачестеност на распоредувањата, преглед на квотата или друг план — не бесконечна јамка за повторни обиди.
  • Неуспех на мрежата или временско ограничување: проверете го излезниот заштитен ѕид и DNS пристапот. Задржете го временското ограничување наместо да дозволите распоредувањата да висат.
  • Неисправен одговор: задржете ги категоријата на неуспех и HTTP статусот во телеметријата, но не го ослабувајте маперот за да прифаќа произволни облици. Прегледајте ја официјалната документација пред да ја промените границата.
  • Неочекувано блокирање: клучевите за сериозност се земаат од одговорот. Усогласете го SECURITY_ANALYZER_FAIL_ON точно со сериозноста што вашата политика има намера да ја блокира.

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

  1. Сметката и Free, Plus или Pro планот се активирани.
  2. Токенот ограничен на услугата се наоѓа само во продукциското складиште за тајни.
  3. Целта е јавна HTTPS URL-адреса контролирана од проектот.
  4. php artisan config:cache се извршува по промени во околината.
  5. Тестовите со HTTP лажни одговори поминуваат без да прават надворешни барања.
  6. Продукциската здравствена проверка успева пред да започне анализата.
  7. Распоредувањето повикува php artisan security:analyze-production точно еднаш.
  8. Дневниците прикажуваат резултат и броења по сериозност без ингеренции или сурови наоди.
  9. Ограничувањата на стапката, оддалечените неуспеси и политиката за блокирачка сериозност произведуваат видливи резултати во цевководот.

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

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

Mihajlo

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