Vodiči

Laravel Client Dashboards: Integrate Website Security Analyzer for Actionable Client Insights

Laravel korisničke nadzorne ploče: Integrirajte analizator sigurnosti web-mjesta za korisne uvide za klijente

Sigurnosno izvješće postaje korisno tek kada netko može vidjeti što se promijenilo, razumjeti što je važno i preporuke pretvoriti u dovršeni posao. Za malu web agenciju to znači više od smještanja API odgovora u lijepu karticu. Nadzorna ploča treba trajnu povijest, izvršavanje u pozadini, eksplicitna stanja neuspjeha i zadatke sanacije koji preživljavaju sljedeće skeniranje.

Ovaj vodič izrađuje taj tijek rada u Laravelu i PHP-u 8.3+. Svaki klijent ima HTTPS web-mjesto, kronološku povijest skeniranja, nalaze grupirane prema ozbiljnosti, TLS pojedinosti i provedive preporuke. Website Security Analyzer provodi ograničenu, neinvazivnu analizu javnog HTTPS-a i sigurnosnog stanja preglednika. Trebao bi podržavati rutinsku higijenu i određivanje prioriteta, ali nikada se ne smije opisivati kao penetracijski test.

Pribavite pristup prije pisanja integracijskog koda

  1. Registrirajte se na https://ai.mihajlo.mk/register ili upotrijebite https://ai.mihajlo.mk/login ako već imate račun.
  2. Otvorite stranicu usluge Website Security Analyzer. Odaberite dostupni Free, Plus ili Pro plan i dovršite njegovu aktivaciju.
  3. Otvorite službenu dokumentaciju usluge. Pronađite ploču Service token i kopirajte token ograničen na uslugu.
  4. Spremite taj token u okruženje aplikacije. Njegovo ponovno generiranje opoziva prethodno aktivni token, stoga uskladite rotaciju s implementacijom umjesto da ga nepromišljeno ponovno generirate.

API prihvaća Bearer token, zaglavlje X-API-Token ili parametar upita token. Ovaj projekt upotrebljava Bearer token jer se tako autentikacija ne nalazi u URL-ovima, zapisima pristupa i povijesti preglednika.

Točan zahtjev je POST https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website, s JSON tijelom koje sadrži url. Provjerite pristup minimalnim zahtjevom:

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

Sada postavite vjerodajnicu u .env, nikada u PHP izvorni kod, fixtureove, snimke zaslona ili zapise:

WEBSITE_SECURITY_ANALYZER_TOKEN=YOUR_SERVICE_TOKEN
QUEUE_CONNECTION=database

Dodajte konfiguraciju koja se temelji na okruženju u config/services.php:

'website_security_analyzer' => [
    'endpoint' => 'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website',
    'token' => env('WEBSITE_SECURITY_ANALYZER_TOKEN'),
],

Odaberite arhitekturu koja čuva povijest

Zahtjev ne bi trebao čekati završetak vanjske analize. Kontroler stvara skeniranje na čekanju, a zatim šalje posao u red. Posao poziva namjenski API klijent, provjerava odgovor na granici aplikacije, sprema nepromjenjivu snimku i iz preporuka stvara otvorene zadatke sanacije.

Takav dizajn zahtijeva nekoliko tablica baze podataka i radnika reda, ali pruža brže odgovore pregledniku, kontrolirane ponovne pokušaje i revizijski trag. Također odvaja transportne brige od kontrolera i prikaza.

Preduvjeti su PHP 8.3+, Composer, podržana Laravel baza podataka i pozadinski sustav reda. Počnite s uobičajenom Laravel aplikacijom i generirajte glavne klase:

composer create-project laravel/laravel agency-security-dashboard
cd agency-security-dashboard
php artisan make:model Client -m
php artisan make:model SecurityScan -m
php artisan make:model RemediationTask -m
php artisan make:controller ClientSecurityController
php artisan make:job AnalyzeClientWebsite
php artisan make:policy ClientPolicy --model=Client
php artisan make:test WebsiteSecurityAnalyzerTest --unit

Relevantna struktura namjerno je mala:

app/
  Data/WebsiteAnalysis.php
  Exceptions/AnalyzerFailure.php
  Http/Controllers/ClientSecurityController.php
  Jobs/AnalyzeClientWebsite.php
  Models/Client.php
  Models/SecurityScan.php
  Models/RemediationTask.php
  Services/WebsiteSecurityAnalyzer.php
resources/views/clients/security.blade.php
routes/web.php
tests/Unit/WebsiteSecurityAnalyzerTest.php

Spremite snimke i zadatke odvojeno

Skeniranje je dokaz zabilježen u određenom trenutku. Zadatak sanacije je promjenjiv posao. Njihovo odvajanje omogućuje agenciji da zatvori zadatak bez prepisivanja povijesnog odgovora koji ga je stvorio.

