Vodiči

Laravel: Automate Weekly Website Security Scans and Alert Owners

Laravel: Automatizirajte tjedna sigurnosna skeniranja web-mjesta i obavijestite vlasnike

Web-stranica malog poduzeća može neprimjetno prijeći u slabije sigurnosno stanje, a da nitko ne uvede očito opasnu promjenu. Promijeni se konfiguracija certifikata, proxy prestane slati važno zaglavlje pregledniku ili migracija hostinga tiho izmijeni ponašanje HTTPS-a. Koristan odgovor nije još jedna nadzorna ploča koju se netko mora sjetiti otvoriti. To je tjedna provjera s trajnom osnovnom vrijednošću i e-pošta samo kada rezultat padne.

Ovaj vodič izrađuje taj tijek rada u Laravelu na PHP-u 8.3. Planirana Artisan naredba poziva Website Security Analyzer, provjerava njegov odgovor na granici aplikacije, uspoređuje rezultat s prethodnim uspješnim rezultatom i šalje e-poštu vlasniku web-mjesta kada je novi rezultat niži.

Analizator provodi ograničenu, neinvazivnu analizu javnog HTTPS-a i sigurnosnog stanja preglednika. Njegov je izlaz koristan za praćenje i određivanje prioriteta, ali ne smije se opisivati kao penetracijski test niti tretirati kao njegova zamjena.

Dobijte pristup prije pisanja integracijskog koda

  1. Izradite račun na https://ai.mihajlo.mk/register ili upotrijebite https://ai.mihajlo.mk/login ako ga već imate.
  2. Otvorite stranicu usluge Website Security Analyzer.
  3. Odaberite dostupni plan Free, Plus ili Pro i dovršite njegovu aktivaciju.
  4. Otvorite službenu dokumentaciju usluge.
  5. Pronađite ploču Service token i kopirajte ondje prikazani token ograničen na uslugu.

Ova usluga nije bez tokena: svaki zahtjev zahtijeva autentikaciju pomoću Bearer tokena, zaglavlja X-API-Token ili parametra upita token. Upotrijebit ćemo standardni oblik Bearer. Ponovno generiranje tokena usluge opoziva prethodno aktivni token, stoga rotaciju uskladite s implementacijom umjesto da ga nepromišljeno ponovno generirate.

Potvrdite točan API poziv

Integracija šalje POST zahtjeve na https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website. Tijelo JSON-a sadrži jednu obaveznu vrijednost, url.

curl --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://www.example-business.test"}'

Upotrijebite javni HTTPS URL koji posjedujete ili za čije ste praćenje ovlašteni. Nikada ne stavljajte stvarni token u povijest ljuske dijeljenu s drugim korisnicima, dokumentaciju, snimke zaslona, testne podatke ili kontrolu izvornog koda.

Smjestite vjerodajnice u konfiguraciju podržanu okruženjem

Dodajte vrijednosti specifične za implementaciju u .env. U implementiranoj aplikaciji upotrijebite stvarnu javnu domenu.

SECURITY_ANALYZER_TOKEN=YOUR_SERVICE_TOKEN
SECURITY_MONITOR_URL=https://www.example-business.test
[email protected]

Dodajte API postavke u config/services.php:

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

Izradite config/security-monitor.php za nadzirano web-mjesto:

<?php

return [
    'url' => env('SECURITY_MONITOR_URL'),
    'owner_email' => env('SECURITY_MONITOR_OWNER'),
];

Konfiguracijske datoteke mogu se predati u kontrolu verzija jer sadrže samo dohvaćanja iz okruženja. Popunjena datoteka .env mora ostati izvan kontrole verzija.

Odaberite namjerno malu arhitekturu

Aplikacija treba četiri komponente: API klijent, objekt odgovora domene, zapis baze podataka koji sadrži posljednji uspješni rezultat i planiranu naredbu. Naredba šalje poštu sinkrono jer ovaj projekt skenira jedno web-mjesto jednom tjedno. Uvođenje reda čekanja dodalo bi radnike, umnožavanje ponovnih pokušaja i više stanja implementacije bez poboljšanja ovog skromnog opterećenja.

