Vodiči

Symfony Security Monitoring: Email Small Businesses When Website Scores Drop

Symfony sigurnosni nadzor: e-poštom obavijestite male tvrtke kada ocjene web-mjesta padnu

Web-mjesto male tvrtke može nastaviti posluživati stranice dok se njegova sigurnosna pozicija neprimjetno pogoršava. Promjena certifikata, uklonjeno zaglavlje preglednika ili pogreška pri implementaciji možda neće uzrokovati vidljivi prekid rada, no i dalje mogu smanjiti sigurnosnu ocjenu web-mjesta.

Ovaj vodič izrađuje Symfony aplikaciju usmjerenu na produkciju koja svaki tjedan provjerava jedno javno web-mjesto, pamti prethodnu ocjenu i vlasniku šalje e-poštu samo kada je nova ocjena niža. Integracija koristi ograničenu, neinvazivnu analizu javnog HTTPS-a i sigurnosne pozicije preglednika. To je signal za nadzor, a ne penetracijski test, procjena ranjivosti ili zamjena za ovlašteno sigurnosno testiranje.

Dobijte pristup i kopirajte servisni token

Dovršite uvođenje u uslugu 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.
  3. Odaberite dostupni Free, Plus ili Pro plan i dovršite njegovu aktivaciju.
  4. Otvorite službenu dokumentaciju usluge.
  5. Pronađite ploču Service token i kopirajte ondje prikazani token za određenu uslugu.

Ponovno generiranje tog tokena opoziva prethodno aktivni token. Koordinirajte rotaciju s implementacijom kako bi aplikacija dobila zamjenu prije ili odmah nakon ponovnog generiranja.

API prihvaća Bearer token, zaglavlje X-API-Token ili parametar tokena u upitu. Ovaj projekt koristi Bearer autentikaciju jer je manje vjerojatno da će se zaglavlja, za razliku od nizova upita, pojaviti u zapisnicima proxyja i pristupa.

Potvrdite krajnju točku prije izrade funkcionalnosti

Točna operacija je POST https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website. Njezin JSON zahtjev sadrži url. Testirajte token javnom HTTPS adresom kojom upravljate:

curl --request POST \
  '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.com"}'

Uspješan odgovor sadrži ocjenu, nalaze grupirane prema ozbiljnosti, TLS pojedinosti i preporuke. Te ćemo vrijednosti provjeriti na granici aplikacije, umjesto da pretpostavimo kako svaki ugniježđeni nalaz ima nedokumentiranu strukturu.

Instalirajte prve Symfony komponente koje projekt upotrebljava:

composer require symfony/http-client symfony/mailer symfony/cache symfony/lock
composer require --dev symfony/test-pack

Stavite vjerodajnice i adrese specifične za implementaciju u .env.local za lokalni razvoj. Nemojte potvrđivati ovu datoteku u repozitorij. U produkciji iste varijable unesite putem hosting platforme ili upravitelja tajni.

SECURITY_ANALYZER_TOKEN=YOUR_SERVICE_TOKEN
SECURITY_WEBSITE_URL=https://www.example.com
[email protected]
[email protected]

MAILER_DSN=smtp://USERNAME:[email protected]:587
LOCK_DSN=flock

Namjerno mala arhitektura

Tjednoj provjeri nije potreban posrednik poruka samo kako bi djelovala sofisticirano. Symfony konzolna naredba dobro radi s cronom ili raspoređivačem platforme, dok zaključavanje sprječava preklapajuća izvršavanja.

  • WebsiteSecurityAnalyzer upravlja granicom udaljenog HTTP-a i pravilima ponovnih pokušaja.
  • SecuritySnapshot preslikava i provjerava odgovor domene.
  • SecurityMonitor uspoređuje ocjene, šalje e-poštu i pohranjuje novu osnovnu vrijednost.
  • CheckWebsiteSecurityCommand pruža zaključavanje, izlazni status i izlaz za operatera.

Primjer pohranjuje stanje u Symfony predmemorijski skup. Skup datotečnog sustava i flock prikladni su za jedan trajni poslužitelj. Više replika aplikacije zahtijeva dijeljene pozadinske sustave predmemorije i zaključavanja; inače bi dva hosta mogla uspoređivati različite osnovne vrijednosti i slati dvostruka upozorenja.

