Туториали

Secure Your Laravel Deployments: Automate Website Security Scans Post-Launch

Обезбедете ги вашите Laravel распоредувања: Автоматизирајте ги безбедносните скенирања на веб-страниците по пуштањето во употреба

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

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

Добијте пристап до Website Security Analyzer

Започнете со регистрација на сметка, или користете ја страницата за најава ако веќе имате сметка.

  1. Отворете ја страницата на услугата Website Security Analyzer.
  2. Изберете достапен Free, Plus или Pro план и завршете ја неговата активација.
  3. Отворете ја официјалната документација за услугата.
  4. Најдете го панелот Service token и копирајте го неговиот токен ограничен на услугата.
  5. Зачувајте го тој токен во конфигурацијата на проектот поддржана од околински променливи.

Повторното генерирање на сервисниот токен го поништува претходно активниот токен. Координирајте ја ротацијата со конфигурацијата за поставување за продукцијата никогаш да не зависи од токен што веќе е поништен.

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

Потврдете ја точната крајна точка

Интеграцијата испраќа POST барања до https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website. JSON телото содржи една задолжителна вредност: url.

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

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

Ставете ги акредитивот и целта за поставување во локалната конфигурација .env за време на развојот. Во продукција, внесете еквивалентни вредности преку платформата за поставување или менаџерот за тајни. Никогаш не додавајте во верзионска контрола пополнета датотека со околински променливи.

WEBSITE_ANALYZER_TOKEN=YOUR_SERVICE_TOKEN
WEBSITE_ANALYZER_URL=https://www.example.com
WEBSITE_ANALYZER_CONNECT_TIMEOUT=3
WEBSITE_ANALYZER_TIMEOUT=15

Изберете архитектура прилагодена за поставување

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

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

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

app/
  Console/Commands/AnalyzeProductionWebsite.php
  Domain/Security/WebsiteAnalysis.php
  Services/WebsiteAnalyzerClient.php
config/
  services.php
tests/
  Feature/AnalyzeProductionWebsiteTest.php

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

Додајте посебен запис во config/services.php. Конфигурациските датотеки може да читаат околински променливи; апликациските класи наместо тоа треба да читаат Laravel конфигурација. Таа разлика го одржува config:cache сигурен.

<?php

return [
    // Existing service configuration...

    'website_analyzer' => [
        'token' => env('WEBSITE_ANALYZER_TOKEN'),
        'url' => env('WEBSITE_ANALYZER_URL'),
        'connect_timeout' => (int) env('WEBSITE_ANALYZER_CONNECT_TIMEOUT', 3),
        'timeout' => (int) env('WEBSITE_ANALYZER_TIMEOUT', 15),
    ],
];

Мапирајте го одговорот на границата на апликацијата

Оддалечениот JSON не треба да тече низ апликацијата без валидација. Резултатот од анализаторот содржи резултат, наоди групирани според сериозност, TLS детали и препораки. Мал непроменлив објект ги проверува тие облици пред кодот за поставување да се потпре на нив.

<?php

namespace App\Domain\Security;

use UnexpectedValueException;