Schema::create('clients', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->string('name');
    $table->string('website_url', 2048);
    $table->timestamps();
});

Schema::create('security_scans', function (Blueprint $table) {
    $table->id();
    $table->foreignId('client_id')->constrained()->cascadeOnDelete();
    $table->string('status')->index(); // pending, running, completed, failed
    $table->double('score')->nullable();
    $table->json('findings')->nullable();
    $table->json('tls_details')->nullable();
    $table->json('recommendations')->nullable();
    $table->string('error_code')->nullable();
    $table->timestamp('completed_at')->nullable();
    $table->timestamps();
});

Schema::create('remediation_tasks', function (Blueprint $table) {
    $table->id();
    $table->foreignId('client_id')->constrained()->cascadeOnDelete();
    $table->foreignId('security_scan_id')->constrained()->cascadeOnDelete();
    $table->text('description');
    $table->timestamp('completed_at')->nullable();
    $table->timestamps();
});

Konfigurirajte modele odgovarajućim odnosima hasMany i belongsTo. Pretvorite JSON stupce u array, a vremenske oznake u datetime. Ili deklarirajte dodijeljena polja u $fillable ili dosljedno upotrebljavajte eksplicitno dodjeljivanje svojstava.

Provjerite API odgovor na jednoj granici

Isporučeni ugovor izlaže rezultat, nalaze grupirane prema ozbiljnosti, TLS pojedinosti i preporuke. Prilagodnik ne bi smio dopustiti da neočekivana HTML stranica s pogreškom ili promijenjeni JSON oblik prodru u domenu.

<?php

namespace App\Data;

use App\Exceptions\AnalyzerFailure;

final readonly class WebsiteAnalysis
{
    public function __construct(
        public int|float $score,
        public array $findings,
        public array $tlsDetails,
        public array $recommendations,
    ) {}

    public static function fromPayload(mixed $payload): self
    {
        if (! is_array($payload)
            || ! is_int($payload['score'] ?? null) && ! is_float($payload['score'] ?? null)
            || ! is_array($payload['findings'] ?? null)
            || ! is_array($payload['tls'] ?? null)
            || ! is_array($payload['recommendations'] ?? null)
        ) {
            throw new AnalyzerFailure('schema', 'Unexpected analyzer response.');
        }

        foreach ($payload['findings'] as $group => $items) {
            if (! is_string($group) || ! is_array($items)) {
                throw new AnalyzerFailure('schema', 'Invalid findings grouping.');
            }
        }

        $recommendations = array_values(array_filter(
            $payload['recommendations'],
            static fn (mixed $value): bool => is_string($value) && trim($value) !== ''
        ));

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

Ako službena dokumentacija vraća ta polja unutar dokumentirane omotnice, raspakirajte tu omotnicu ovdje i nigdje drugdje. Nemojte raspršivati spekulativne zamjenske opcije po poslovima i prikazima.

Upotrijebite ograničena vremenska ograničenja i klasificirane neuspjehe

<?php

namespace App\Services;

use App\Data\WebsiteAnalysis;
use App\Exceptions\AnalyzerFailure;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;

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

        if (! is_string($token) || $token === '') {
            throw new AnalyzerFailure('configuration', 'Analyzer token is missing.');
        }

        try {
            $response = Http::acceptJson()
                ->asJson()
                ->withToken($token)
                ->connectTimeout(5)
                ->timeout(30)
                ->post(
                    config('services.website_security_analyzer.endpoint'),
                    ['url' => $url],
                );
        } catch (ConnectionException $exception) {
            throw new AnalyzerFailure('temporary', 'Analyzer connection failed.', previous: $exception);
        }

        if ($response->successful()) {
            return WebsiteAnalysis::fromPayload($response->json());
        }

        $status = $response->status();

        if (in_array($status, [401, 403], true)) {
            throw new AnalyzerFailure('authentication', 'Analyzer authentication failed.');
        }

        if ($status === 429) {
            $header = $response->header('Retry-After');
            $delay = ctype_digit((string) $header)
                ? max(30, min(900, (int) $header))
                : 120;

            throw new AnalyzerFailure('rate_limit', 'Analyzer rate limit reached.', $delay);
        }

        if ($status === 422) {
            throw new AnalyzerFailure('validation', 'Analyzer rejected the website URL.');
        }

        if ($status >= 500) {
            throw new AnalyzerFailure('temporary', 'Analyzer is temporarily unavailable.');
        }

        throw new AnalyzerFailure('upstream', "Unexpected analyzer status: {$status}.");
    }
}

