Vodiči

Laravel Deployment Guardian: Automate Security Checks Post-Push

Laravel Deployment Guardian: Automatizirajte sigurnosne provjere nakon slanja promjena

Implementacija može uspjeti, a da pritom tiho oslabi web-stranicu. Promjena proxyja ukloni sigurnosno zaglavlje, lanac certifikata bude pogrešno konfiguriran ili nova politika odgovora više ne štiti preglednike kako je zamišljeno. Jedinični testovi rijetko otkrivaju te probleme jer pregledavaju aplikaciju prije nego što javni put isporuke završi s njezinom transformacijom.

Ovaj vodič dodaje čuvara nakon implementacije Laravel aplikaciji. Nakon što svako produkcijsko izdanje postane dostupno, Artisan naredba traži od Website Security Analyzera da ispita javnu HTTPS krajnju točku. Integracija preslikava vraćeni rezultat, nalaze grupirane prema ozbiljnosti, TLS pojedinosti i preporuke u strogi objekt domene, a zatim zapisuje sažet rezultat za operatere.

Analiza je ograničena i neinvazivna. Procjenjuje javni HTTPS i sigurnosni položaj preglednika; nije penetracijski test i nikada je se ne smije tako opisivati.

Osigurajte pristup prije pisanja integracijskog koda

Najprije registrirajte račun ili se prijavite ako ga već imate.

Otvorite stranicu usluge Website Security Analyzer. Odaberite dostupni Free, Plus ili Pro plan i dovršite njegovu aktivaciju. Odabir plana pripada ovdje, a ne u kod aplikacije, jer se kvote i komercijalni uvjeti mogu mijenjati neovisno o implementaciji.

Zatim otvorite službenu dokumentaciju usluge. Pronađite ploču Service token i kopirajte token ograničen na uslugu. Ponovno generiranje tog tokena opoziva prethodno aktivni token, stoga uskladite rotaciju s odgovarajućim ažuriranjem produkcijske konfiguracije.

Ova usluga zahtijeva autentikaciju; u ovoj integraciji nema neautentificiranog poziva. Prihvaća Bearer token, zaglavlje X-API-Token ili parametar tokena u upitu. Upotrijebit ćemo oblik Bearer jer vjerodajnicu zadržava izvan URL-ova, koji se često čuvaju u zapisnicima pristupa i sustavima nadzora.

Točna operacija je POST https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website. Prije izrade Laravel funkcionalnosti potvrdite pristup minimalnim zahtjevom prema javnom HTTPS URL-u koji kontrolirate:

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"}'

Nemojte predati token u repozitorij niti ostaviti stvarnu vrijednost u shell skriptama. Smjestite ga u šifrirano spremište tajni produkcijske platforme i izložite Laravelu kroz konfiguraciju podržanu varijablama okruženja:

# .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'),
    ],
];

Nakon promjene produkcijskih tajni ponovno izgradite Laravelovu predmemoriju konfiguracije. Kod aplikacije mora čitati config(), a ne izravno pozivati env() nakon što je konfiguracija predmemorirana.

Odaberite namjerno malu arhitekturu

Implementacija zahtijeva PHP 8.3 ili noviji, Laravel aplikaciju, odlazni HTTPS pristup analizatoru i implementirani javni HTTPS URL. Koristi Laravelov ugrađeni HTTP klijent, pa nije potreban dodatni HTTP paket.

Pokretni dijelovi namjerno su ograničeni:

  • Objekt domene validira i prenosi odgovor analizatora.
  • Klasa usluge upravlja autentikacijom, vremenskim ograničenjima, ponovnim pokušajima i klasifikacijom pogrešaka.
  • Artisan naredba pokreće se iz cjevovoda implementacije i odlučuje treba li konfigurirana ozbiljnost prekinuti korak.
  • HTTP lažnjaci čine integraciju determinističkom u automatiziranim testovima.

Posao u redu čekanja dodao bi odgodu i zahtijevao zdrav radnik upravo dok izdanje mijenja infrastrukturu. Ovdje proces implementacije treba trenutan, ograničen odgovor, pa je sinkrona naredba jasniji kompromis. Ako provjera kasnije postane informativna umjesto signala za izdanje, slanje ekvivalentnog posla u red čekanja može biti razumno.

Relevantna struktura projekta je:

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

Validirajte API odgovor na granici

Udaljeni JSON je nepouzdan ulaz čak i kada dolazi od usluge koju ste odabrali. Preslikavač odgovora prihvaća samo navedeni ugovor: numerički score, objekt findings grupiranih prema ozbiljnosti, objekt TLS pojedinosti tls i popis tekstualnih recommendations. Namjerno izbjegava nagađanje nedokumentiranih ugniježđenih polja.

<?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,
        );
    }
}

Strukturirani neuspjesi omogućuju naredbi da razlikuje pogrešne vjerodajnice od prolaznih infrastrukturnih problema bez raščlanjivanja poruka iznimki:

<?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);
    }
}

Izradite Laravel HTTP klijent svjestan ponovnih pokušaja

Klijent dopušta tri pokušaja, uz ograničeni eksponencijalni povratni odmak. Ponovno pokušava kod neuspjeha povezivanja, HTTP 429 odgovora i pogrešaka poslužitelja. Ne pokušava naslijepo ponovno autentikacijske ili validacijske neuspjehe: drugi identični zahtjev neće popraviti nevažeći token ili tijelo.

Numerička vrijednost Retry-After poštuje se kod ograničenja brzine, ali je ograničena na pet sekundi kako se implementacija ne bi mogla neograničeno zaustaviti. Vremenska ograničenja povezivanja i ukupnog odgovora pružaju dodatnu čvrstu granicu.

<?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().'.',
        );
    }
}