Relevantna struktura projekta je:

  • app/Services/SecurityAnalyzer.php
  • app/Data/SecurityReport.php
  • app/Exceptions/AnalyzerException.php
  • app/Models/WebsiteMonitor.php
  • app/Console/Commands/ScanWebsiteSecurity.php
  • app/Mail/SecurityScoreDropped.php
  • resources/views/mail/security-score-dropped.blade.php
  • routes/console.php

Generirajte dijelove u vlasništvu okvira, a zatim ih uredite kako je prikazano u nastavku.

php artisan make:model WebsiteMonitor -m
php artisan make:command ScanWebsiteSecurity
php artisan make:mail SecurityScoreDropped --markdown=mail.security-score-dropped

Trajno pohranite posljednji uspješni rezultat

Osnovna vrijednost pripada bazi podataka, a ne varijabli lokalnoj za proces ili kratkotrajnoj predmemoriji. Upotrijebite stabilan ključ kako promjena URL-a slučajno ne bi stvorila drugi nadzor.

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('website_monitors', function (Blueprint $table): void {
            $table->id();
            $table->string('key', 100)->unique();
            $table->text('url');
            $table->string('owner_email');
            $table->decimal('last_score', 8, 2)->nullable();
            $table->timestamp('last_checked_at')->nullable();
            $table->string('last_status', 30)->nullable();
            $table->string('last_error', 100)->nullable();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('website_monitors');
    }
};

Dopustite ta polja u WebsiteMonitor i pretvorite pohranjene vrijednosti:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

final class WebsiteMonitor extends Model
{
    protected $fillable = [
        'key', 'url', 'owner_email', 'last_score',
        'last_checked_at', 'last_status', 'last_error',
    ];

    protected function casts(): array
    {
        return [
            'last_score' => 'float',
            'last_checked_at' => 'immutable_datetime',
        ];
    }
}

Provjerite odgovor analizatora na granici

Udaljeni JSON je nepouzdan ulaz čak i kada dolazi od usluge koju ste odabrali. Ugovor pruža rezultat, nalaze grupirane po ozbiljnosti, TLS pojedinosti i preporuke. Jednom mapirajte te vrijednosti i odbacite neispravne odgovore prije nego što dođu do poslovne logike.

<?php

namespace App\Data;