final readonly class WebsiteAnalysis
{
    public function __construct(
        public int|float $score,
        public array $findings,
        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 missing or invalid.');
        }

        if (! is_array($findings) || ! is_array($tls) || ! is_array($recommendations)) {
            throw new UnexpectedValueException('Analyzer result sections are missing or invalid.');
        }

        foreach ($findings as $severity => $items) {
            if (! is_string($severity) || ! is_array($items)) {
                throw new UnexpectedValueException('Findings are not grouped by severity.');
            }
        }

        foreach ($recommendations as $recommendation) {
            if (! is_string($recommendation)) {
                throw new UnexpectedValueException('Analyzer recommendations are invalid.');
            }
        }

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

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

Овој мапер намерно избегнува да претпоставува недокументирани прагови за резултати, имиња на наоди или TLS подполиња. Ако договорот за услугата се промени, клиентот ќе создаде видлив неуспех на протоколот наместо тивко да зачува погрешни делумни податоци.

Изградете ограничен API клиент што е свесен за повторни обиди

Вградениот HTTP клиент на Laravel обезбедува JSON кодирање, Bearer автентикација, истекување на времето и детерминистички лажни одговори за тестови. Клиентот подолу повторува обиди при неуспеси на конекцијата, HTTP 429 и избрани привремени одговори од серверот. Не повторува обиди при неуспеси на автентикација или валидација, бидејќи повторените идентични барања не можат да поправат лош токен или URL.

<?php

namespace App\Services;

use App\Domain\Security\WebsiteAnalysis;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Throwable;
use UnexpectedValueException;

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

    public function __construct(private LoggerInterface $logger) {}

    public function analyze(string $url): WebsiteAnalysis
    {
        $token = config('services.website_analyzer.token');

        if (! is_string($token) || $token === '') {
            throw new RuntimeException('Website analyzer 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 RuntimeException('The analyzer target must be a valid HTTPS URL.');
        }

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = Http::withToken($token)
                    ->acceptJson()
                    ->asJson()
                    ->connectTimeout((int) config(
                        'services.website_analyzer.connect_timeout',
                        3
                    ))
                    ->timeout((int) config('services.website_analyzer.timeout', 15))
                    ->post(self::ENDPOINT, ['url' => $url]);
            } catch (ConnectionException $exception) {
                if ($attempt === 3) {
                    throw new RuntimeException(
                        'Could not connect to the website analyzer.',
                        previous: $exception
                    );
                }

                $this->pauseBeforeRetry($attempt, null);
                continue;
            }

            if ($response->successful()) {
                $payload = $response->json();

                if (! is_array($payload)) {
                    throw new RuntimeException('Analyzer returned malformed JSON.');
                }

                try {
                    return WebsiteAnalysis::fromArray($payload);
                } catch (UnexpectedValueException $exception) {
                    throw new RuntimeException(
                        'Analyzer returned an unexpected response contract.',
                        previous: $exception
                    );
                }
            }

            if (in_array($response->status(), [401, 403], true)) {
                throw new RuntimeException('Analyzer authentication was rejected.');
            }

            if ($response->status() === 422) {
                throw new RuntimeException('Analyzer rejected the target URL.');
            }

            $retryable = $response->status() === 429
                || in_array($response->status(), [500, 502, 503, 504], true);

            if (! $retryable || $attempt === 3) {
                throw new RuntimeException(
                    'Analyzer request failed with HTTP '.$response->status().'.'
                );
            }

            $retryAfter = $response->header('Retry-After');
            $this->pauseBeforeRetry(
                $attempt,
                is_string($retryAfter) && ctype_digit($retryAfter)
                    ? (int) $retryAfter
                    : null
            );
        }

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

    private function pauseBeforeRetry(int $attempt, ?int $retryAfterSeconds): void
    {
        $milliseconds = $retryAfterSeconds === null
            ? 250 * (2 ** ($attempt - 1))
            : $retryAfterSeconds * 1000;

        $milliseconds = min(2000, max(250, $milliseconds));

        $this->logger->warning('Retrying website security analysis.', [
            'attempt' => $attempt + 1,
            'delay_ms' => $milliseconds,
        ]);

        usleep($milliseconds * 1000);
    }
}

Ограничувањето на повратното чекање на две секунди го одржува времето на поставување предвидливо. Конечниот 429 останува неуспех наместо процесот за издание да чека неограничено. Ако избраниот план редовно ја достигнува својата квота, поретко закажување поставувања или промена на планот е поискрено отколку сокривање на состојбата зад агресивни повторни обиди.

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

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

<?php

namespace App\Console\Commands;

use App\Services\WebsiteAnalyzerClient;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Throwable;

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

    public function handle(WebsiteAnalyzerClient $client): int
    {
        $url = config('services.website_analyzer.url');

        if (! is_string($url) || $url === '') {
            $this->error('WEBSITE_ANALYZER_URL is not configured.');
            return self::FAILURE;
        }

        try {
            $analysis = $client->analyze($url);

            $report = [
                'scanned_at' => now()->toIso8601String(),
                'url' => $url,
                ...$analysis->toArray(),
            ];

            Storage::disk('local')->put(
                'security/last-website-scan.json',
                json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR)
            );

            Log::info('Production website security analysis completed.', [
                'url' => $url,
                'score' => $analysis->score,
                'finding_groups' => array_map('count', $analysis->findings),
            ]);

            $this->info('Website analysis completed. Score: '.$analysis->score);
            return self::SUCCESS;
        } catch (Throwable $exception) {
            report($exception);
            $this->error('Website analysis failed: '.$exception->getMessage());
            return self::FAILURE;
        }
    }
}

Тестирајте успех, зачувување и повторни обиди

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

<?php

namespace Tests\Feature;

use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;

final class AnalyzeProductionWebsiteTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();

        config()->set('services.website_analyzer.token', 'test-token');
        config()->set('services.website_analyzer.url', 'https://www.example.com');
        Storage::fake('local');
    }

    public function test_it_analyzes_and_stores_the_production_report(): void
    {
        Http::fake([
            '*' => Http::response([
                'score' => 88,
                'findings' => [
                    'high' => [],
                    'medium' => [['summary' => 'Example finding']],
                ],
                'tls' => ['enabled' => true],
                'recommendations' => ['Review the reported browser policy.'],
            ], 200),
        ]);

        $this->artisan('security:analyze-production')->assertExitCode(0);

        Storage::disk('local')
            ->assertExists('security/last-website-scan.json');

        Http::assertSent(function (Request $request): bool {
            return $request->method() === 'POST'
                && $request->url()
                    === 'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website'
                && $request['url'] === 'https://www.example.com'
                && $request->hasHeader('Authorization', 'Bearer test-token');
        });
    }

    public function test_it_retries_a_transient_server_failure(): void
    {
        Http::fakeSequence()
            ->push([], 503)
            ->push([
                'score' => 95,
                'findings' => ['high' => []],
                'tls' => ['enabled' => true],
                'recommendations' => [],
            ], 200);

        $this->artisan('security:analyze-production')->assertExitCode(0);

        Http::assertSentCount(2);
    }
}

Додајте дополнителни тестови за 401, 422, повторени одговори 429, исклучоци при конекција, неправилен JSON и недостигачки делови од одговорот. Тоа не се случајни рабни случаи: тие дефинираат дали неуспесите при поставувањето се разбирливи во моментот кога некој мора да реагира на нив.

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

Извршете тестови и кеширајте ја конфигурацијата пред да го префрлите изданието. Откако новата верзија е активна, почекајте ја нејзината јавна здравствена проверка и извршете ја командата:

php artisan test
php artisan config:cache

# Activate the release and complete the public health check here.

php artisan security:analyze-production

Задржете го ненултиот излезен статус на командата видлив за системот за поставување. Дали треба да врати издание назад е деловна одлука. Недостапноста на API обично оправдува предупредување и рачна проверка, додека сериозен нов наод може да оправда блокирање на промоцијата. Не извлекувајте ниту една политика од недокументиран праг за резултат.

Безбедност, набљудливост и вообичаени неуспеси

  • Заштитете го акредитивот: чувајте го во менаџер за тајни или заштитена поставка на околината, ограничете го пристапот на операторите за поставување и ротирајте го намерно.
  • Контролирајте ја целта: прифаќајте ја URL-адресата само од доверлива конфигурација за поставување. Не го изложувајте овој клиент преку јавен контролер што прифаќа произволни URL-адреси.
  • Зачувајте корисни докази: задржете го генерираниот извештај како заштитен артефакт од поставувањето ако локалното складирање на изданието е краткотрајно.
  • Предупредувајте за структурирани исходи: следете ги излезните кодови на командите, категориите HTTP статус, бројот на повторни обиди, латентноста и промените на резултатот без да бележите заглавија за авторизација или цели тела на одговорите.
  • Толкувајте го опсегот правилно: резултатот ја опишува ограничената, неинвазивна јавна HTTPS и безбедносна состојба на прелистувачот. Тоа не е доказ дека апликацијата нема ранливости.

401 или 403 обично значи дека токенот недостасува, е неправилно форматиран, е поништен или припаѓа на погрешна активација на услугата. 422 укажува на испратената URL-адреса, а не на сигурноста на транспортот. Повторените одговори 429 укажуваат на притисок од квота или ограничување на стапката. TLS наодите што изгледа дека се спротивни на конфигурацијата на серверот често потекнуваат од јавниот CDN, балансирачот на оптоварување или обратниот прокси — слојот до кој прелистувачот навистина стигнува.

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

  • Free, Plus или Pro планот е активен и токенот ограничен на услугата е тековен.
  • Продукциското складиште за тајни го содржи токенот без да го изложува во изворната контрола.
  • Конфигурираната цел е канонската јавна HTTPS URL-адреса.
  • Барањето користи POST, Bearer автентикација и JSON url.
  • Истекувањата на времето, ограничените повторни обиди и неповторливите неуспеси при автентикација и валидација се однесуваат како што е предвидено.
  • Тестовите со Http::fake() поминуваат без надворешен мрежен пристап.
  • Командата се извршува по активирањето на изданието и јавната здравствена проверка.
  • Извештајот се чува некаде трајно и со контролиран пристап.
  • Операторите за поставување можат да ги видат неуспесите и знаат дали да предупредат, истражат или вратат назад.

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

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

Mihajlo

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