Izložite provjeru kao naredbu za implementaciju

Naredba ne prihvaća proizvoljan URL. Analizira samo vrijednost okruženja koju kontrolira operater, sprječavajući CLI pozivatelja da integraciju pretvori u opći alat za dohvaćanje URL-ova. Također odbija rad izvan produkcije, čime štiti kvote lokalnih testova od slučajne upotrebe.

<?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;
    }
}

Laravelovo uobičajeno otkrivanje naredbi čini klasu pod app/Console/Commands dostupnom Artisanu. Potvrdite je pomoću php artisan list prije izmjene produkcijskog cjevovoda.

Testirajte preslikavanje, autentikaciju i ponašanje pri neuspjehu

Http::fake() sprječava da testovi troše kvotu ili ovise o mreži. Prvi test provjerava točnu krajnju točku, Bearer zaglavlje, tijelo zahtjeva i preslikavanje domene. Drugi dokazuje da se autentikacijski neuspjeh ne pokušava ponovno.

<?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);
    }
}

Pokrenite ciljani test pomoću php artisan test --filter=WebsiteSecurityAnalyzerTest. U testovima zadržite lažne tokene; paketu testova nikada ne smije trebati produkcijska tajna.

Povežite ga s produkcijskom implementacijom

Pozovite analizator tek nakon što je izdanje aktivno i njegova krajnja točka za provjeru stanja uspije. Taj redoslijed mjeri javni put isporuke, a ne neobjavljeni direktorij. Tipičan završetak implementacije izgleda ovako:

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

Nenulti izlazni status naredbe omogućuje sustavu implementacije da prikaže prekide analizatora, pogreške konfiguracije ili nalaze pri konfiguriranoj blokirajućoj ozbiljnosti. Budući da se provjera događa nakon prebacivanja prometa, nemojte pretpostaviti da neuspjela naredba automatski vraća izdanje. Vraćanje učinite izričitom politikom platforme za implementaciju i razmislite o početku s ponašanjem samo za upozorenja tako da SECURITY_ANALYZER_FAIL_ON ostavite praznim dok tim ne razumije svoju početnu vrijednost.

Zapisnici namjerno uključuju naziv hosta, rezultat, brojanja i kategoriju neuspjeha, ali ne token, tijelo odgovora, TLS teret ni sadržaj nalaza. Pošaljite te strukturirane događaje na postojeće odredište zapisnika projekta i upozoravajte na ponovljene neuspjehe ili značajnu promjenu rezultata. Izbjegavajte zapisivanje zaglavlja zahtjeva pri uključivanju HTTP dijagnostike.

Uobičajeni neuspjesi koje vrijedi planirati

  • 401 ili 403: provjerite aktivaciju plana i token ograničen na uslugu. Ako ga je netko ponovno generirao, stari token je opozvan i svako okruženje koje ga koristi mora se ažurirati.
  • 400 ili 422: potvrdite da je SECURITY_ANALYZER_URL potpun, javno dostupan HTTPS URL. Klijent ne pokušava ponovno te odgovore.
  • 429: ograničeni ponovni pokušaj može se oporaviti od kratkog ograničenja, ali ponovljeni odgovori zahtijevaju smanjenu učestalost implementacije, pregled kvote ili drukčiji plan — ne beskonačnu petlju ponovnih pokušaja.
  • Neuspjeh mreže ili vremensko ograničenje: provjerite odlazni vatrozid i DNS pristup. Zadržite vremensko ograničenje umjesto dopuštanja da implementacije ostanu visjeti.
  • Neispravno oblikovan odgovor: zadržite kategoriju neuspjeha i HTTP status u telemetriji, ali nemojte oslabiti preslikavač da prihvaća proizvoljne oblike. Prije promjene granice pregledajte službenu dokumentaciju.
  • Neočekivano blokiranje: ključevi ozbiljnosti preuzimaju se iz odgovora. Točno uskladite SECURITY_ANALYZER_FAIL_ON s ozbiljnošću koju vaša politika namjerava blokirati.

Završni popis za provjeru

  1. Račun i Free, Plus ili Pro plan su aktivirani.
  2. Token ograničen na uslugu nalazi se samo u produkcijskom spremištu tajni.
  3. Cilj je javni HTTPS URL koji projekt kontrolira.
  4. php artisan config:cache pokreće se nakon promjena okruženja.
  5. HTTP lažni testovi prolaze bez vanjskih zahtjeva.
  6. Produkcijska provjera stanja uspijeva prije početka analize.
  7. Implementacija poziva php artisan security:analyze-production točno jednom.
  8. Zapisnici prikazuju rezultat i brojanja ozbiljnosti bez vjerodajnica ili sirovih nalaza.
  9. Ograničenja brzine, udaljeni neuspjesi i politika blokirajuće ozbiljnosti stvaraju vidljive ishode cjevovoda.

Produkcijsko izdanje nije završeno kada datoteke stignu na poslužitelj; završeno je kada se javna stranica ponaša kako je zamišljeno. Postavljanjem ograničene provjere sigurnosnog položaja odmah nakon implementacije Laravel dobiva praktičnu povratnu petlju na mjestu gdje se konfiguracija, TLS, proxyji i odgovori aplikacije naposljetku susreću. Ne zamjenjuje dublje sigurnosno testiranje, ali znatno otežava neprimjetno slanje važne klase javnih regresija.

Portret autora bloga

Mihajlo

Ja sam Mihajlo — programer vođen znatiželjom, disciplinom i stalnom željom da stvorim nešto smisleno. Dijelim uvide, tutorijale i besplatne usluge kako bih pomogao drugima da pojednostave svoj rad i rastu u svijetu softvera i umjetne inteligencije koji se neprestano razvija.