use UnexpectedValueException;

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

    public static function fromPayload(array $payload): self
    {
        foreach (['score', 'findings', 'tls', 'recommendations'] as $field) {
            if (! array_key_exists($field, $payload)) {
                throw new UnexpectedValueException("Missing response field: {$field}");
            }
        }

        if (! is_int($payload['score']) && ! is_float($payload['score'])) {
            throw new UnexpectedValueException('The score must be numeric.');
        }

        if (! is_array($payload['findings'])
            || ! is_array($payload['tls'])
            || ! is_array($payload['recommendations'])) {
            throw new UnexpectedValueException('Invalid analyzer response shape.');
        }

        foreach ($payload['findings'] as $severity => $items) {
            if (! is_string($severity) || ! is_array($items)) {
                throw new UnexpectedValueException(
                    'Findings must be grouped by severity.'
                );
            }
        }

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

Izgradite ograničen HTTP klijent svjestan ponovnih pokušaja

Klijent koristi Laravelov ugrađeni HTTP facade s odvojenim vremenskim ograničenjima za povezivanje i ukupan odgovor. Ponovno pokušava samo kod neuspjelih povezivanja, ograničenja brzine i pogrešaka poslužitelja. Neuspjesi autentikacije i provjere su deterministički i ne bi smjeli trošiti dodatnu kvotu slijepim ponovnim pokušajima.

<?php

namespace App\Services;

use App\Data\SecurityReport;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Throwable;
use UnexpectedValueException;

final class SecurityAnalyzer
{
    public function analyze(string $url): SecurityReport
    {
        $token = config('services.security_analyzer.token');
        $endpoint = config('services.security_analyzer.endpoint');

        if (! is_string($token) || $token === '') {
            throw new RuntimeException('Security analyzer token is not configured.');
        }

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = Http::acceptJson()
                    ->withToken($token)
                    ->connectTimeout(5)
                    ->timeout(20)
                    ->post($endpoint, ['url' => $url]);
            } catch (ConnectionException $exception) {
                if ($attempt === 3) {
                    throw new RuntimeException(
                        'Security analyzer connection failed.',
                        previous: $exception
                    );
                }

                usleep(250000 * $attempt);
                continue;
            }

            if ($response->status() === 429 || $response->serverError()) {
                if ($attempt === 3) {
                    throw new RuntimeException(
                        $response->status() === 429
                            ? 'Security analyzer quota or rate limit reached.'
                            : 'Security analyzer is temporarily unavailable.'
                    );
                }

                $retryAfter = $response->header('Retry-After');
                $milliseconds = is_numeric($retryAfter)
                    ? min(5000, max(0, (int) $retryAfter * 1000))
                    : 250 * $attempt;

                Log::warning('Security analyzer request will be retried.', [
                    'attempt' => $attempt,
                    'status' => $response->status(),
                ]);

                usleep($milliseconds * 1000);
                continue;
            }

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

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

            if ($response->failed()) {
                throw new RuntimeException(
                    "Security analyzer returned HTTP {$response->status()}."
                );
            }

            $payload = $response->json();

            if (! is_array($payload)) {
                throw new RuntimeException('Security analyzer returned invalid JSON.');
            }

            try {
                return SecurityReport::fromPayload($payload);
            } catch (UnexpectedValueException $exception) {
                throw new RuntimeException(
                    'Security analyzer returned an unexpected response.',
                    previous: $exception
                );
            }
        }

        throw new RuntimeException('Security analyzer request did not complete.');
    }
}

Klijent namjerno ne uključuje tijela odgovora, tokene ni autorizacijska zaglavlja u zapisnike. Njegova odgoda ponovnog pokušaja poštuje brojčanu vrijednost Retry-After, a istodobno ograničava stanku na pet sekundi, čime sprječava neograničeno spavanje jedne planirane invokacije.

Usporedite rezultate i upozorite vlasnika

Prvo uspješno skeniranje uspostavlja osnovnu vrijednost bez slanja uznemirujuće e-pošte o „padu”. Kasnija skeniranja šalju poštu samo kada je novi rezultat strogo niži. Osnovna vrijednost sprema se nakon uspješne dostave e-pošte, tako da neuspjeh prijenosa pošte ne potroši upozorenje neprimjetno.

<?php

namespace App\Console\Commands;

use App\Mail\SecurityScoreDropped;
use App\Models\WebsiteMonitor;
use App\Services\SecurityAnalyzer;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Throwable;

final class ScanWebsiteSecurity extends Command
{
    protected $signature = 'security:scan-weekly';
    protected $description = 'Analyze the configured website and alert on a score drop';