Relevantna struktura projekta je:

src/
  Command/CheckWebsiteSecurityCommand.php
  Security/AnalyzerException.php
  Security/SecurityMonitor.php
  Security/SecuritySnapshot.php
  Security/WebsiteAnalyzer.php
  Security/WebsiteSecurityAnalyzer.php
tests/
  Security/WebsiteSecurityAnalyzerTest.php
  Security/SecurityMonitorTest.php

Konfigurirajte Symfonyjevu infrastrukturu

Izradite ograničenog klijenta kako bi token, osnovni URL i vremenska ograničenja ostali izvan logike aplikacije. Vremensko ograničenje neaktivnosti i ukupno trajanje sprječavaju da tjedni proces visi neograničeno.

# config/packages/security_monitor.yaml
framework:
  http_client:
    scoped_clients:
      security_analyzer.client:
        base_uri: 'https://ai.mihajlo.mk/api/website-security-analyzer-api'
        headers:
          Authorization: 'Bearer %env(SECURITY_ANALYZER_TOKEN)%'
        timeout: 10
        max_duration: 20

  cache:
    pools:
      security_monitor.cache:
        adapter: cache.adapter.filesystem
        default_lifetime: 0

  lock: '%env(LOCK_DSN)%'
  mailer:
    dsn: '%env(MAILER_DSN)%'

Povežite imenovane usluge i vrijednosti okruženja putem ubrizgavanja ovisnosti:

# config/services.yaml
services:
  _defaults:
    autowire: true
    autoconfigure: true
    bind:
      $securityAnalyzerClient: '@security_analyzer.client'
      $securityMonitorCache: '@security_monitor.cache'
      $websiteUrl: '%env(SECURITY_WEBSITE_URL)%'
      $ownerEmail: '%env(SECURITY_OWNER_EMAIL)%'
      $fromEmail: '%env(SECURITY_FROM_EMAIL)%'

  App\:
    resource: '../src/'

  App\Security\WebsiteAnalyzer:
    alias: App\Security\WebsiteSecurityAnalyzer

Preslikajte odgovor na granici API-ja

DTO prihvaća numeričke ocjene, ali ne izmišlja raspon ocjena. Provjerava četiri obećana područja odgovora i čuva ugniježđene podatke čiji se točan oblik može mijenjati.

<?php
// src/Security/SecuritySnapshot.php
namespace App\Security;

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

    public static function fromArray(array $data): self
    {
        $score = $data['score'] ?? null;

        if ((!is_int($score) && !is_float($score))
            || !is_finite((float) $score)
            || !is_array($data['findings'] ?? null)
            || !is_array($data['tls'] ?? null)
            || !is_array($data['recommendations'] ?? null)
        ) {
            throw new AnalyzerException('The analyzer returned an invalid response.');
        }

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

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

Definirajte usko sučelje kako bi se pravilo nadzora moglo testirati bez HTTP poziva:

<?php
namespace App\Security;

interface WebsiteAnalyzer
{
    public function analyze(string $url): SecuritySnapshot;
}

final class AnalyzerException extends \RuntimeException
{
    public function __construct(
        string $message,
        public readonly bool $temporary = false,
        ?\Throwable $previous = null,
    ) {
        parent::__construct($message, 0, $previous);
    }
}

Izradite ograničen, selektivan HTTP klijent

Klijent ponavlja pokušaje samo kod transportnih kvarova, ograničenja stope i malog skupa prolaznih kvarova pristupnika. Pogreške autentikacije i provjere odmah ne uspijevaju. Tri ukupna pokušaja s ograničenim odgodama dovoljna su za tjedni zadatak bez pretvaranja jedne provjere u nekontroliranog potrošača kvote.

<?php
// src/Security/WebsiteSecurityAnalyzer.php
namespace App\Security;

use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final class WebsiteSecurityAnalyzer implements WebsiteAnalyzer
{
    public function __construct(
        private HttpClientInterface $securityAnalyzerClient,
        private LoggerInterface $logger,
        private ?\Closure $sleep = null,
    ) {}

    public function analyze(string $url): SecuritySnapshot
    {
        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->securityAnalyzerClient->request(
                    'POST',
                    '/v1/analyze-website',
                    ['json' => ['url' => $url]],
                );
                $status = $response->getStatusCode();
            } catch (TransportExceptionInterface $e) {
                if ($attempt === 3) {
                    throw new AnalyzerException(
                        'The analyzer could not be reached.',
                        true,
                        $e,
                    );
                }

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

            if ($status >= 200 && $status < 300) {
                try {
                    $payload = $response->toArray(false);
                } catch (DecodingExceptionInterface $e) {
                    throw new AnalyzerException(
                        'The analyzer returned invalid JSON.',
                        false,
                        $e,
                    );
                }

                return SecuritySnapshot::fromArray($payload);
            }

            if ($status === 401 || $status === 403) {
                throw new AnalyzerException('Analyzer authentication failed.');
            }

            if ($status === 400 || $status === 422) {
                throw new AnalyzerException('The website request was rejected.');
            }

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

            if ($retryable && $attempt < 3) {
                $headers = $response->getHeaders(false);
                $retryAfter = $headers['retry-after'][0] ?? null;

                $this->logger->warning('Security analysis will be retried.', [
                    'status' => $status,
                    'attempt' => $attempt,
                ]);

                $this->pause($attempt, $retryAfter);
                continue;
            }

            throw new AnalyzerException(
                'Analyzer request failed with HTTP '.$status.'.',
                $retryable || $status >= 500,
            );
        }

        throw new AnalyzerException('Analyzer attempts were exhausted.', true);
    }

    private function pause(int $attempt, ?string $retryAfter): void
    {
        $milliseconds = is_string($retryAfter) && ctype_digit($retryAfter)
            ? min(30000, (int) $retryAfter * 1000)
            : min(2000, 250 * (2 ** ($attempt - 1)));

        if ($this->sleep !== null) {
            ($this->sleep)($milliseconds);
            return;
        }

        usleep($milliseconds * 1000);
    }
}