AnalyzerFailure je mala podklasa RuntimeException koja izlaže javna readonly svojstva kind i nullable retryAfter. Njezin konstruktor trebao bi prihvatiti PHP-ov imenovani argument previous kako je prikazano.

Pokrenite skeniranja u redu

Samo neuspjesi veze, neuspjesi poslužitelja i ograničenja brzine zaslužuju novi pokušaj. Neuspjesi provjere valjanosti i autentikacije zahtijevaju ljudsku intervenciju, stoga njihovo ponovno pokušavanje samo troši kvotu i skriva stvarni problem.

final class AnalyzeClientWebsite implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 4;

    public function __construct(public SecurityScan $scan) {}

    public function backoff(): array
    {
        return [30, 120, 300];
    }

    public function handle(WebsiteSecurityAnalyzer $analyzer): void
    {
        $this->scan->update(['status' => 'running']);

        try {
            $result = $analyzer->analyze($this->scan->client->website_url);
        } catch (AnalyzerFailure $failure) {
            Log::warning('Website analysis failed', [
                'scan_id' => $this->scan->id,
                'client_id' => $this->scan->client_id,
                'kind' => $failure->kind,
                'attempt' => $this->attempts(),
            ]);

            if ($failure->kind === 'rate_limit' && $this->attempts() < $this->tries) {
                $this->release($failure->retryAfter ?? 120);
                return;
            }

            if ($failure->kind === 'temporary') {
                throw $failure;
            }

            $this->scan->update([
                'status' => 'failed',
                'error_code' => $failure->kind,
                'completed_at' => now(),
            ]);

            return;
        }

        DB::transaction(function () use ($result): void {
            $this->scan->update([
                'status' => 'completed',
                'score' => $result->score,
                'findings' => $result->findings,
                'tls_details' => $result->tlsDetails,
                'recommendations' => $result->recommendations,
                'completed_at' => now(),
            ]);

            foreach ($result->recommendations as $recommendation) {
                $this->scan->remediationTasks()->create([
                    'client_id' => $this->scan->client_id,
                    'description' => $recommendation,
                ]);
            }
        });
    }

    public function failed(?Throwable $failure): void
    {
        $this->scan->update([
            'status' => 'failed',
            'error_code' => 'retry_exhausted',
            'completed_at' => now(),
        ]);
    }
}

Akcija kontrolera trebala bi autorizirati pristup, zaključati red klijenta u transakciji, odbiti drugo skeniranje na čekanju ili u tijeku, stvoriti zapis na čekanju i poslati posao nakon potvrde transakcije:

public function store(Client $client): RedirectResponse
{
    Gate::authorize('update', $client);

    DB::transaction(function () use ($client): void {
        $locked = Client::query()->lockForUpdate()->findOrFail($client->id);

        abort_if(
            $locked->securityScans()
                ->whereIn('status', ['pending', 'running'])
                ->exists(),
            409,
            'A scan is already in progress.'
        );

        $scan = $locked->securityScans()->create(['status' => 'pending']);
        AnalyzeClientWebsite::dispatch($scan)->afterCommit();
    });

    return back()->with('status', 'Security analysis queued.');
}

Definirajte rute za nadzornu ploču, stvaranje skeniranja i dovršavanje zadataka. Pravilo mora osigurati da autentikirani korisnik posjeduje klijenta; povezivanje modela ruta samo po sebi nije autorizacija.

Route::middleware('auth')->group(function (): void {
    Route::get('/clients/{client}/security', [ClientSecurityController::class, 'show'])
        ->name('clients.security.show');

    Route::post('/clients/{client}/security/scans', [ClientSecurityController::class, 'store'])
        ->name('clients.security.scans.store');

    Route::patch('/clients/{client}/tasks/{task}', [ClientSecurityController::class, 'complete'])
        ->scopeBindings()
        ->name('clients.security.tasks.complete');
});

Blade prikaz može ostati jednostavan: prikažite najnoviji rezultat i vrijeme dovršetka, iterirajte kroz findings po grupi ozbiljnosti, prikažite TLS pojedinosti kao označene vrijednosti, navedite otvorene zadatke sanacije s obrascima za dovršavanje zaštićenima CSRF-om i postavite ranija skeniranja ispod trenutačne snimke. Escapeajte uobičajene vrijednosti Bladeovim {{ }}; nemojte prikazivati API tekst putem {!! !!}.

Testirajte granicu bez pozivanja produkcije

Laravelov HTTP fake čini transportne testove determinističkima i dokazuje da su tajne i tijela zahtjeva ispravno sastavljeni.