    public function handle(SecurityAnalyzer $analyzer): int
    {
        $url = config('security-monitor.url');
        $owner = config('security-monitor.owner_email');

        if (! is_string($url) || ! filter_var($url, FILTER_VALIDATE_URL)
            || ! str_starts_with(strtolower($url), 'https://')
            || ! is_string($owner) || ! filter_var($owner, FILTER_VALIDATE_EMAIL)) {
            $this->error('Monitor URL or owner email is invalid.');
            return self::FAILURE;
        }

        $monitor = WebsiteMonitor::updateOrCreate(
            ['key' => 'primary'],
            ['url' => $url, 'owner_email' => $owner]
        );

        try {
            $report = $analyzer->analyze($url);
            $previous = $monitor->last_score;

            if ($previous !== null && $report->score < $previous) {
                $findingCount = array_sum(
                    array_map('count', $report->findingsBySeverity)
                );

                Mail::to($owner)->send(new SecurityScoreDropped(
                    siteUrl: $url,
                    previousScore: $previous,
                    currentScore: $report->score,
                    findingCount: $findingCount,
                ));
            }

            $monitor->update([
                'last_score' => $report->score,
                'last_checked_at' => now(),
                'last_status' => 'ok',
                'last_error' => null,
            ]);

            $this->info("Security score recorded: {$report->score}");
            return self::SUCCESS;
        } catch (Throwable $exception) {
            $monitor->update([
                'last_status' => 'failed',
                'last_error' => 'scan_or_delivery_failed',
            ]);

            Log::error('Weekly website security check failed.', [
                'monitor_id' => $monitor->id,
                'exception_type' => $exception::class,
            ]);

            $this->error('Security check failed; inspect application logs.');
            return self::FAILURE;
        }
    }
}

Mailable zadržava fokus e-pošte na odluci koju vlasnik mora donijeti. Detaljni nalazi, TLS podaci i preporuke ostaju dostupni kodu aplikacije bez pretvaranja uobičajene e-pošte u spremište sigurnosnih informacija.

<?php

namespace App\Mail;

use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;

final class SecurityScoreDropped extends Mailable
{
    use Queueable, SerializesModels;

    public function __construct(
        public string $siteUrl,
        public float $previousScore,
        public float $currentScore,
        public int $findingCount,
    ) {}

    public function envelope(): Envelope
    {
        return new Envelope(subject: 'Website security score dropped');
    }

    public function content(): Content
    {
        return new Content(markdown: 'mail.security-score-dropped');
    }

    public function attachments(): array
    {
        return [];
    }
}

Zamijenite generirani Markdown prikaz e-pošte sljedećim:

@component('mail::message')
# Website security score dropped

The weekly check for {{ $siteUrl }} reported a lower score.

Previous score: {{ $previousScore }}
Current score: {{ $currentScore }}
Reported findings: {{ $findingCount }}

Review the analyzer results and verify important changes before altering production.

Thanks,
{{ config('app.name') }}
@endcomponent

Zakažite tjedno pokretanje

U routes/console.php zakažite ponedjeljak ujutro u vremenskoj zoni aplikacije i spriječite preklapanje izvršavanja:

<?php

use Illuminate\Support\Facades\Schedule;

Schedule::command('security:scan-weekly')
    ->weeklyOn(1, '08:00')
    ->timezone(config('app.timezone'))
    ->withoutOverlapping(120);

Ako se raspoređivač aplikacije izvodi na više poslužitelja, dodajte onOneServer() i konfigurirajte zajednički upravljački program predmemorije koji podržava atomska zaključavanja. Na jednom poslužitelju dovoljan je uobičajeni Laravelov unos raspoređivača u cronu:

* * * * * cd /var/www/example-app && php artisan schedule:run >> /dev/null 2>&1

Testirajte ponašanje bez pozivanja stvarne usluge

Http::fake() čini test determinističkim, dok Mail::fake() dokazuje da pad rezultata proizvodi namjeravanu obavijest.

<?php

namespace Tests\Feature;

use App\Mail\SecurityScoreDropped;
use App\Models\WebsiteMonitor;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;

final class WeeklySecurityScanTest extends TestCase
{
    use RefreshDatabase;