Primijetite što nedostaje u zapisnicima: token, tijelo odgovora, TLS pojedinosti i nalazi. Operativni zapisnici trebaju informacije o statusu i pokušajima, a ne vjerodajnice ili duplicirano sigurnosno izvješće.

Usporedite ocjene i pošaljite upozorenje

Prvo uspješno pokretanje uspostavlja osnovnu vrijednost bez slanja e-pošte. Kasnija uspješna pokretanja ažuriraju tu osnovnu vrijednost. Ako ocjena padne, e-pošta se šalje prije spremanja stanja. To stvara ponašanje upozorenja barem jednom: neuspjeh slanja pošte ostavlja staru osnovnu vrijednost netaknutom kako bi kasnije pokretanje moglo pokušati ponovno.

<?php
// src/Security/SecurityMonitor.php
namespace App\Security;

use Psr\Cache\CacheItemPoolInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;

final class SecurityMonitor
{
    public function __construct(
        private WebsiteAnalyzer $analyzer,
        private CacheItemPoolInterface $securityMonitorCache,
        private MailerInterface $mailer,
        private LoggerInterface $logger,
        private string $websiteUrl,
        private string $ownerEmail,
        private string $fromEmail,
    ) {}

    public function run(): string
    {
        $snapshot = $this->analyzer->analyze($this->websiteUrl);
        $key = 'website_security_'.hash('sha256', $this->websiteUrl);
        $item = $this->securityMonitorCache->getItem($key);
        $stored = $item->isHit() ? $item->get() : null;
        $previous = is_array($stored) && is_numeric($stored['score'] ?? null)
            ? (float) $stored['score']
            : null;

        $outcome = $previous === null ? 'baseline' : 'steady';

        if ($previous !== null && $snapshot->score < $previous) {
            $body = sprintf(
                "Website: %s\nPrevious score: %s\nCurrent score: %s\n\n".
                "Findings by severity:\n%s\n\nTLS details:\n%s\n\n".
                "Recommendations:\n%s\n",
                $this->websiteUrl,
                $previous,
                $snapshot->score,
                json_encode($snapshot->findings, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
                json_encode($snapshot->tls, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
                json_encode($snapshot->recommendations, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
            );

            $this->mailer->send(
                (new Email())
                    ->from($this->fromEmail)
                    ->to($this->ownerEmail)
                    ->subject('Website security score dropped')
                    ->text($body),
            );

            $outcome = 'alerted';
        }

        $item->set([
            'score' => $snapshot->score,
            'checked_at' => gmdate(DATE_ATOM),
        ]);

        if (!$this->securityMonitorCache->save($item)) {
            throw new \RuntimeException('The security baseline could not be saved.');
        }

        $this->logger->info('Weekly website security check completed.', [
            'website' => $this->websiteUrl,
            'score' => $snapshot->score,
            'outcome' => $outcome,
        ]);

        return $outcome;
    }
}

Izložite tijek rada kao zaključanu naredbu

<?php
// src/Command/CheckWebsiteSecurityCommand.php
namespace App\Command;

use App\Security\SecurityMonitor;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Lock\LockFactory;

#[AsCommand(name: 'app:security:check')]
final class CheckWebsiteSecurityCommand extends Command
{
    public function __construct(
        private SecurityMonitor $monitor,
        private LockFactory $lockFactory,
        private LoggerInterface $logger,
    ) {
        parent::__construct();
    }

    protected function execute(
        InputInterface $input,
        OutputInterface $output,
    ): int {
        $lock = $this->lockFactory->createLock(
            'weekly-website-security',
            300.0,
        );

        if (!$lock->acquire()) {
            $output->writeln('A security check is already running.');
            return Command::SUCCESS;
        }

        try {
            $outcome = $this->monitor->run();
            $output->writeln('Security check completed: '.$outcome);
            return Command::SUCCESS;
        } catch (\Throwable $e) {
            $this->logger->error('Website security check failed.', [
                'exception' => $e::class,
                'message' => $e->getMessage(),
            ]);
            $output->writeln('Security check failed; inspect application logs.');
            return Command::FAILURE;
        } finally {
            $lock->release();
        }
    }
}

Testirajte i granicu i poslovno pravilo

MockHttpClient čini API test determinističkim i provjerava točnu metodu, krajnju točku i tijelo zahtjeva.

<?php
namespace App\Tests\Security;

use App\Security\WebsiteSecurityAnalyzer;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class WebsiteSecurityAnalyzerTest extends TestCase
{
    public function testItMapsAValidResponse(): void
    {
        $client = new MockHttpClient(
            function (string $method, string $url, array $options): MockResponse {
                self::assertSame('POST', $method);
                self::assertSame(
                    'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website',
                    $url,
                );
                self::assertSame(
                    ['url' => 'https://www.example.com'],
                    json_decode($options['body'], true, 512, JSON_THROW_ON_ERROR),
                );

                return new MockResponse(json_encode([
                    'score' => 84,
                    'findings' => ['high' => [], 'medium' => [['id' => 'header']]],
                    'tls' => ['available' => true],
                    'recommendations' => ['Review browser security headers.'],
                ], JSON_THROW_ON_ERROR));
            },
            'https://ai.mihajlo.mk/api/website-security-analyzer-api',
        );

        $snapshot = (new WebsiteSecurityAnalyzer(
            $client,
            new NullLogger(),
            static function (int $milliseconds): void {},
        ))->analyze('https://www.example.com');

        self::assertSame(84.0, $snapshot->score);
        self::assertArrayHasKey('medium', $snapshot->findings);
    }
}

Drugi test trebao bi obuhvatiti važan ishod: uspostavljanje osnovne vrijednosti i slanje točno jedne poruke nakon pada.

<?php
public function testItEmailsOnlyAfterTheScoreDrops(): void
{
    $analyzer = $this->createMock(\App\Security\WebsiteAnalyzer::class);
    $analyzer->method('analyze')->willReturnOnConsecutiveCalls(
        new \App\Security\SecuritySnapshot(90, ['high' => []], [], []),
        new \App\Security\SecuritySnapshot(82, ['high' => [['id' => 'x']]], [], ['Review finding x.']),
    );

    $mailer = $this->createMock(\Symfony\Component\Mailer\MailerInterface::class);
    $mailer->expects(self::once())->method('send');

    $monitor = new \App\Security\SecurityMonitor(
        $analyzer,
        new \Symfony\Component\Cache\Adapter\ArrayAdapter(),
        $mailer,
        new \Psr\Log\NullLogger(),
        'https://www.example.com',
        '[email protected]',
        '[email protected]',
    );

    self::assertSame('baseline', $monitor->run());
    self::assertSame('alerted', $monitor->run());
}

Također dodajte slučajeve za neispravan JSON, nedostajuća polja odgovora, HTTP 401 bez ponovnih pokušaja, HTTP 429 s ograničenim ponovnim pokušajima, neuspjeh prijenosa e-pošte bez ažuriranja osnovne vrijednosti i oštećeno stanje predmemorije.

Implementirajte i rasporedite tjednu provjeru

Pokrenite testove, zagrijte produkcijsku predmemoriju, provjerite isporuku e-pošte i jednom ručno izvršite naredbu. To prvo izvršavanje trebalo bi prijaviti baseline i ne poslati e-poruku o padu ocjene.

php bin/phpunit
APP_ENV=prod APP_DEBUG=0 php bin/console cache:warmup
APP_ENV=prod APP_DEBUG=0 php bin/console app:security:check

Na jednom poslužitelju, cron unos za ponedjeljak može pokrenuti provjeru u mirno vrijeme:

17 3 * * 1 cd /srv/example-app && APP_ENV=prod APP_DEBUG=0 php bin/console app:security:check

Konfigurirajte raspoređivač da zabilježi nenulti izlazni status naredbe. Upozorite na ponovljene neuspjehe jer pogreške autentikacije, iscrpljena kvota plana, DNS poteškoće, neuspjesi prijenosa e-pošte i spremište predmemorije bez mogućnosti pisanja sprječavaju koristan nadzor.

Za implementacije u spremnicima ili s više replika nemojte osnovnu vrijednost držati isključivo unutar efemernog spremnika. Upotrijebite trajnu dijeljenu pohranu za predmemoriju i dijeljeni pozadinski sustav zaključavanja. Pokrenite raspored s jednog određenog radnika ili se oslonite na distribuirano zaključavanje.

Uobičajeni produkcijski neuspjesi

  • 401 ili 403: potvrdite varijablu okruženja, aktivaciju plana i rotaciju tokena. Nemojte naslijepo ponavljati te odgovore.
  • 400 ili 422: provjerite da je SECURITY_WEBSITE_URL valjan javni HTTPS URL i da JSON polje ostaje url.
  • 429: poštujte ograničeno ponašanje ponovnih pokušaja, provjerite korištenje plana i ponovno pokrenite kasnije. Nikada ne pretvarajte ograničavanje stope u beskonačnu petlju.
  • Nema e-pošte nakon prvog pokretanja: to je namjerno. Prva ocjena stvara osnovnu vrijednost za usporedbu.
  • Ponovljena upozorenja: provjerite uspijeva li slanje e-pošte, ali se trajno stanje ne može spremiti, ili replike koriste odvojene skupove predmemorije.
  • Naredba uspijeva, ali cron ne: cron često ima manje varijabli okruženja. Upotrijebite apsolutne putanje i osigurajte da su potrebne varijable dostupne zakazanom procesu.

Završni kontrolni popis za provjeru

  • Token postoji samo u konfiguraciji podržanoj varijablama okruženja i nije prisutan u kontroli izvornog koda ni zapisnicima.
  • Klijent poziva točnu POST krajnju točku s JSON poljem url.
  • Vremena povezivanja i ukupnog odgovora su ograničena.
  • Samo prolazni kvarovi i ograničenja stope dobivaju ograničene ponovne pokušaje.
  • Prvi uspješan rezultat stvara trajnu osnovnu vrijednost bez slanja e-pošte.
  • Niža kasnija ocjena šalje jedno korisno izvješće koje sadrži nalaze, TLS pojedinosti i preporuke.
  • Neuspjesi e-pošte ili pohrane stanja proizvode nenulti rezultat naredbe.
  • Raspoređivač se pokreće tjedno, a preklapajuća izvršavanja su zaključana.

Vrijedan dio ovog nadzora nije cron izraz, pa čak ni e-pošta. To je pažljiva granica oko promjene: provjerite nesigurne vanjske podatke, zapamtite posljednji pouzdan rezultat, obavijestite samo kada se signal pogorša i vidljivo ne uspijte kada sam sustav nadzora prestane raditi. To jednostavan tjedni API poziv pretvara u pouzdan mehanizam ranog upozoravanja koji mala tvrtka stvarno može koristiti.

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.