public function test_it_maps_a_successful_analysis(): void
{
    config()->set('services.website_security_analyzer.token', 'test-token');
    config()->set(
        'services.website_security_analyzer.endpoint',
        'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website'
    );

    Http::fake([
        'https://ai.mihajlo.mk/*' => Http::response([
            'score' => 82,
            'findings' => ['high' => [], 'medium' => []],
            'tls' => [],
            'recommendations' => ['Review the site security configuration.'],
        ], 200),
    ]);

    $result = app(WebsiteSecurityAnalyzer::class)
        ->analyze('https://client.example');

    $this->assertSame(82, $result->score);
    $this->assertCount(1, $result->recommendations);

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

public function test_authentication_failure_is_not_retried(): void
{
    config()->set('services.website_security_analyzer.token', 'invalid');
    Http::fake(['https://ai.mihajlo.mk/*' => Http::response([], 401)]);

    try {
        app(WebsiteSecurityAnalyzer::class)->analyze('https://client.example');
        $this->fail('Expected an authentication failure.');
    } catch (AnalyzerFailure $failure) {
        $this->assertSame('authentication', $failure->kind);
    }

    Http::assertSentCount(1);
}

Dodajte testove poslova s Queue::fake() za slanje i Http::fake() za dovršavanje, ograničavanje brzine, neispravan JSON i iscrpljene privremene neuspjehe. Provjeravajte stanje baze podataka, a ne interne pozive metoda.

Zaštitne mjere u produkciji i implementacija

  • Prihvaćajte samo normalizirane javne https:// URL-ove klijenata. Odbijte vjerodajnice u URL-ovima, nazive localhosta i privatna ili rezervirana IP odredišta.
  • Šifrirajte tajne okruženja putem platforme za implementaciju i ograničite tko ih može vidjeti. Tijekom rotacije implementirajte ponovno generirani token svugdje gdje se red koristi prije nego što stari radnici nastave obradu.
  • Nikada ne zapisujte token, autorizacijsko zaglavlje, potpuni odgovor ni URL klijenta. Stabilni ID-ovi skeniranja, ID-ovi klijenata, kategorije neuspjeha, pokušaji, trajanje i kategorije HTTP statusa pružaju korisnu vidljivost.
  • Postavite upozorenja za ponovljene neuspjehe autentikacije, neuspjehe sheme, iscrpljene pokušaje i rastući red. Neuspjeh sheme alarm je integracije, a ne prazno uspješno izvješće.
  • Jasno označite nadzornu ploču kao ograničenu, neinvazivnu analizu sigurnosti web-mjesta. Izbjegavajte jezik koji implicira provjeru iskorištavanja ranjivosti, pokrivenost interne mreže ili penetracijsko testiranje.

Implementirajte migracije baze podataka, ponovno izgradite konfiguraciju i ponovno pokrenite radnike kako bi učitali novi token i kod:

php artisan migrate --force
php artisan config:cache
php artisan queue:restart
php artisan queue:work --tries=4 --timeout=90

Pokrenite radnika pod nadzorom procesa u produkciji. Njegovo vremensko ograničenje procesa mora ostati znatno dulje od vremenskog ograničenja HTTP odgovora. Uobičajeni neuspjesi najčešće su izravni: 401 ili 403 označava problem s konfiguracijom ili rotacijom tokena; 422 označava neprihvatljiv URL; 429 zahtijeva odgođeni ponovni pokušaj ili pregled plana; ponovljeni 5xx ili neuspjesi veze označavaju privremeni problem uzvodne usluge; a neuspjeh sheme znači da se prilagodnik mora usporediti s trenutačnom službenom dokumentacijom.

Završni kontrolni popis za provjeru

  • Klijent može staviti jedno skeniranje u red bez zadržavanja otvorenog zahtjeva preglednika.
  • Istodobni klikovi ne mogu stvoriti više aktivnih skeniranja za istog klijenta.
  • Uspješni rezultati zadržavaju rezultat, grupirane nalaze, TLS pojedinosti, preporuke i vrijeme dovršetka.
  • Preporuke postaju zadaci koji se mogu zatvoriti, dok izvorno skeniranje ostaje nepromjenjivo.
  • Autorizacija sprječava jednog korisnika agencije da pregledava ili ažurira klijente drugog korisnika.
  • Neuspjesi autentikacije i provjere valjanosti odmah se zaustavljaju; prolazni neuspjesi koriste ograničeni backoff.
  • Testovi ne upućuju mrežne pozive i ne sadrže stvarni servisni token.
  • Nadzorna ploča točno opisuje rezultat i nikada ga ne naziva penetracijskim testom.

Trajna vrijednost nije gumb za skeniranje. To je ciklus oko njega: zabilježiti ograničenu procjenu, sačuvati ono što je opaženo, preporuke pretvoriti u dodijeljeni posao i kasnije se vratiti kako bi se izmjerilo što se promijenilo. Time se sigurnosni API pretvara iz jednokratnog izvješća u mirnu, ponovljivu uslugu za klijente.

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.