    public function test_it_emails_the_owner_when_the_score_drops(): void
    {
        config([
            'security-monitor.url' => 'https://shop.example.test',
            'security-monitor.owner_email' => '[email protected]',
            'services.security_analyzer.token' => 'test-token',
        ]);

        WebsiteMonitor::create([
            'key' => 'primary',
            'url' => 'https://shop.example.test',
            'owner_email' => '[email protected]',
            'last_score' => 92,
        ]);

        Http::fake([
            'https://ai.mihajlo.mk/*' => Http::response([
                'score' => 81,
                'findings' => [
                    'high' => [['message' => 'Example finding']],
                    'medium' => [],
                    'low' => [],
                ],
                'tls' => ['enabled' => true],
                'recommendations' => ['Review the reported finding.'],
            ], 200),
        ]);

        Mail::fake();

        $this->artisan('security:scan-weekly')->assertSuccessful();

        $this->assertDatabaseHas('website_monitors', [
            'key' => 'primary',
            'last_score' => 81,
            'last_status' => 'ok',
        ]);

        Mail::assertSent(
            SecurityScoreDropped::class,
            fn (SecurityScoreDropped $mail): bool =>
                $mail->hasTo('[email protected]')
                && $mail->previousScore === 92.0
                && $mail->currentScore === 81.0
        );

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

Dodajte popratne testove za osnovnu vrijednost pri prvom pokretanju, nepromijenjen rezultat, neispravan JSON, HTTP 401, HTTP 422, ograničavanje brzine i iscrpljene ponovne pokušaje nakon pogrešaka poslužitelja. Testovi autentikacije i provjere trebaju potvrditi da je napravljen točno jedan zahtjev.

Sigurnost i operacije u produkciji

Tijekom implementacije pokrenite php artisan migrate --force, konfigurirajte stvarni prijenos pošte, namjerno postavite APP_TIMEZONE i pokrenite php artisan config:cache tek nakon što su vrijednosti okruženja prisutne. Nakon rotacije tokena ažurirajte implementiranu tajnu i ponovno izgradite predmemoriju konfiguracije jer prethodni token prestaje raditi.

Pratite i raspoređivač i naredbu. Zdrav zapisnik aplikacije trebao bi prepoznati uspjeh putem pohranjene vremenske oznake, a neuspjehe putem strukturiranih poruka, bez bilježenja tijela odgovora ili vjerodajnica. Operativno upozorite kada last_checked_at postane stariji od očekivanog tjednog intervala; inače pokvareni cron proces može izgledati kao tjedan bez događaja.

Uobičajeni obrasci neuspjeha jednostavni su: 401 ili 403 obično znači da token nedostaje, opozvan je ili pripada pogrešnoj usluzi; 422 upućuje na poslani URL; 429 zahtijeva pregled plana ili rasporeda; ponovljeni odgovori 5xx upućuju na privremeni neuspjeh uzvodne usluge; a uspješna skeniranja bez e-pošte obično znače da rezultat nije pao ili da je prvo pokretanje samo uspostavilo osnovnu vrijednost.

Završni kontrolni popis za provjeru

  • Nadzirana adresa javni je HTTPS URL koji ste ovlašteni provjeravati.
  • Token ograničen na uslugu postoji samo u pohrani tajni podržanoj okruženjem.
  • Migracija je izvršena, a konfigurirani prijenos pošte može isporučivati vanjskim primateljima.
  • php artisan security:scan-weekly uspješno se dovršava pri ručnom pokretanju.
  • Lažni niži rezultat šalje jednu e-poštu i bilježi novu osnovnu vrijednost.
  • Neuspjesi autentikacije i provjere ne pokušavaju se ponovno.
  • Ograničenja brzine, neuspjesi povezivanja i neuspjesi poslužitelja zaustavljaju se nakon ograničenih ponovnih pokušaja.
  • Sistemski cron pokreće Laravelov raspoređivač svake minute.
  • Zapisnici i nadzor otkrivaju propuštena skeniranja bez izlaganja vjerodajnica.

Najvrjedniji dio ove integracije nije tjedni API poziv. To je disciplina oko njega: provjerena granica, trajna osnovna vrijednost, suzdržani ponovni pokušaji i obavijest vezana uz značajnu promjenu. To pretvara sigurnosni rezultat iz još jednog broja na još jednoj nadzornoj ploči u tihi operativni signal na koji vlasnik poduzeća zaista može djelovati.

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.